oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

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.
37package main
38
39import (
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
52func 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