oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
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.
32type 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.
60func 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.
221func (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.
228func (a *App) Handler() *http.ServeMux { return a.mux }
229
230// Close releases the database. Safe to call on a partially built app.
231func (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