// Command tmplatformd is the TextMachine control plane: HTTP for the frontend, Postgres for the // read model, and (from P-1 onward) a worker that supervises tmctl processes. It contains no // translation logic: the engine is spawned, never linked (D39.81). package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "textmachine/platform/internal/auth" "textmachine/platform/internal/config" "textmachine/platform/internal/httpapi" "textmachine/platform/internal/login" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/reqid" ) // sessionSweep is how often expired sessions are deleted. The table is small and the work is a // single DELETE, so the interval is about not accumulating rows, not about load. const sessionSweep = time.Hour func main() { // Structured logs on stderr, like the engine's: stdout stays free for anything machine-read. // The handler is wrapped so every *Context call carries its request id without saying so. log := slog.New(reqid.WithContext(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))) if err := run(log); err != nil { log.Error("fatal", "err", err) os.Exit(1) } } func run(log *slog.Logger) error { cfg, err := config.Load() if err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var db *pgstore.Store if cfg.DSN == "" { // Deliberate: the process still serves liveness so a supervisor can start it before the // database exists. /readyz is the honest signal, and it says no. log.Warn("no TM_PLATFORM_DSN: starting without a database, /readyz will report not ready") } else { if cfg.Migrate { if err := pgstore.Migrate(ctx, cfg.DSN); err != nil { return err } log.Info("migrations applied") } if db, err = pgstore.Open(ctx, cfg.DSN); err != nil { return err } defer db.Close() } cookies := auth.Cookies{Insecure: cfg.InsecureCookies} if cfg.InsecureCookies { log.Warn("TM_PLATFORM_INSECURE_COOKIES: serving the session cookie without Secure, under a dev name — never in production") } authn := &auth.Authenticator{ IdleTTL: cfg.SessionIdleTTL, Cookies: cookies, Log: log, Deny: httpapi.ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"), } deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins, HSTS: !cfg.InsecureCookies} if db != nil { deps.DB = db authn.Sessions = db go sweepSessions(ctx, db, log) } switch { case !cfg.LoginEnabled(): log.Warn("no TM_PLATFORM_OIDC_ISSUER: sign-in is not mounted") case db == nil: // A login writes rows. Mounting it without a database would answer every attempt with a 503 // from deep inside the flow instead of saying so once, here. return errors.New("sign-in is configured but TM_PLATFORM_DSN is not: a login needs the database") default: lg, err := login.New(login.Config{ Provider: cfg.OIDCProvider, Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, ClientSecret: cfg.OIDCClientSecret, RedirectURL: cfg.OIDCRedirectURL, AfterLogin: cfg.AfterLogin, SessionIdleTTL: cfg.SessionIdleTTL, SessionMaxAge: cfg.SessionMaxAge, SignupGrantMicroUSD: cfg.SignupGrantMicroUSD, }, db, cookies, log) if err != nil { return err } lg.SetFail(httpapi.WriteProblem) deps.Login = lg go sweepLogins(ctx, db, log) } handler, err := httpapi.New(deps) if err != nil { return err } // A second signal must kill rather than wait: once the drain starts, the handler is // unregistered and the next SIGTERM goes back to being fatal. go func() { <-ctx.Done() stop() }() srv := httpapi.NewServer(cfg.Addr, handler, log) ln, err := srv.Listen(ctx) if err != nil { return err } log.Info("listening", "addr", ln.Addr().String()) return srv.Run(ctx, ln) } // sweepSessions deletes rows past their absolute expiry (PD-7). A failed sweep is logged and // retried on the next tick: it is housekeeping, and it must never take the service down. func sweepSessions(ctx context.Context, db *pgstore.Store, log *slog.Logger) { t := time.NewTicker(sessionSweep) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: c, cancel := context.WithTimeout(ctx, 30*time.Second) n, err := db.SweepSessions(c, time.Now()) cancel() switch { case err != nil: log.Error("session sweep failed", "err", err) case n > 0: log.Info("session sweep", "deleted", n) } } } } // loginJournalRetention is how long a sign-in stays in the journal. Long enough to answer "was // that me last month", short enough that an unauthenticated endpoint cannot grow the table without // end. const loginJournalRetention = 180 * 24 * time.Hour // sweepLogins deletes abandoned authorization requests and journal entries past retention. func sweepLogins(ctx context.Context, db *pgstore.Store, log *slog.Logger) { t := time.NewTicker(15 * time.Minute) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: c, cancel := context.WithTimeout(ctx, time.Minute) states, err := db.DeleteExpiredLoginStates(c, time.Now()) if err != nil { log.Error("login state sweep failed", "err", err) } events, err := db.DeleteOldLoginEvents(c, time.Now().Add(-loginJournalRetention)) cancel() if err != nil { log.Error("login journal sweep failed", "err", err) } if states > 0 || events > 0 { log.Info("login sweep", "states", states, "events", events) } } } }