oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package docs is the CARLOS Docs rastrillo app: its database handle,
2// router and (for now, one) page. Follows rastrillo's SKILL.md app
3// shape (models.go, app.go, handlers.go, render.go, cmd/docs/main.go),
4// adapted into a New(cfg) (*App, error) constructor + App.Handler()
5// rather than SKILL.md's illustrative App(d, origin, logger)
6// (*http.ServeMux, error) function, because the task that specified
7// this app also specified the test harness (docs.NewForTest,
8// app.Handler().ServeHTTP) that every later task's tests depend on —
9// the constructor name and shape follow that harness; everything else
10// below follows SKILL.md.
11package docs
12
13import (
14 "amadan.net/oficina/estilo.git/sky"
15 "context"
16 "errors"
17 "fmt"
18 "log/slog"
19 "net/http"
20 "net/url"
21 "strings"
22 "time"
23
24 "amadan.net/oficina/estilo.git/brand"
25 "amadan.net/oficina/estilo.git/chrome"
26 "amadan.net/oficina/estilo.git/theme"
27 "amadan.net/oficina/home/handoff"
28 nativeauth "amadan.net/rastrillo/native/server"
29 "amadan.net/rastrillo/rastrillo"
30 rauth "amadan.net/rastrillo/rastrillo/auth"
31 "amadan.net/rastrillo/rastrillo/db"
32 "amadan.net/rastrillo/rastrillo/mail"
33 "amadan.net/rastrillo/rastrillo/migrate"
34 "amadan.net/rastrillo/rastrillo/sessions"
35 "github.com/carlosframework/docs/internal/docs/linkstore"
36 "github.com/go-chi/chi/v5"
37
38 "github.com/carlosframework/docs/internal/core"
39 "github.com/carlosframework/docs/internal/docs/teamstate"
40)
41
42// Config configures New.
43type Config struct {
44 Home *HomeConfig
45 // Directory is the hub directory origin ($HOME_DIRECTORY_ORIGIN): the
46 // app switcher's "All apps". Empty means no switcher.
47 Directory string
48 Team *TeamConfig
49
50 // DBPath is the SQLite file New opens via rastrillo's db package.
51 DBPath string
52 ContentKey string
53
54 // Env is "development" or "production" ("" defaults to
55 // "production"). It gates two fail-closed defaults below — Origin
56 // and (auth.go) InstanceKey/Mailer — and nothing else: the
57 // development-only stand-in identity that used to live in
58 // devauth.go is gone (fix round 1 on Task 1 — real sign-in made
59 // it dead code the moment RequireSession started guarding every
60 // route, and it was deleted rather than left as an unreachable
61 // trap for whoever next read "DevAuth" and assumed it still did
62 // something). cmd/docs/main.go sources this from an explicit
63 // -env flag, deliberately independent of any identity flag — Env
64 // choosing "log mail instead of sending it" is not the same
65 // decision as "who can sign in", and conflating the two is
66 // exactly what fix round 1 un-did. $DOCS_ENV is consulted too,
67 // but — fix round 2, after that round's own $DOCS_ENV fallback
68 // turned out to reopen the same hole — never for "development"
69 // itself: only the literal flag may ever select it
70 // (cmd/docs/main.go's chooseEnv has the full reasoning).
71 Env string
72
73 // Origin is this app's own origin (scheme://host[:port], no path),
74 // what csrf.Protect (rastrillo SKILL.md §5) uses as its fallback
75 // same-origin evidence. Fix round 3 corrected a wrong model of
76 // what that check actually does — see csrf.Protect's own doc
77 // comment (rastrillo v0.21.0's csrf/csrf.go): it checks
78 // Sec-Fetch-Site FIRST when a browser sends it, which every
79 // current browser does on a state-changing request —
80 // "same-origin"/"none" pass immediately, anything else is refused
81 // immediately, and Origin is never consulted in that path at all.
82 // Only when Sec-Fetch-Site is entirely absent (a legacy browser, or
83 // a non-browser client such as curl) does the check fall back to
84 // comparing the request's Origin or Referer header against this
85 // string exactly. So a wrong-but-nonempty Origin does NOT make a
86 // real browser's genuine same-origin POST fail, and it does NOT
87 // open up a genuine cross-site request either — both of those are
88 // decided by Sec-Fetch-Site alone, never by this string. What a
89 // wrong Origin actually breaks is the legacy fallback path, and it
90 // breaks it silently: a real client that happens to omit
91 // Sec-Fetch-Site gets wrongly rejected, or — if that comparison
92 // were ever loosened or removed — stops being checked at all.
93 // Neither shows up in ordinary manual testing with a real browser,
94 // which is exactly why getting this right cannot be verified by
95 // "the page loaded and the form worked."
96 //
97 // Empty is allowed only when Env is "development" (New falls back
98 // to DefaultOrigin there, for tests and local convenience); with
99 // any other Env, empty is a startup refusal — see New.
100 //
101 // cmd/docs/main.go wires this from $DOCS_ORIGIN, the same
102 // "<APP>_ORIGIN" convention rastrillo's own `new` scaffold uses
103 // (cmd/rastrillo/new.go's mainTemplate) — the platform's real
104 // per-instance origin (spec §9.2's <app>.<sqid>.oncarlos.com)
105 // is set there at deploy time. On the unix-socket/LISTEN_FDS
106 // activation path there is no -addr to derive a fallback from, so
107 // leaving $DOCS_ORIGIN unset must fail loudly rather than quietly
108 // configuring the app with a string that has nothing to do with
109 // where it is actually reachable — that is exactly what the
110 // Env-gated default below refuses to do.
111 //
112 // A non-empty value is trimmed and then validated (validateOrigin)
113 // rather than trusted as-is: whitespace-only is not "empty" by a
114 // bare == "" check, and would otherwise sail past the guard above
115 // with nothing catching it until the legacy fallback path above
116 // tried, and failed, to match it against anything real.
117 Origin string
118
119 // InstanceKey seals rastrillo/auth's pending-link blob (an HMAC
120 // key — auth.Config.InstanceKey's own doc comment explains why an
121 // empty one is dangerous, not just inconvenient: it hashes to one
122 // fixed, publicly computable value, identical across every
123 // deployment that made the same mistake). Sourced from
124 // $DOCS_INSTANCE_KEY (cmd/docs/main.go), the same "read in main,
125 // validated in New" split Origin above uses.
126 //
127 // Empty is allowed only when Env is "development" (New falls back
128 // to a fixed, non-secret development value — see devInstanceKey in
129 // auth.go); with any other Env, empty is a startup refusal, the
130 // same fail-closed shape as Origin.
131 InstanceKey string
132
133 // Mailer delivers the magic-link email. Nil is fine only in
134 // development: New wraps rastrillo/mail's logging fallback so the
135 // link lands in the log, clearly marked, and TestSignIn (testing.go)
136 // can read it back — no mail server required for
137 // scripts/run-dev.sh or for tests. Outside development, nil is a
138 // startup refusal: a sign-in that cannot send its link is not
139 // sign-in, and silently falling back to logging a live credential
140 // in a real deployment's log is exactly the mistake that fallback
141 // exists to flag, not absorb. cmd/docs/main.go builds this from
142 // $DOCS_SMTP_HOST/$DOCS_SMTP_FROM (and the other
143 // $DOCS_SMTP_* vars rastrillo/mail.FromEnv reads) when both are
144 // set, and leaves it nil otherwise — New.buildMailer (auth.go) is
145 // what turns "nil outside development" into the refusal, the same
146 // split Origin/InstanceKey use.
147 Mailer mail.Sender
148
149 // AllowedAddresses is the comma-separated allowlist of the only
150 // addresses this instance admits — $DOCS_ALLOWED_ADDRESSES
151 // (cmd/docs/main.go), the same "<APP>_..." env-var convention
152 // Origin and the SMTP vars above use. Compared case-insensitively,
153 // each entry trimmed of surrounding whitespace (auth.go's
154 // normalizeAddress) — a real config file has spaces after its
155 // commas, and people type capitals into an email field.
156 //
157 // The sign-in mail queue restricts email delivery (mailqueue.go,
158 // listAdmits), and rauth.Config.Authorize
159 // checks verified identities before minting a session. Classification
160 // does not depend on admission, so an OAuth start cannot reveal membership.
161 //
162 // Empty is allowed only when Env is "development"
163 // (resolveAllowlist below defaults that to "admit everyone", so
164 // TestSignIn and scripts/run-dev.sh keep working with no setup of
165 // their own) — anywhere else, empty is a startup refusal; see
166 // resolveAllowlist's own doc comment for why that beats the
167 // alternative of silently admitting nobody.
168 AllowedAddresses string
169
170 // SampleDocuments opts this instance into seeding a brand-new
171 // person's very first sign-in with CARLOS Docs' own sample
172 // documents (seed.go's Seed, six of them) — the fix for "Paul
173 // signed in to a live instance and saw an empty list": the sample
174 // documents docs.Seed already wrote were never reachable on a
175 // deployed instance, because -seed is gated to Env ==
176 // "development" (main.go's own doc comment, unchanged by this
177 // field) and a deployed instance never runs with that flag.
178 // Documents are owner-scoped (Document.OwnerID, owned() in
179 // store.go) and a boot-time seed has no person to own anything, so
180 // this switches seeding from a boot-time, instance-wide event to a
181 // per-person one — see Store.EnsurePerson's own doc comment for
182 // exactly where that happens and why there, not "the list is
183 // empty" (a person who deletes every sample document must not get
184 // them back).
185 //
186 // Sourced from $DOCS_SAMPLE_DOCUMENTS (cmd/docs/main.go), the same
187 // "<APP>_..." convention every other setting on this struct uses.
188 // Parsed by parseSampleDocuments below, which accepts a small,
189 // case-insensitive truthy set ("1", "true", "yes") and treats
190 // everything else — unset, empty, or a typo alike — as off.
191 // Default OFF: a real team instance must never get six invented
192 // documents about a reading room and an allotment committee just
193 // because nobody thought to unset something.
194 //
195 // Deliberately NOT fail-closed the way Origin/InstanceKey/
196 // AllowedAddresses above are: those three guard who can sign in
197 // and what seals a session, where a value New cannot make sense of
198 // silently taking the permissive branch would be a real security
199 // regression, so New refuses to start rather than guess. This
200 // setting only decides whether a new person's document list starts
201 // with six sample documents or zero — cosmetic content, not a
202 // security control — so an unparseable value here is read as "off"
203 // and New boots anyway. A platform whose env var block has a typo
204 // in $DOCS_SAMPLE_DOCUMENTS should get an empty list, silently, the
205 // same outcome as never having set it at all, not a refusal to
206 // serve any traffic over a setting that was never load-bearing for
207 // safety in the first place.
208 SampleDocuments string
209
210 // Logger defaults to slog.Default().
211 Logger *slog.Logger
212}
213
214// parseSampleDocuments interprets raw the way Config.SampleDocuments'
215// own doc comment describes: a small, case-insensitive truthy set
216// means on, and everything else means off. See that doc comment for
217// why this is deliberately not fail-closed like resolveInstanceKey/
218// resolveAllowlist (auth.go) just below use for their own settings.
219func parseSampleDocuments(raw string) bool {
220 switch strings.ToLower(strings.TrimSpace(raw)) {
221 case "1", "true", "yes":
222 return true
223 default:
224 return false
225 }
226}
227
228// DefaultOrigin is Config.Origin's fallback when empty — see its doc
229// comment. Exported so a caller that needs to know what origin this
230// app is protecting itself against (NewForTest's callers, notably)
231// does not have to duplicate the literal.
232const DefaultOrigin = "http://localhost:8080"
233
234// validateOrigin reports whether origin (already trimmed by the
235// caller — New) is something csrf.Protect's legacy Origin/Referer
236// fallback can actually use: an absolute http(s) URL naming a scheme
237// and a host and nothing else. "Nothing else" is checked
238// deliberately, not just "parses as a URL" — url.Parse happily
239// accepts a bare path, a value carrying a query string, or one with
240// embedded userinfo, none of which a same-origin check has any
241// business being handed, and none of which would ever equal a real
242// browser's Origin header. An app that accepted one would boot
243// looking entirely healthy — a modern browser's genuine same-origin
244// traffic passes on Sec-Fetch-Site alone and never reaches this
245// string at all (Config.Origin's own doc comment) — and only fail,
246// silently, on whatever still uses the legacy fallback.
247func validateOrigin(origin string) error {
248 if origin == "" {
249 return errors.New("origin is blank")
250 }
251 u, err := url.Parse(origin)
252 if err != nil {
253 return fmt.Errorf("origin %q does not parse as a URL: %w", origin, err)
254 }
255 if u.Scheme != "http" && u.Scheme != "https" {
256 return fmt.Errorf("origin %q must have an http or https scheme", origin)
257 }
258 // u.Host != "" is not enough: fix round 2's own bug. "http://:8099"
259 // — exactly what -addr :8099 derives (main.go's "http://"+opts.Addr,
260 // the plan's own example invocation) — has a non-empty Host
261 // (":8099") but an empty Hostname(); Host merely means "there was
262 // something between // and the next / (or the end)", not "there is
263 // a host". A reviewer booted a production-Env binary on this exact
264 // origin and watched it serve. Hostname() is what actually strips
265 // the port and answers "is there a host here at all".
266 if u.Hostname() == "" {
267 return fmt.Errorf("origin %q has no host", origin)
268 }
269 if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
270 return fmt.Errorf("origin %q must be scheme://host[:port] only, with no path, query, fragment or userinfo", origin)
271 }
272 return nil
273}
274
275// App is the CARLOS Docs rastrillo app.
276type App struct {
277 cfg Config
278 db *db.DB
279 mux *http.ServeMux
280 logger *slog.Logger
281 core *core.Runtime
282 store *Store
283 handlers *handlers
284 mailRecorder *recordingMailer // nil outside development; see auth.go
285 signinMail *mailQueue // sign-in links, sent after the response
286}
287
288// BootSchema is everything New applies at boot, in apply order.
289// migrate.Merge's argument order is apply order (rastrillo SKILL.md
290// §2): sessions.Schema before auth.Schema — auth's own backfill
291// migration reads the sessions table — before this app's own Schema
292// (models.go).
293var BootSchema = migrate.Merge(sessions.Schema, authSchema, Schema, linkstore.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"))
294
295// New opens the database, applies migrations and wires the router. It
296// is rastrillo SKILL.md's app.go shape: db.Open for the SQLite handle,
297// migrate.Apply once at boot, a chi router wrapped as an
298// *http.ServeMux so httptest exercises the real router, csrf.Protect
299// mounted app-wide (SKILL.md §5), rastrillo/auth (auth.go) guarding
300// every document route behind a real, per-person magic-link sign-in.
301func New(cfg Config) (*App, error) {
302 logger := cfg.Logger
303 if logger == nil {
304 logger = slog.Default()
305 }
306 if cfg.Env == "" {
307 cfg.Env = "production"
308 }
309
310 // Fail closed, loudly: an empty Origin defaults to DefaultOrigin
311 // only in development (tests via NewForTest, a person running
312 // scripts/run-dev.sh). Anywhere else — Env left at its "production"
313 // default very much included — an empty Origin is a startup
314 // refusal, not a silent fallback to a placeholder whose only real
315 // effect is on csrf.Protect's legacy Origin/Referer fallback path
316 // (Config.Origin's own doc comment explains why that path, not
317 // "every request", is what a wrong string actually touches). This
318 // is the same fail-closed shape InstanceKey and Mailer use below
319 // (auth.go): exactly one string ("development") is ever treated
320 // as "safe to default", checked before anything else opens.
321 //
322 // cfg.Origin == "" alone is not the right test: a whitespace value
323 // like $DOCS_ORIGIN=" " is non-empty, so it would sail past that
324 // check. Nothing about a modern browser's own traffic would look
325 // wrong — Sec-Fetch-Site: same-origin passes without this string
326 // ever being read — so the only visible symptom would be the
327 // legacy fallback path failing to match literal whitespace against
328 // anything real, for whichever client still exercises it. That
329 // fails safe on that one path, but silently and late (at request
330 // time, for whoever hits it), not at boot where a malformed origin
331 // belongs. validateOrigin below rejects that and any other value
332 // that cannot possibly be what csrf.Protect needs (no scheme, no
333 // host, a stray path/query/fragment/userinfo) before New ever
334 // returns a listening app.
335 trimmedOrigin := strings.TrimSpace(cfg.Origin)
336 switch {
337 case trimmedOrigin == "" && cfg.Env != "development":
338 return nil, fmt.Errorf("docs: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $DOCS_ORIGIN to this instance's real origin", cfg.Env, "development")
339 case trimmedOrigin == "":
340 cfg.Origin = DefaultOrigin
341 default:
342 if err := validateOrigin(trimmedOrigin); err != nil {
343 return nil, fmt.Errorf("docs: Config.Origin is invalid: %w", err)
344 }
345 cfg.Origin = trimmedOrigin
346 }
347
348 // Resolved before anything else opens — same fail-closed shape as
349 // Origin above: a deployment that cannot seal a
350 // pending link, or cannot send one, refuses to start rather than
351 // booting into a broken or (worse) silently-logging-credentials
352 // sign-in. See auth.go.
353 homeSettings, err := validatedHomeConfig(cfg.Home, cfg.Origin)
354 if err != nil {
355 return nil, err
356 }
357 teamSettings, err := validatedTeamConfig(cfg.Team, homeSettings, cfg.Origin)
358 if err != nil {
359 return nil, err
360 }
361 instanceKey, err := resolveInstanceKey(cfg)
362 if err != nil {
363 return nil, err
364 }
365 mailer, mailRecorder, err := buildMailer(cfg, logger)
366 if err != nil {
367 return nil, err
368 }
369 // Same fail-closed shape again: an instance with nobody configured
370 // to sign in either refuses to start (outside development) or, in
371 // development, admits everyone — never a silent "admits nobody"
372 // that would boot looking healthy. See resolveAllowlist's own doc
373 // comment for the full reasoning.
374 list, err := resolveAllowlist(cfg)
375 if err != nil {
376 return nil, err
377 }
378
379 cipher, err := newContentCipher(cfg.ContentKey, cfg.Env)
380 if err != nil {
381 return nil, err
382 }
383
384 d, err := db.Open(cfg.DBPath, logger)
385 if err != nil {
386 return nil, fmt.Errorf("docs: open database: %w", err)
387 }
388
389 required, activated, err := teamstate.Required(context.Background(), d.Writer())
390 if err != nil || activated && (teamSettings == nil || required != teamSettings.fingerprint) {
391 d.Close()
392 return nil, errTeamConfig
393 }
394 schema := BootSchema
395 if teamSettings != nil {
396 schema = TeamBootSchema
397 }
398 if _, err := migrate.Apply(context.Background(), d, schema); err != nil {
399 d.Close()
400 return nil, fmt.Errorf("docs: apply migrations: %w", err)
401 }
402
403 state, err := readContentSealing(context.Background(), d.G, cipher)
404 if err == nil && state != nil {
405 err = requireSealedContent(context.Background(), d.G)
406 }
407 if err != nil {
408 d.Close()
409 return nil, err
410 }
411 if state == nil {
412 logger.Error("docs: content sealing not activated", "content_encoding", 0, "key_fingerprint", cipher.fingerprint)
413 }
414
415 // One Runtime for the process (core.New compiles the wasm module
416 // once — expensive; instantiating a per-document Handle from it is
417 // not), shared by every document's Store.replay.
418 rt, err := core.New(context.Background())
419 if err != nil {
420 d.Close()
421 return nil, fmt.Errorf("docs: start core runtime: %w", err)
422 }
423 store := NewStore(d.G, rt, parseSampleDocuments(cfg.SampleDocuments), logger)
424 if state != nil {
425 store.content = cipher
426 }
427
428 // A grant admits its holder. Wired here rather than inside
429 // resolveAllowlist because the allowlist is resolved before the store
430 // exists — deliberately, so a misconfigured allowlist refuses to boot
431 // before anything else is built. See allowlist.granted for why an
432 // unredeemable invitation is the failure worth avoiding.
433 // The sample library is for members. A guest admitted by a grant is
434 // visiting, not moving in.
435 store.seedOnlyMembers = list.configured
436 if teamSettings != nil {
437 store.team, err = newTeamRuntime(context.Background(), teamSettings, d.Writer(), homeSettings)
438 if err != nil {
439 rt.Close(context.Background())
440 d.Close()
441 return nil, err
442 }
443 }
444
445 list.granted = func(address string) (bool, error) {
446 // A short deadline rather than context.Background(): this runs on
447 // the sign-in path and now on every request (requireAdmission), so
448 // a stalled query must fail rather than hold a request open.
449 //
450 // The error is passed up rather than folded into false. Each
451 // caller wants a different answer to "the check failed": sign-in
452 // refuses, and a live request answers 503 so the editor's save
453 // queue retries instead of dropping the batch.
454 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
455 defer cancel()
456 return store.HasGrant(ctx, address)
457 }
458
459 render, err := newRenderer()
460 if err != nil {
461 rt.Close(context.Background())
462 d.Close()
463 return nil, fmt.Errorf("docs: build renderer: %w", err)
464 }
465
466 // Sign-in links go out after the response, from a worker that asks
467 // the allowlist first (mailqueue.go). Closed by Close, or below if
468 // New fails after this point.
469 signinMail := newMailQueue(mailer, listAdmits(list), nil, logger)
470 built := false
471 defer func() {
472 if !built {
473 signinMail.close()
474 }
475 }()
476 // h is built below, after the plugin it wraps; the door rastrillo
477 // calls back into is bound to it late.
478 var h *handlers
479 door := signinDoor{
480 refused: func(w http.ResponseWriter, r *http.Request) { h.signinRefused(w, r) },
481 confirm: func(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) { h.confirmSignin(w, r, d) },
482 }
483 authPlugin, err := newAuthPlugin(cfg, d.Writer(), instanceKey, signinMail, list, logger, door)
484 if err != nil {
485 rt.Close(context.Background())
486 d.Close()
487 return nil, fmt.Errorf("docs: build auth: %w", err)
488 }
489
490 directory, err := validatedDirectory(cfg.Directory)
491 if err != nil {
492 rt.Close(context.Background())
493 d.Close()
494 return nil, err
495 }
496 h = &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews(), origin: cfg.Origin, isMember: list.configured, oneShots: newOneShots(), directory: directory}
497 render.frame = h.frame
498 // peek: the viewer on routes no session guards (chrome.go). Standalone
499 // reads rastrillo/auth's own cookie; with Home, the Home bridge's.
500 h.peek = func(r *http.Request) (string, bool) {
501 id, ok := authPlugin.SessionFrom(r)
502 return id.Address, ok
503 }
504
505 var home *homeRoutes
506 if homeSettings != nil {
507 home, err = newHomeRoutes(homeSettings, cfg.Origin, d.Writer(), authPlugin, h)
508 if err != nil {
509 rt.Close(context.Background())
510 d.Close()
511 return nil, err
512 }
513 h.homeEnabled = true
514 h.peek = func(r *http.Request) (string, bool) {
515 s, ok := home.bridge.sessions.From(r)
516 if !ok || s.Subject == "" {
517 return "", false
518 }
519 if s.Method == "magiclink" || s.Method == "keymail" {
520 return s.Subject, true
521 }
522 id, err := home.bridge.resolver.resolve(sessions.WithSession(r, s))
523 if err != nil {
524 return "", false
525 }
526 return actorAddress(id.Actor), true
527 }
528 if store.team != nil {
529 store.team.bridge = home.bridge
530 home.bridge.resolver.team = store.team
531 store.team.readSession, err = newTeamSessionReader(d.Writer(), cfg.Origin, home.bridge.sessionCookie, logger)
532 if err != nil {
533 rt.Close(context.Background())
534 d.Close()
535 return nil, err
536 }
537 }
538
539 }
540 // Approval and long-lived streams reuse the same current admission chain.
541 h.requireCurrent = func(next http.Handler) http.Handler {
542 if store.team != nil {
543 next = store.team.admission(next)
544 } else {
545 next = requireAdmission(list, "/signin")(next)
546 }
547 if home == nil {
548 return authPlugin.RequireSession(next)
549 }
550 current := home.bridge.sessions.Require(home.bridge.resolver.middleware("/signin/local")(next))
551 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
552 // A live stream retains its opening context. Require trusts that cached
553 // session, so re-read the cookie before admitting each stream write.
554 session, ok := home.bridge.sessions.From(r)
555 if !ok {
556 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusUnauthorized)
557 return
558 }
559 current.ServeHTTP(w, sessions.WithSession(r, session))
560 })
561 }
562 nativeSessions, err := newNativeSessions(d.Writer(), cfg.Origin, logger)
563 if err != nil {
564 rt.Close(context.Background())
565 d.Close()
566 return nil, err
567 }
568 native := nativeauth.New(nativeSessions, cfg.Origin, "Docs", h.requireCurrent)
569 var standalone *localLinkNavigation
570 if home == nil {
571 if standalone, err = standaloneReturn(instanceKey, cfg.Origin); err != nil {
572 rt.Close(context.Background())
573 d.Close()
574 return nil, err
575 }
576 }
577 if home == nil {
578 h.remembered = standalone.returnPath
579 } else {
580 h.remembered = home.navigation.returnPath
581 }
582 r := chi.NewRouter()
583 // Every route that is not a route is the 404 page for a browser, and a
584 // page GET to a POST-only path is too (errors.go).
585 r.NotFound(h.notFound)
586 r.MethodNotAllowed(h.methodNotAllowed(r))
587 r.Use(native.Resume)
588 // Mounted above every route, per rastrillo SKILL.md §5: refuses a
589 // cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/
590 // Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does
591 // nothing to "/" or "/d/{sqid}" and everything to "/documents",
592 // "/signin" and "/signout".
593 r.Use(homeCSRF(cfg.Origin, home))
594 r.Post("/native/request", native.ServeHTTP)
595 r.Post("/native/exchange", native.ServeHTTP)
596 r.Get("/native/start", native.ServeHTTP)
597 r.Get("/native/authorize", native.ServeHTTP)
598 r.Post("/native/authorize", native.ServeHTTP)
599 // Unauthenticated: the sign-in page and its four handlers, and the
600 // static assets that page (and the shell around it) needs to
601 // render at all.
602 if home == nil {
603 r.Get("/signin", h.standaloneSignin(standalone))
604 r.Get(localLinkCompletePath, standalone.completeStandalone)
605 r.Get("/signed-out", h.signedOut)
606 } else {
607 home.mountPublic(r)
608 }
609 // Appearance (estilo chrome): inside the app-wide CSRF check above and
610 // outside every session guard below, so a session that expired while
611 // the menu was open cannot turn the click into a sign-in loop.
612 r.Post("/appearance", chrome.HandleAppearance)
613 r.Post("/signin", h.beginSignin(authPlugin.Begin))
614 // An emailed link opens GET /auth/verify, which only draws the
615 // confirm door (h.confirmSignin); its Sign in button POSTs the token
616 // back, and the POST spends it. Without the POST nobody signs in by
617 // email. Both sit inside the app-wide CSRF check, which the POST must
618 // pass like any other form. A refused address gets the door in a 403
619 // (h.signinRefused), from either route.
620 r.Get("/auth/callback", localAuthGuard(home, authPlugin.Callback))
621 r.Get("/auth/verify", localAuthGuard(home, authPlugin.Verify))
622 r.Post("/auth/verify", localAuthGuard(home, authPlugin.Verify))
623 // The published document, and the only route here no session guards.
624 // It is authorized by the token in its own path (links.go) rather
625 // than by who is asking — which is what a capability is, and why it
626 // sits above the RequireSession group rather than inside it with an
627 // exception carved into the middleware.
628 r.Get("/p/{token}", h.publicShow)
629 if home == nil {
630 r.Post("/signout", signoutToSignedOut(authPlugin.Signout))
631 } else {
632 r.Post("/signout", home.signout)
633 }
634 // staticHandler (assets.go) composes the committed editor bundle
635 // with render.assets.Handler()'s vendored design-system tree —
636 // both live under this one URL prefix, so one composed handler,
637 // not two competing route registrations. Still unauthenticated,
638 // still mounted above the RequireSession group below.
639 r.Handle("/static/*", staticHandler(render.assets.Handler()))
640 // The suite's app icons, served from estilo (the bar's brand, and the
641 // switcher's tiles once they render).
642 r.Handle(brand.Prefix+"*", brand.Handler())
643 // Onest, Calma's typeface, served by estilo under its versioned prefix.
644 r.Handle(theme.FontPrefix+"*", theme.FontHandler())
645 // The sky behind the sign-in door: a closed set of SVGs, public.
646 r.Handle(sky.Prefix+"*", sky.Handler())
647
648 // Everything else needs a real, signed-in person — rastrillo
649 // SKILL.md §5/§1's "signed-in routes... in a Group with
650 // sess.Require", here auth's own RequireSession. A signed-out
651 // GET/HEAD redirects to /signin; anything else 403s.
652
653 if store.team != nil {
654 r.Group(func(r chi.Router) {
655 r.Use(home.bridge.sessions.Require)
656 r.Use(home.bridge.resolver.middleware("/signin/local"))
657 r.Use(requireTeamAccountAdmission(list))
658 home.mountAccount(r)
659 })
660 }
661 r.Group(func(r chi.Router) {
662 if home == nil {
663 r.Use(requireSessionReturning(authPlugin))
664 } else {
665 r.Use(home.bridge.sessions.Require)
666 r.Use(home.bridge.resolver.middleware("/signin/local"))
667 }
668 // After the session middleware, so a 500 page shows the viewer's
669 // own chrome (errors.go).
670 r.Use(h.recoverToErrorPage)
671 // Admission is re-asked per request, not only at sign-in, so
672 // revoking a guest's last grant ends their access now rather
673 // than whenever their 30-day session happens to expire.
674 if store.team != nil {
675 r.Use(store.team.admission)
676 } else {
677 r.Use(requireAdmission(list, "/signin"))
678 if home != nil {
679 home.mountAccount(r)
680 }
681 }
682 r.Get("/", h.list)
683 r.Post("/documents", h.create)
684 // Title search for the jump-to palette (search.go). Inside
685 // this group like every other document route, and reader-scoped
686 // through Store.Search → Store.List rather than through a
687 // check of its own — a person's document titles are theirs.
688 r.Get("/documents/search", h.searchJSON)
689 r.Get("/d/{sqid}", h.show)
690 r.Post("/d/{sqid}/title", h.rename)
691 // Task 5: the update log, what the browser's editor reads to
692 // build its Yjs doc (GET) and posts new edits back through
693 // (POST) — see handlers.go's updatesGet/updatesPost. Neither
694 // is exempted from csrf.Protect above: rastrillo SKILL.md §5's
695 // origin check is what stops a cross-site page from writing
696 // to a document through this route.
697 // Sharing. All three owner-only, and all three answering 404 to
698 // anybody else — including a writer, who may edit the document and
699 // still may not decide who else reaches it.
700 r.Get("/d/{sqid}/share", h.shareGet)
701 r.Post("/d/{sqid}/share", h.sharePost)
702 r.Post("/d/{sqid}/share/revoke", h.shareRevoke)
703 r.Post("/d/{sqid}/share/link", h.shareLink)
704
705 r.Get("/d/{sqid}/updates", h.updatesGet)
706 r.Post("/d/{sqid}/updates", h.updatesPost)
707 // Export (export.go). Inside this group, so a signed-out
708 // request gets the same redirect every other document route
709 // gives it, and reader-scoped through the same Store.GetFor, so a
710 // document someone else owns 404s identically to one that does
711 // not exist. Extensions in the path rather than a ?format=
712 // query: the URL is what a person sees when they save the
713 // file, and one that ends in .pdf is one a browser and an
714 // operating system both already understand.
715 r.Get("/d/{sqid}/export.md", h.exportMarkdown)
716 r.Get("/d/{sqid}/export.pdf", h.exportPDF)
717 // Plan 5: the live-update stream (stream.go). A GET, so
718 // csrf.Protect passes it through untouched, and reader-scoped
719 // through the same getDocumentForUpdates helper as the two
720 // routes above — a stranger gets 404, never 403.
721 r.Get("/d/{sqid}/stream", h.stream)
722 })
723
724 mux := http.NewServeMux()
725 mux.Handle("/", r)
726
727 built = true
728 return &App{cfg: cfg, db: d, mux: mux, logger: logger, core: rt, store: store, handlers: h, mailRecorder: mailRecorder, signinMail: signinMail}, nil
729}
730
731// Store returns the app's document store — models.go's Document,
732// store.go's Store — over the app's migrated database and shared
733// core.Runtime.
734func (a *App) Store() *Store { return a.store }
735
736// Handler is the app's router: rastrillo.Options.Mux for real serving
737// (cmd/docs/main.go), and — per rastrillo's own doc comment on
738// Handler/Serve — exactly what a test harness should exercise directly,
739// so httptest hits the real route table rather than a hand-duplicated
740// one. *http.ServeMux, not http.Handler: it satisfies both callers
741// without forcing an unchecked type assertion on the main.go side,
742// and matches SKILL.md's own App(...) (*http.ServeMux, error) shape.
743func (a *App) Handler() *http.ServeMux { return a.mux }
744
745// Close releases the database handle and the core runtime's compiled
746// module. NewForTest registers this as a t.Cleanup.
747func (a *App) Close() error {
748 var errs []error
749 // First, while the database the allowlist reads is still open.
750 if a.signinMail != nil {
751 a.signinMail.close()
752 }
753 if a.core != nil {
754 if err := a.core.Close(context.Background()); err != nil {
755 errs = append(errs, err)
756 }
757 }
758 if err := a.db.Close(); err != nil {
759 errs = append(errs, err)
760 }
761 return errors.Join(errs...)
762}
763