| 1 | // Command home is the Oficina suite home binary. |
| 2 | // |
| 3 | // It serves sign-in, the passkey second factor and recovery codes (§13 |
| 4 | // step 2), plus a placeholder front page, and refuses to start without |
| 5 | // the configuration those need. See |
| 6 | // docs/superpowers/specs/2026-08-30-home-design.md. |
| 7 | // |
| 8 | // The platform activation contract has no $PORT: rastrillo.Resolve and |
| 9 | // Serve own it end to end — activation argv, LISTEN_FDS, |
| 10 | // $STATE_DIRECTORY, GET /healthz, GET /api/version, SIGTERM drain, |
| 11 | // baseline security headers. |
| 12 | // |
| 13 | // THE DATABASE PATH IS A DEFAULT HERE, NOT A FLAG, and that is not a |
| 14 | // style choice. The home does not hibernate (AGENTS.md), so it runs as |
| 15 | // an ordinary unit tenant: the platform's carlos-app@.service has |
| 16 | // ExecStart=/opt/carlos/live/%i serve with NO arguments, and only the |
| 17 | // hibernate exec child is invoked with --socket and --db. A binary that |
| 18 | // required --db would therefore refuse every boot on the platform while |
| 19 | // working perfectly for a developer who typed the flag. Resolve |
| 20 | // overwrites Options.DBPath only when -db is actually passed, and joins |
| 21 | // a relative path to $STATE_DIRECTORY, so "home.db" below is the |
| 22 | // platform path and the flag still wins locally. |
| 23 | // |
| 24 | // THIS BINARY REFUSES TO START WITHOUT ITS SECRETS: HOME_DEK, |
| 25 | // HOME_PEPPER, HOME_SIGNIN_KEY, CARLOS_SMTP_PASS and the SMTP host and |
| 26 | // from address. That is deliberate — every one of those absences |
| 27 | // produces an app that answers /healthz and is broken or leaking. |
| 28 | // |
| 29 | // What that costs, stated plainly: the unit is Restart=always, and the |
| 30 | // activator deliberately does not treat a wedged unit as down. Deploying |
| 31 | // this build before those secrets exist would cycle the process into a |
| 32 | // binary that exits 1, systemd would respin it, and the site would 502 |
| 33 | // until a rollback. It is NOT true that "the old binary keeps serving" — |
| 34 | // that is what a promote does, not a deploy. Nothing in .amadan/ci.d |
| 35 | // deploys on merge, so merging is safe; deploying is the step that |
| 36 | // needs the secrets first. |
| 37 | package main |
| 38 | |
| 39 | import ( |
| 40 | "context" |
| 41 | "log/slog" |
| 42 | "net/http" |
| 43 | "os" |
| 44 | |
| 45 | "amadan.net/rastrillo/rastrillo" |
| 46 | rdb "amadan.net/rastrillo/rastrillo/db" |
| 47 | "amadan.net/rastrillo/rastrillo/migrate" |
| 48 | |
| 49 | "amadan.net/oficina/home/internal/home" |
| 50 | ) |
| 51 | |
| 52 | func main() { |
| 53 | if len(os.Args) > 1 && os.Args[1] == "operator-v1" { |
| 54 | if len(os.Args) != 4 || os.Args[2] != "--db" { |
| 55 | os.Exit(2) |
| 56 | } |
| 57 | os.Exit(home.RunOperatorCommand(context.Background(), os.Args[3])) |
| 58 | } |
| 59 | logger := slog.Default() |
| 60 | |
| 61 | // DBPath is set here, not required as a flag — see the package |
| 62 | // comment. Relative, so Resolve joins it to $STATE_DIRECTORY. |
| 63 | opts, err := rastrillo.Resolve(rastrillo.Options{Logger: logger, DBPath: "home.db"}) |
| 64 | if err != nil { |
| 65 | logger.Error("resolve", "err", err) |
| 66 | os.Exit(1) |
| 67 | } |
| 68 | |
| 69 | // Every secret and setting whose absence is silent is validated |
| 70 | // before anything serves. Each refusal here is a case where |
| 71 | // continuing would produce an app that answers /healthz perfectly |
| 72 | // well and is broken or leaking: no DEK and addresses persist in the |
| 73 | // clear, no pepper and every person row is orphaned, no SMTP host |
| 74 | // and magic links are written to platform-held logs instead of |
| 75 | // emailed. The database path cannot be empty on this path — it is |
| 76 | // set below — but LoadSecrets checks it anyway, for the caller that |
| 77 | // builds its own Options. |
| 78 | runtime, err := home.LoadRuntime(opts.DBPath, logger) |
| 79 | if err != nil { |
| 80 | logger.Error("config", "err", err) |
| 81 | os.Exit(1) |
| 82 | } |
| 83 | |
| 84 | // Before db.Open, never after: Open calls Ping, which opens the |
| 85 | // writer pool's single long-lived connection there and then, and a |
| 86 | // hook registered afterwards misses it forever. That connection is |
| 87 | // the only one that performs DELETEs. |
| 88 | home.RegisterSecureDelete() |
| 89 | |
| 90 | d, err := rdb.Open(opts.DBPath, logger) |
| 91 | if err != nil { |
| 92 | logger.Error("open db", "err", err) |
| 93 | os.Exit(1) |
| 94 | } |
| 95 | defer d.Close() |
| 96 | |
| 97 | // Ledgered, once at boot, forward-only. A boot that cannot reconcile |
| 98 | // its schema refuses rather than guessing, same as a failed open. |
| 99 | if _, err := migrate.Apply(context.Background(), d, runtime.Schema()); err != nil { |
| 100 | logger.Error("migrate", "err", err) |
| 101 | os.Exit(1) |
| 102 | } |
| 103 | |
| 104 | home.LogSchemaInventory(context.Background(), d, runtime, logger) |
| 105 | app, err := home.NewRuntime(runtime, d, logger) |
| 106 | if err != nil { |
| 107 | logger.Error("wire app", "err", err) |
| 108 | os.Exit(1) |
| 109 | } |
| 110 | |
| 111 | // rastrillo.Options.Mux wants a *http.ServeMux; the app's own |
| 112 | // router is a chi.Router (an http.Handler), so it mounts under one. |
| 113 | mux := http.NewServeMux() |
| 114 | mux.Handle("/", app.Router()) |
| 115 | opts.Mux = mux |
| 116 | opts.CSP = app.SecurityPolicy() |
| 117 | |
| 118 | // Blanked because this file opened the handle itself: Serve would |
| 119 | // otherwise open a second one against the same file. |
| 120 | opts.DBPath = "" |
| 121 | |
| 122 | if err := rastrillo.Serve(opts); err != nil { |
| 123 | logger.Error("serve", "err", err) |
| 124 | os.Exit(1) |
| 125 | } |
| 126 | } |
| 127 | |