package main import ( "context" "embed" "flag" "fmt" "io/fs" "log" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "fn-registry/functions/infra" ) //go:embed all:dist var frontendDist embed.FS func main() { // Subcommand `kanban mcp` runs as MCP server over stdio (spawned by claude -p). if len(os.Args) > 1 && os.Args[1] == "mcp" { if err := runMCPServer(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "kanban mcp: %v\n", err) os.Exit(1) } return } flags := flag.NewFlagSet("kanban", flag.ExitOnError) port := flags.Int("port", 8095, "HTTP port") dbPath := flags.String("db", "operations.db", "SQLite database path") initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)") flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)") flags.Parse(os.Args[1:]) featureFlags, err := loadFeatureFlags(*flagsPath) if err != nil { log.Fatalf("load feature flags: %v", err) } for name, fl := range featureFlags.Flags { log.Printf("feature flag %q enabled=%v", name, fl.Enabled) } db, err := openDB(*dbPath) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() bootstrapAdmin(db, *initialAdmin) startSessionCleanup(db) internalToken := os.Getenv("KANBAN_INTERNAL_TOKEN") if internalToken == "" { internalToken = generateInternalToken() } wd := chatWorkdir(*dbPath) logger := newChatLogger(filepath.Join(wd, "chat.log")) log.Printf("chat tool log: %s", logger.path) mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags)) feHandler := frontendHandler() if feHandler != nil { mux.Handle("/", feHandler) log.Printf("serving frontend from embedded dist/") } else { log.Printf("no frontend build found, API-only mode") } authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{ DB: db.conn, CookieName: cookieName, SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/health", "/assets/", "/index.html"}, UserCtxKey: userCtxKey, }) chain := infra.HTTPMiddlewareChain( infra.HTTPLoggerMiddleware(os.Stdout), infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}), apiOnlyAuth(authMW), ) handler := chain(mux) addr := fmt.Sprintf(":%d", *port) log.Printf("kanban server starting on http://0.0.0.0%s", addr) log.Printf("database: %s", *dbPath) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() if err := infra.HTTPServe(addr, handler, ctx); err != nil { log.Fatalf("server: %v", err) } } // apiOnlyAuth applies auth middleware only to /api/* paths so the SPA shell // can be served without a session (the SPA itself handles login UI). func apiOnlyAuth(mw infra.Middleware) infra.Middleware { return func(next http.Handler) http.Handler { gated := mw(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { gated.ServeHTTP(w, r) return } next.ServeHTTP(w, r) }) } } func bootstrapAdmin(db *DB, spec string) { spec = strings.TrimSpace(spec) if spec == "" { return } count, err := db.CountUsers() if err != nil { log.Printf("bootstrap admin: count users: %v", err) return } if count > 0 { return } parts := strings.SplitN(spec, ":", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { log.Printf("bootstrap admin: invalid spec, expected user:pass") return } u, err := db.CreateUser(parts[0], parts[1], parts[0]) if err != nil { log.Printf("bootstrap admin: %v", err) return } log.Printf("bootstrap admin: created user %q", u.Username) } func startSessionCleanup(db *DB) { go func() { t := time.NewTicker(1 * time.Hour) defer t.Stop() for range t.C { if n, err := infra.SessionCleanup(db.conn); err != nil { log.Printf("session cleanup: %v", err) } else if n > 0 { log.Printf("session cleanup: purged %d expired", n) } } }() } func frontendHandler() http.Handler { sub, err := fs.Sub(frontendDist, "dist") if err != nil { return nil } entries, _ := fs.ReadDir(sub, ".") if len(entries) == 0 { return nil } return infra.SPAHandler(sub, "index.html") }