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 // Version is the build-time identifier of the kanban app. It is injected // from app.md's `version:` field via -ldflags "-X main.Version=..." by run.sh // (and by docker/CI). Defaults to "dev" for hand-built binaries. var Version = "dev" 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) hub := NewEventHub() dispatcher := NewDispatcher(db, hub) dispatcher.Start() defer dispatcher.Stop() mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags, hub, dispatcher)) 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", "/api/version", "/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 cacheHeadersMiddleware(infra.SPAHandler(sub, "index.html")) } // cacheHeadersMiddleware ensures the SPA shell is never cached while the // hashed assets (which are content-addressed by Vite) are cached for a long // time. Without this, browsers happily reuse an old index.html — pinned to a // stale /assets/index-.js URL — and never pick up new releases. // // Policy: // // /assets/* → public, max-age=1y, immutable (filename changes per build) // everything else → no-store, must-revalidate (forces revalidation on every // navigation so the latest hash is always discovered) func cacheHeadersMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") } next.ServeHTTP(w, r) }) }