| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "amadan.net/oficina/home/connect" |
| 5 | "amadan.net/oficina/home/connect/linkstore" |
| 6 | "amadan.net/oficina/home/handoff" |
| 7 | nativeauth "amadan.net/rastrillo/native/server" |
| 8 | "context" |
| 9 | "fmt" |
| 10 | "log/slog" |
| 11 | "net/http" |
| 12 | "sync" |
| 13 | "time" |
| 14 | |
| 15 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 16 | "amadan.net/rastrillo/rastrillo/db" |
| 17 | "amadan.net/rastrillo/rastrillo/migrate" |
| 18 | "amadan.net/rastrillo/rastrillo/sessions" |
| 19 | |
| 20 | "amadan.net/oficina/calendar/internal/calendar/seal" |
| 21 | ) |
| 22 | |
| 23 | // App is the whole running app: the database it owns, the router it |
| 24 | // serves, and the pieces handlers need. Build one with New and close it |
| 25 | // with Close. |
| 26 | // |
| 27 | // A constructor plus App.Handler() rather than SKILL.md's illustrative |
| 28 | // bare App() function, because the test harness (testing.go) needs a |
| 29 | // value it can hold onto — a signed-in request builder and the |
| 30 | // development mail recorder both hang off this struct, and every later |
| 31 | // test depends on them. |
| 32 | type App struct { |
| 33 | native *nativeauth.Auth |
| 34 | home *homeConnection |
| 35 | cfg Config |
| 36 | db *db.DB |
| 37 | mux *http.ServeMux |
| 38 | logger *slog.Logger |
| 39 | store *Store |
| 40 | |
| 41 | // mailRecorder is non-nil only in development — see buildMailer. |
| 42 | mailRecorder *recordingMailer |
| 43 | // signinMail is the queue sign-in links go through (signin_mail.go). |
| 44 | signinMail *mailQueue |
| 45 | |
| 46 | // auth is held so the harness can mint a session the same way a |
| 47 | // real sign-in does, rather than by writing a session row by hand. |
| 48 | auth *rauth.Auth |
| 49 | |
| 50 | // h is held for the test harness; requests reach it through mux. |
| 51 | h *handlers |
| 52 | scheduled *scheduledWork |
| 53 | closeOnce sync.Once |
| 54 | closeErr error |
| 55 | } |
| 56 | |
| 57 | // New resolves every fail-closed setting, applies the schema, and wires |
| 58 | // the router. Every refusal it can return is a misconfiguration that |
| 59 | // would otherwise produce an instance which looks healthy and is not. |
| 60 | func New(cfg Config) (*App, error) { |
| 61 | logger := cfg.Logger |
| 62 | if logger == nil { |
| 63 | logger = slog.Default() |
| 64 | } |
| 65 | |
| 66 | origin, err := resolveOrigin(cfg) |
| 67 | if err != nil { |
| 68 | return nil, err |
| 69 | } |
| 70 | cfg.Origin = origin |
| 71 | homeSettings, err := connect.DecodeCredentials(cfg.Home, origin) |
| 72 | if err != nil { |
| 73 | return nil, fmt.Errorf("calendar: Home configuration: %w", err) |
| 74 | } |
| 75 | |
| 76 | instanceKey, err := resolveInstanceKey(cfg) |
| 77 | if err != nil { |
| 78 | return nil, err |
| 79 | } |
| 80 | |
| 81 | mailer, recorder, err := buildMailer(cfg, logger) |
| 82 | if err != nil { |
| 83 | return nil, err |
| 84 | } |
| 85 | |
| 86 | list, err := resolveAllowlist(cfg) |
| 87 | if err != nil { |
| 88 | return nil, err |
| 89 | } |
| 90 | |
| 91 | dek, err := resolveDEK(cfg) |
| 92 | if err != nil { |
| 93 | return nil, err |
| 94 | } |
| 95 | sealer, err := seal.New(dek) |
| 96 | if err != nil { |
| 97 | return nil, fmt.Errorf("calendar: %w", err) |
| 98 | } |
| 99 | |
| 100 | d, err := db.Open(cfg.DBPath, logger) |
| 101 | if err != nil { |
| 102 | return nil, fmt.Errorf("calendar: open database: %w", err) |
| 103 | } |
| 104 | |
| 105 | // Apply BootSchema, not Schema: BootSchema is every subsystem's |
| 106 | // migrations plus this app's own, in apply order, while Schema |
| 107 | // stays narrow so the migration generator never sees a subsystem's |
| 108 | // tables and proposes dropping them. |
| 109 | boot := BootSchema |
| 110 | if homeSettings != nil { |
| 111 | boot = migrate.Merge(BootSchema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace)) |
| 112 | } |
| 113 | migrated, err := migrate.Apply(context.Background(), d, boot) |
| 114 | if err != nil { |
| 115 | d.Close() |
| 116 | return nil, fmt.Errorf("calendar: apply schema: %w", err) |
| 117 | } |
| 118 | // One line per boot. Without it a fleet-wide adoption — every |
| 119 | // database stamped, no DDL run, exactly once, never again — |
| 120 | // produces no output at all, which is the last event that should |
| 121 | // be silent. |
| 122 | logger.Info("migrate", "applied", migrated.Applied, |
| 123 | "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| 124 | |
| 125 | // The writer pool, not a second connection: sessions and auth are |
| 126 | // database/sql packages and take a *sql.DB, and db.Open's handle is |
| 127 | // already the one with the single-writer routing. Opening our own |
| 128 | // here would be a second writer against a WAL database that expects |
| 129 | // exactly one. |
| 130 | writer, err := d.G.DB() |
| 131 | if err != nil { |
| 132 | d.Close() |
| 133 | return nil, fmt.Errorf("calendar: writer handle: %w", err) |
| 134 | } |
| 135 | |
| 136 | if err := checkDataKey(d.G, sealer); err != nil { |
| 137 | d.Close() |
| 138 | return nil, err |
| 139 | } |
| 140 | |
| 141 | // ONE bounded sender for everything this app sends — sign-in, |
| 142 | // reminders, booking mail. The recorder stays inside it, so tests |
| 143 | // still read what was sent (booking spec §6). |
| 144 | mailer = newBoundedSender(mailer) |
| 145 | // Sign-in links alone go through a queue: sent after the response, |
| 146 | // to permitted addresses only (signin_mail.go). Closed by Close. |
| 147 | // Refusals are counted in the log without the address (AGENTS.md |
| 148 | // §Logs) — by the queue's worker, through its non-blocking logf. |
| 149 | signinMail := newMailQueueLogging(mailer, list.allows, nil, logger, "calendar: signin refused by allowlist") |
| 150 | // handlers before auth: auth's Refused and RenderConfirm are pages |
| 151 | // the handlers draw. viewerFrom is auth's, so it is set just after. |
| 152 | store := NewStore(d.G, sealer, logger) |
| 153 | h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer, gate: newGate()} |
| 154 | authPlugin, err := newAuthPlugin(origin, writer, instanceKey, signinMail, list, logger, homeSettings != nil, h) |
| 155 | if err != nil { |
| 156 | signinMail.close() |
| 157 | d.Close() |
| 158 | return nil, fmt.Errorf("calendar: wire sign-in: %w", err) |
| 159 | } |
| 160 | h.viewerFrom = authPlugin.SessionFrom |
| 161 | |
| 162 | a := &App{ |
| 163 | cfg: cfg, |
| 164 | db: d, |
| 165 | logger: logger, |
| 166 | store: store, |
| 167 | mailRecorder: recorder, |
| 168 | signinMail: signinMail, |
| 169 | auth: authPlugin, |
| 170 | } |
| 171 | a.h = h |
| 172 | if homeSettings != nil { |
| 173 | a.home, err = a.newHome(homeSettings, list) |
| 174 | if err != nil { |
| 175 | signinMail.close() |
| 176 | d.Close() |
| 177 | return nil, err |
| 178 | } |
| 179 | h.homeEnabled = true |
| 180 | h.directory = homeSettings.Directory |
| 181 | h.viewerFrom = func(r *http.Request) (rauth.Identity, bool) { |
| 182 | // requirePerson has already resolved the person on every page |
| 183 | // behind it; resolve again only where it has not run (public |
| 184 | // pages, errors raised before it). |
| 185 | if p, ok := CurrentPerson(r); ok { |
| 186 | return rauth.Identity{Address: p.Address}, true |
| 187 | } |
| 188 | if a.home == nil { |
| 189 | return viewer(r) |
| 190 | } |
| 191 | if s, ok := sessions.Current(r); !ok || s.Method != connect.HomeMethod { |
| 192 | return viewer(r) |
| 193 | } |
| 194 | p, err := a.homePerson(r) |
| 195 | return rauth.Identity{Address: p.Address}, err == nil |
| 196 | } |
| 197 | } |
| 198 | // Native wiring comes before the scheduler starts, so a native |
| 199 | // failure leaves no timer goroutine behind to leak. |
| 200 | if err := a.wireNative(); err != nil { |
| 201 | signinMail.close() |
| 202 | d.Close() |
| 203 | return nil, err |
| 204 | } |
| 205 | a.scheduled = newScheduledWork(store.nextDueAt, cfg.Env == "development", func(err error) { |
| 206 | logger.Error("calendar: scheduled work registration failed", "stage", "schedule.register", "err", scrubErr(err)) |
| 207 | }) |
| 208 | if err := a.scheduled.start(); err != nil { |
| 209 | signinMail.close() |
| 210 | d.Close() |
| 211 | return nil, err |
| 212 | } |
| 213 | h.scheduled = a.scheduled |
| 214 | a.mux = a.routes(h, authPlugin, list, origin) |
| 215 | return a, nil |
| 216 | } |
| 217 | |
| 218 | // NextDue answers the platform's scheduled-wake poll: the next moment |
| 219 | // this instance has work. rastrillo mounts it as GET /api/next-due, |
| 220 | // outside app middleware, and the activator asks before hibernating. |
| 221 | func (a *App) NextDue() time.Time { |
| 222 | return a.scheduled.probe() |
| 223 | } |
| 224 | |
| 225 | // Handler is the app's router, for rastrillo.Options.Mux and for |
| 226 | // tests. It is typed *http.ServeMux rather than http.Handler because |
| 227 | // Options.Mux is typed that way. |
| 228 | func (a *App) Handler() *http.ServeMux { return a.mux } |
| 229 | |
| 230 | // Close releases the database. Safe to call on a partially built app. |
| 231 | func (a *App) Close() error { |
| 232 | a.closeOnce.Do(func() { |
| 233 | if a.signinMail != nil { |
| 234 | a.signinMail.close() |
| 235 | } |
| 236 | if a.scheduled != nil { |
| 237 | a.scheduled.close() |
| 238 | } |
| 239 | if a.db != nil { |
| 240 | a.closeErr = a.db.Close() |
| 241 | } |
| 242 | }) |
| 243 | return a.closeErr |
| 244 | } |
| 245 | |