| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log/slog" |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | |
| 10 | "amadan.net/oficina/estilo.git/chrome" |
| 11 | "amadan.net/oficina/estilo.git/sky" |
| 12 | "amadan.net/oficina/estilo.git/theme" |
| 13 | |
| 14 | "github.com/go-chi/chi/v5" |
| 15 | |
| 16 | "amadan.net/oficina/home/connect" |
| 17 | "amadan.net/rastrillo/rastrillo/auth" |
| 18 | "amadan.net/rastrillo/rastrillo/db" |
| 19 | "amadan.net/rastrillo/rastrillo/migrate" |
| 20 | "amadan.net/rastrillo/rastrillo/sessions" |
| 21 | |
| 22 | "amadan.net/oficina/memoria/internal/memoria/index" |
| 23 | "amadan.net/oficina/memoria/internal/memoria/seal" |
| 24 | ) |
| 25 | |
| 26 | // App wires the whole app: schema, sealing, index, router, static files. |
| 27 | // |
| 28 | // The order matters. Migrations first, then the index is filled from the |
| 29 | // sealed rows — the index lives only in memory (see internal/memoria/index |
| 30 | // for why), so a boot that skipped the rebuild would serve an empty |
| 31 | // corpus and call it "nothing matched". |
| 32 | func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*http.ServeMux, *app, error) { |
| 33 | migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| 34 | if err != nil { |
| 35 | return nil, nil, err |
| 36 | } |
| 37 | logger.Info("migrate", "applied", migrated.Applied, |
| 38 | "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| 39 | |
| 40 | ix, err := index.Open() |
| 41 | if err != nil { |
| 42 | return nil, nil, err |
| 43 | } |
| 44 | |
| 45 | // Custody starts nil and stays nil until UseSiblings supplies one. |
| 46 | // nil withholds every sibling passage rather than rendering it, so |
| 47 | // the un-wired state is the closed one — slice 0 stores nothing owned |
| 48 | // by anybody else, which makes nil correct rather than a gap. See |
| 49 | // Store.custody, and UseSiblings for why this is a setter. |
| 50 | store := NewStore(d, sealer, ix, nil) |
| 51 | indexed, err := store.Rebuild(context.Background()) |
| 52 | if err != nil { |
| 53 | // A failed rebuild is fatal, not a warning. Serving with a |
| 54 | // partly-filled index means every search silently under-reports, |
| 55 | // which is the plausible-empty-list failure this codebase treats |
| 56 | // as the worst available. |
| 57 | return nil, nil, err |
| 58 | } |
| 59 | logger.Info("index rebuilt", "passages", indexed) |
| 60 | |
| 61 | // Reclaim runs that were in flight when the process last stopped. |
| 62 | // |
| 63 | // Sound by construction rather than by timeout: a process that has |
| 64 | // just started cannot be running a sync, so anything still marked |
| 65 | // in flight was killed by the platform sleeping the app. Without |
| 66 | // this the connector wedges permanently on its NORMAL path — the |
| 67 | // goroutine dies without writing, the row stays "fetching", and |
| 68 | // every later sync is refused as already running. |
| 69 | reclaimed, err := store.ReclaimRuns(context.Background()) |
| 70 | if err != nil { |
| 71 | return nil, nil, err |
| 72 | } |
| 73 | if reclaimed > 0 { |
| 74 | logger.Info("reclaimed interrupted syncs", "runs", reclaimed) |
| 75 | } |
| 76 | |
| 77 | a := &app{db: d, logger: logger, store: store, syncer: NewSyncer(store, logger), origin: origin} |
| 78 | |
| 79 | if err := a.wireNative(origin); err != nil { |
| 80 | return nil, nil, err |
| 81 | } |
| 82 | r := chi.NewRouter() |
| 83 | // Outermost, so a panic anywhere in the router is the 500 page with a |
| 84 | // reference. The page reads the session itself (admissionOf), so it |
| 85 | // shows the viewer's own chrome however deep the panic was. |
| 86 | r.Use(a.recoverToErrorPage) |
| 87 | r.Use(a.native.Resume) |
| 88 | r.Use(a.homeMiddleware(origin)) |
| 89 | // Unknown routes, and a GET of a POST-only path, are the 404 page (a |
| 90 | // browser) or plain text (anything else). They were Go's plain |
| 91 | // "404 page not found" in the browser's monospace. |
| 92 | r.NotFound(a.routeNotFound) |
| 93 | r.MethodNotAllowed(a.methodNotAllowed(r)) |
| 94 | |
| 95 | r.Post("/native/request", a.native.ServeHTTP) |
| 96 | r.Post("/native/exchange", a.native.ServeHTTP) |
| 97 | r.Get("/native/start", a.native.ServeHTTP) |
| 98 | // The approval page is the native kit's, styled by one inline |
| 99 | // <style>: allowed by its hash on this route alone (native.go). |
| 100 | r.Get("/native/authorize", nativeApprovalCSP(a.native.ServeHTTP)) |
| 101 | r.Post("/native/authorize", nativeApprovalCSP(a.native.ServeHTTP)) |
| 102 | |
| 103 | // Appearance (estilo chrome §8.3): inside the app-wide CSRF check |
| 104 | // (homeMiddleware) and OUTSIDE the session gate, so a session that |
| 105 | // expired mid-page cannot turn the click into a sign-in loop. It sets |
| 106 | // one host-only preference cookie and redirects to a SafeReturn path. |
| 107 | r.Post("/appearance", chrome.HandleAppearance) |
| 108 | |
| 109 | // Public: the sign-in page and auth's own callbacks. |
| 110 | r.Get("/signin", a.signin) |
| 111 | r.Get("/signin/local", func(w http.ResponseWriter, r *http.Request) { |
| 112 | if a.home == nil { |
| 113 | a.routeNotFound(w, r) |
| 114 | return |
| 115 | } |
| 116 | a.localSignin(w, r) |
| 117 | }) |
| 118 | // Where Sign out lands, standalone and with Home alike. |
| 119 | r.Get("/signed-out", a.signedOut) |
| 120 | // The Open source page is public: the sign-in page links to it (spec |
| 121 | // §8.4), and it says nothing about the team. It still shows the |
| 122 | // viewer's own chrome when somebody is signed in. |
| 123 | r.Get("/help/open-source", a.credits) |
| 124 | r.With(a.requireSession).Get("/account/home", func(w http.ResponseWriter, r *http.Request) { |
| 125 | if a.home == nil { |
| 126 | a.routeNotFound(w, r) |
| 127 | return |
| 128 | } |
| 129 | a.homeAccount(w, r) |
| 130 | }) |
| 131 | r.With(a.requireSession).Post("/account/home", func(w http.ResponseWriter, r *http.Request) { |
| 132 | if a.home == nil { |
| 133 | a.routeNotFound(w, r) |
| 134 | return |
| 135 | } |
| 136 | a.home.routes.LinkStart(w, r) |
| 137 | }) |
| 138 | r.With(a.requireSession).Get("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) { |
| 139 | if a.home == nil { |
| 140 | a.routeNotFound(w, r) |
| 141 | return |
| 142 | } |
| 143 | a.home.bridge.ConfirmPage(w, r) |
| 144 | }) |
| 145 | r.With(a.requireSession).Post("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) { |
| 146 | if a.home == nil { |
| 147 | a.routeNotFound(w, r) |
| 148 | return |
| 149 | } |
| 150 | a.home.routes.ConfirmPost(w, r) |
| 151 | }) |
| 152 | |
| 153 | // Everything else requires a session. memoria holds the team's work |
| 154 | // in one place, so there is no anonymous surface at all — not even a |
| 155 | // landing page. §4.3: admission is not authorization, but it is the |
| 156 | // door. |
| 157 | r.Group(func(r chi.Router) { |
| 158 | r.Use(a.requireSession) |
| 159 | r.Get("/api/native/session", a.nativeSession) |
| 160 | r.Post("/api/native/search", a.nativeSearch) |
| 161 | r.Post("/api/native/notes", a.nativeNote) |
| 162 | r.Get("/", a.index) |
| 163 | r.Get("/add", a.addContent) |
| 164 | r.Get("/search", a.searchResults) |
| 165 | r.Post("/documents", a.createDocument) |
| 166 | r.Get("/documents/{id}", a.showDocument) |
| 167 | r.Get("/documents/{id}/delete", a.confirmDelete) |
| 168 | r.Post("/documents/{id}/delete", a.deleteDocument) |
| 169 | |
| 170 | // Connector routes sit INSIDE this group, so they inherit |
| 171 | // csrf.Protect and requireSession. A route mounted on the outer |
| 172 | // mux (as /static/ is) would bypass both. |
| 173 | r.Group(func(r chi.Router) { |
| 174 | r.Use(a.requireOwner) |
| 175 | r.Get("/connectors", a.connectors) |
| 176 | r.Get("/connectors/notion", a.notionConnector) |
| 177 | r.Post("/connectors/notion", a.saveNotionConnector) |
| 178 | r.Post("/connectors/notion/sync", a.startNotionSync) |
| 179 | r.Post("/connectors/notion/disconnect", a.disconnectNotion) |
| 180 | }) |
| 181 | }) |
| 182 | |
| 183 | mux := http.NewServeMux() |
| 184 | mux.HandleFunc("GET /static/credits.css", serveCreditsCSS) |
| 185 | mux.Handle("GET /static/", assets.Handler()) |
| 186 | // Onest, Calma's typeface, served by estilo under its versioned |
| 187 | // prefix. A literal, so the route gate can read it; |
| 188 | // TestFontRouteIsEstilosPrefix keeps it equal to theme.FontPrefix. |
| 189 | mux.Handle("GET /static/estilo/fonts/v1/", theme.FontHandler()) |
| 190 | // The sign-in door's sky (estilo sky), under its versioned prefix: |
| 191 | // public artwork, served before any session gate like the font. A |
| 192 | // literal for the same reason; TestSkyRouteIsEstilosPrefix keeps it |
| 193 | // equal to sky.Prefix. |
| 194 | mux.Handle("GET /static/estilo/sky/v1/", sky.Handler()) |
| 195 | mux.Handle("/", r) |
| 196 | return mux, a, nil |
| 197 | } |
| 198 | |
| 199 | // MountAuth adds auth's routes. Separate from App because auth needs the |
| 200 | // admission gate and App builds the router; the caller wires both. |
| 201 | func (a *app) MountAuth(mux *http.ServeMux, origin string) { |
| 202 | r := chi.NewRouter() |
| 203 | r.Use(a.homeMiddleware(origin)) |
| 204 | // Guarded, not bare: the classifier inside Begin dials the submitted |
| 205 | // address's domain before Authorize runs. beginGuard says why. And |
| 206 | // continued, not redirected: signinBegin says why. |
| 207 | r.Post("/auth/begin", a.signinBegin(origin, a.auth.Begin)) |
| 208 | // Verify answers GET with the confirm door (renderConfirm), which |
| 209 | // spends nothing, and POST with the redemption, so it is mounted for |
| 210 | // both methods in both modes: without the POST the door's Sign in |
| 211 | // button gets a 405 and nobody signs in. The path is baked into every |
| 212 | // emailed link. A verified address that is not admitted is answered |
| 213 | // by auth's Refused hook (app.refused), inside Callback and Verify. |
| 214 | if a.home == nil { |
| 215 | r.Get("/auth/callback", a.auth.Callback) |
| 216 | r.Get("/auth/verify", a.auth.Verify) |
| 217 | r.Post("/auth/verify", a.auth.Verify) |
| 218 | // Where a verified link lands (NewAuth's SignedInPath), and on to |
| 219 | // the page the person was heading for (return.go). |
| 220 | r.Get("/auth/landed", a.landed) |
| 221 | r.Post("/signout", signedOutAfter(a.auth.Signout)) |
| 222 | } else { |
| 223 | r.Get("/auth/callback", a.home.routes.LocalGuard(a.auth.Callback)) |
| 224 | r.Get("/auth/verify", a.home.routes.LocalGuard(a.auth.Verify)) |
| 225 | r.Post("/auth/verify", a.home.routes.LocalGuard(a.auth.Verify)) |
| 226 | // Chains to id.*'s /signout (a 307), which ends the Oficina |
| 227 | // session too and returns the browser to /signed-out (§9.5). |
| 228 | r.Post("/signout", a.home.routes.Signout) |
| 229 | r.Get("/auth/start", a.home.bridge.Start) |
| 230 | r.Post("/auth/assertion", a.home.bridge.Callback) |
| 231 | r.Get("/auth/finish", a.home.bridge.Finish) |
| 232 | r.Get("/auth/local/complete", a.landedAfter(a.home.routes.LocalComplete)) |
| 233 | } |
| 234 | mux.Handle("/auth/", r) |
| 235 | mux.Handle("POST /signout", r) |
| 236 | } |
| 237 | |
| 238 | // requireSession is auth.RequireSession, and it FAILS CLOSED. |
| 239 | // |
| 240 | // An earlier version fell through when auth was nil, so that tests could |
| 241 | // reach the routes without a mailer and a magic-link round trip. Sheets |
| 242 | // carries that seam and it is defensible there. Here it was not, for a |
| 243 | // reason a code review found: the safety argument rested on a test named |
| 244 | // TestProductionRefusesToStartWithoutSignin, and **that test did not |
| 245 | // exist** — the name survived a rename and lived on only in comments. So |
| 246 | // deleting main.go's UseAuth call compiled, left the suite green, and |
| 247 | // served every route unauthenticated. |
| 248 | // |
| 249 | // A seam whose guard is imaginary is a hole. Tests now build the app |
| 250 | // through AppForTest, which says what it is doing in its name. |
| 251 | func (a *app) requireSession(next http.Handler) http.Handler { |
| 252 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 253 | if a.allowUnauthenticated { |
| 254 | next.ServeHTTP(w, r) |
| 255 | return |
| 256 | } |
| 257 | if a.auth == nil { |
| 258 | a.logger.Error("no sign-in configured; refusing every request") |
| 259 | http.Error(w, "sign-in is not configured", http.StatusInternalServerError) |
| 260 | return |
| 261 | } |
| 262 | // A signed-out page request goes to /signin WITH the page it asked |
| 263 | // for (spec §9.4): rastrillo's own refusal sends a bare /signin, so |
| 264 | // a document link opened signed out landed on the index. Anything |
| 265 | // else keeps rastrillo's answer below (a POST still gets its 403). |
| 266 | // Both modes: with Home, connect's Signin keeps a safe return_to |
| 267 | // through the exchange (feat/home-consumer added the Home half; |
| 268 | // a.sess reads the same cookie and table as Home's sessions). |
| 269 | if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| 270 | if _, ok := a.sess.From(r); !ok { |
| 271 | w.Header().Set("Cache-Control", "no-store") |
| 272 | http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) |
| 273 | return |
| 274 | } |
| 275 | } |
| 276 | guarded := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 277 | address, ok := a.identity(r) |
| 278 | if !ok || !a.Admit(address) { |
| 279 | // Still closed. What changes is what a REMOVED person is |
| 280 | // told: their session is ended and the sign-in page says |
| 281 | // why, where they used to meet a 404 on every page for |
| 282 | // thirty days. |
| 283 | if _, st := a.admissionOf(r); st == removed { |
| 284 | a.offboard(w, r) |
| 285 | return |
| 286 | } |
| 287 | a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| 288 | return |
| 289 | } |
| 290 | next.ServeHTTP(w, r) |
| 291 | }) |
| 292 | a.auth.RequireSession(guarded).ServeHTTP(w, r) |
| 293 | }) |
| 294 | } |
| 295 | |
| 296 | // requireOwner guards the connector routes. |
| 297 | // |
| 298 | // "Owner" is Config.Owners, and IN SLICE 0 THAT IS THE ENTIRE ADMITTED |
| 299 | // SET — MEMORIA_OWNERS is the admission list, so this is currently |
| 300 | // equivalent to requireSession. It is written as its own middleware |
| 301 | // anyway, and this comment says the equivalence out loud, because an |
| 302 | // earlier draft described the connector pages as "owners only" as though |
| 303 | // that were a restriction. Naming a gate for a distinction the app does |
| 304 | // not have is how a guard that guards nothing gets shipped. |
| 305 | // |
| 306 | // It fails CLOSED: no identity, or an empty owner list, refuses. |
| 307 | // |
| 308 | // Slice 1 replaces the owners list with S7 and there will be no list to |
| 309 | // check. These routes then need an owner predicate from wherever |
| 310 | // membership lives, or they lose their guard — recorded in the spec's |
| 311 | // open items, not left to be discovered. |
| 312 | func (a *app) requireOwner(next http.Handler) http.Handler { |
| 313 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 314 | addr, ok := a.identity(r) |
| 315 | if !ok || !a.isOwner(addr) { |
| 316 | // 404, not 403: AGENTS.md's rule, and a 403 would confirm |
| 317 | // the route exists to someone who may not use it. The same |
| 318 | // page as an unknown address, so it confirms nothing either. |
| 319 | a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| 320 | return |
| 321 | } |
| 322 | next.ServeHTTP(w, r) |
| 323 | }) |
| 324 | } |
| 325 | |
| 326 | // identity is the signed-in address, or false. |
| 327 | func (a *app) identity(r *http.Request) (string, bool) { |
| 328 | if a.testIdentity != "" { |
| 329 | return a.testIdentity, true |
| 330 | } |
| 331 | if s, ok := sessions.Current(r); ok && s.Method == connect.HomeMethod { |
| 332 | if a.home == nil { |
| 333 | return "", false |
| 334 | } |
| 335 | person, err := a.home.bridge.Resolve(r.Context(), s.Subject) |
| 336 | if err != nil { |
| 337 | return "", false |
| 338 | } |
| 339 | return person.Label, a.Admit(person.Label) |
| 340 | } |
| 341 | if s, ok := sessions.Current(r); ok && s.Method != "magiclink" && s.Method != "keymail" { |
| 342 | return "", false |
| 343 | } |
| 344 | id, ok := auth.From(r) |
| 345 | if !ok { |
| 346 | return "", false |
| 347 | } |
| 348 | return strings.ToLower(strings.TrimSpace(id.Address)), true |
| 349 | } |
| 350 | |
| 351 | func (a *app) isOwner(addr string) bool { |
| 352 | if len(a.owners) == 0 { |
| 353 | return false |
| 354 | } |
| 355 | for _, o := range a.owners { |
| 356 | if o == addr { |
| 357 | return true |
| 358 | } |
| 359 | } |
| 360 | return false |
| 361 | } |
| 362 | |
| 363 | // UseSiblings supplies the apps of record memoria may ask about |
| 364 | // passages. Called by main from Config. |
| 365 | // |
| 366 | // A setter, in the shape UseAuth already has here, and with the same |
| 367 | // hazard: production can forget to call it. What makes that survivable is |
| 368 | // which way the omission fails — a store with no custody withholds every |
| 369 | // sibling passage and says so on the page, where UseAuth's omission |
| 370 | // served every route unauthenticated. The failure is loud and visible to |
| 371 | // the first reader, not silent. |
| 372 | func (a *app) UseSiblings(c Custody) { a.store.custody = c } |
| 373 | |
| 374 | // WarmSiblings starts §6.5's wake poll and returns immediately. |
| 375 | // |
| 376 | // **Asynchronous on purpose, and gate 24 is written around that.** A |
| 377 | // synchronous warm-up would make the first request wait for three sibling |
| 378 | // wakes, which puts the latency back on the reader and undoes the whole |
| 379 | // point; an assertion that it finishes "before the first query" is either |
| 380 | // that, or a race between goroutines. So the gate asserts UNPROMPTED |
| 381 | // PRESENCE instead — every fixture is polled within a stated few seconds |
| 382 | // of boot, with no query issued at all — and the claim is that the poll |
| 383 | // happens on its own rather than that it wins a race. |
| 384 | // |
| 385 | // context.WithoutCancel: the poll outlives the request context that |
| 386 | // happened to be current, because it belongs to the process's wake and |
| 387 | // not to anybody's request. |
| 388 | func (a *app) WarmSiblings(ctx context.Context) { |
| 389 | s, ok := a.store.custody.(*Siblings) |
| 390 | if !ok || s == nil { |
| 391 | // No siblings configured (slice 0), or a Custody that is not the |
| 392 | // production roster. Nothing to warm, and not an error. |
| 393 | return |
| 394 | } |
| 395 | go s.Warm(context.WithoutCancel(ctx), a.store, a.logger) |
| 396 | } |
| 397 | |
| 398 | // SetOwners supplies the owner list. Called by main from Config. |
| 399 | func (a *app) SetOwners(owners []string) { a.owners = owners } |
| 400 | |
| 401 | // Store exposes the store, for gates that must inspect it. |
| 402 | func (a *app) Store() *Store { return a.store } |
| 403 | |
| 404 | // Syncer exposes the syncer, so a test can drive a run against a fake. |
| 405 | func (a *app) Syncer() *Syncer { return a.syncer } |
| 406 | |
| 407 | // AsPerson sets the identity AppForTest presents, so a gate can check |
| 408 | // that a non-owner is refused. Test seam only; production never sets it. |
| 409 | func (a *app) AsPerson(addr string) { a.testIdentity = addr } |
| 410 | |
| 411 | // AppForTest builds the app with a stub session so tests can exercise the |
| 412 | // routes. It is exported, obvious, and takes a testing marker so it |
| 413 | // cannot be reached from production by accident: cmd/memoria does not |
| 414 | // import testing. |
| 415 | func AppForTest(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*http.ServeMux, *app, error) { |
| 416 | mux, a, err := App(d, origin, sealer, logger) |
| 417 | if err != nil { |
| 418 | return nil, nil, err |
| 419 | } |
| 420 | a.allowUnauthenticated = true |
| 421 | // An identity, and an owner list containing it. |
| 422 | // |
| 423 | // AppForTest used to bypass RequireSession entirely, which left no |
| 424 | // identity in the request context — so a gate on "is this person an |
| 425 | // owner" could not have been written at all, only asserted. Found by |
| 426 | // a design review. |
| 427 | a.testIdentity = TestOwner |
| 428 | a.owners = []string{TestOwner} |
| 429 | return mux, a, nil |
| 430 | } |
| 431 | |
| 432 | // TestOwner is the address AppForTest signs in as. |
| 433 | const TestOwner = "owner@test" |
| 434 | |