Adopt estilo's shared chrome in Memoria (round 2)
The 52px estilo bar replaces the hand-made header, which wrapped onto two rows at 1440 (97px) with its open Help panel over Sources and Sign out. Search and Sources (owners only, aria-current) are the bar's nav; the account menu is the last thing in it: who is signed in, "Connect your Oficina account" for local sessions when Home is configured, Appearance (Light, Dark, System; /appearance, per-app cookie), Help → Open source, and the one Sign out. "All apps" shows when Home names a directory (HOME_DIRECTORY_ORIGIN). Errors are pages: unknown routes, a missing or unreadable document, a GET of a POST-only path and panics render estilo's error body in the normal layout with the viewer's chrome, split by Accept (the tray, the native API and fetches keep plain answers; TestEveryPlainTextErrorIsClassified lists the rest). Titles are "<Page> · Memoria", built in the layout. Callouts use rastrillo's body shape and real tones; the Notion run is toned by state; "Nothing matched…" is one quiet line, held back while the person is still typing. Sign-in: a signed-out page request goes to /signin?return_to=, the page is remembered in a sealed, origin-bound, ten-minute cookie and landed on after the link (/auth/landed, or connect's local completion); "Use a different address" keeps it; a signed-in /signin runs the admission check first, so a removed owner is signed out and told once (and on any page, instead of a 404 until the session lapses). Sign out lands on "You're signed out". The door uses the suite's copy; the footer's egress claim is said only to somebody signed in. Open source is public (strict-origin). Also: base link colour (estilo base.css), the double-bordered search box, fields that overflowed at 390 (border-box), 19 rst-cell-mut attributes that styled nothing (now rst-muted), the Sources list's bullets in a card, the file chooser and password fields, and Delete asks on its own page first. Memoria is capitalised in its copy and email subject.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
47 files changed,
+2640
−395
cmd/memoria/main.go+4 −0docs/superpowers/specs/2026-09-03-memoria-design.md+23 −12internal/memoria/app.go+63 −21internal/memoria/auth.go+5 −3internal/memoria/chrome.go+267 −0internal/memoria/chrome_test.go+215 −0internal/memoria/config.go+6 −1internal/memoria/credits.go+15 −2internal/memoria/credits_test.go+24 −9internal/memoria/errors.go+221 −0internal/memoria/handlers.go+196 −93internal/memoria/handlers_connector.go+36 −15internal/memoria/home.go+14 −9internal/memoria/home_render.go+31 −14internal/memoria/home_router_test.go+43 −13internal/memoria/native.go+1 −0internal/memoria/render.go+21 −12internal/memoria/return.go+237 −0internal/memoria/seal/seal.go+7 −0internal/memoria/static/memoria.css+113 −32internal/memoria/static/search.js+35 −2internal/memoria/templates/add.html+7 −9internal/memoria/templates/connectors.html+20 −17internal/memoria/templates/credits.html+1 −4internal/memoria/templates/delete.html+14 −0internal/memoria/templates/document.html+1 −2internal/memoria/templates/error.html+4 −0internal/memoria/templates/home.html+0 −1internal/memoria/templates/index.html+13 −11internal/memoria/templates/layout.html+53 −20internal/memoria/templates/notion.html+24 −19internal/memoria/templates/results.html+32 −26internal/memoria/templates/signin.html+3 −5internal/memoria/templates/signin_oauth.html+1 −3internal/memoriatest/app_test.go+6 −3internal/memoriatest/chrome_client_test.go+129 −0internal/memoriatest/chrome_test.go+578 −0internal/memoriatest/drop_test.go+5 −2internal/memoriatest/errors_gate_test.go+103 −0internal/memoriatest/footer_model_test.go+9 −2internal/memoriatest/gates_test.go+9 −16internal/memoriatest/routes_test.go+17 −4internal/memoriatest/search_test.go+2 −2internal/memoriatest/signin_door_test.go+13 −5internal/memoriatest/signin_keymail_test.go+5 −3internal/memoriatest/signinoutcome_test.go+6 −3internal/memoriatest/vendored_test.go+8 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/memoria/cmd/memoria [no test files] ok amadan.net/oficina/memoria/internal/memoria 3.959s ok amadan.net/oficina/memoria/internal/memoria/chunk 0.005s ok amadan.net/oficina/memoria/internal/memoria/fact 0.706s ? amadan.net/oficina/memoria/internal/memoria/icons [no test files] ok amadan.net/oficina/memoria/internal/memoria/index 0.041s ok amadan.net/oficina/memoria/internal/memoria/notion 0.029s ok amadan.net/oficina/memoria/internal/memoria/provider 1.133s ok amadan.net/oficina/memoria/internal/memoria/query 0.005s ok amadan.net/oficina/memoria/internal/memoria/seal 0.005s ok amadan.net/oficina/memoria/internal/memoria/sibling 3.033s ok amadan.net/oficina/memoria/internal/memoriatest 2.186s ? amadan.net/oficina/memoria/scripts/writeestilo [no test files] ? amadan.net/oficina/memoria/tools/bench [no test files] ? amadan.net/oficina/memoria/tools/drive [no test files] ? amadan.net/oficina/memoria/tools/seed [no test files] ? amadan.net/oficina/memoria/tools/vendorassets [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go| index 6b7858c..174c67f 100644 |
| --- a/cmd/memoria/main.go |
| +++ b/cmd/memoria/main.go |
| @@ -120,6 +120,10 @@ func main() { |
| } |
| opts.Mux = mux |
| + // rastrillo.Serve's own panic recovery, for a panic outside the router: |
| + // the same 500 page, with the viewer's chrome, as the router's |
| + // recoverToErrorPage renders (shared chrome §3.3). |
| + opts.ErrorPage = app.ErrorPage |
| opts.DBPath = "" |
| if err := rastrillo.Serve(opts); err != nil { |
| logger.Error("serve failed", "err", err) |
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md| index 6ef0493..1b59056 100644 |
| --- a/docs/superpowers/specs/2026-09-03-memoria-design.md |
| +++ b/docs/superpowers/specs/2026-09-03-memoria-design.md |
| @@ -1427,7 +1427,11 @@ The additional surface is `GET /signin/local`, `GET /signed-out`, |
| cross-origin issuer; all other mutations retain CSRF protection. The normal |
| `/signin` starts Home handoff when enabled. Local email verification remains |
| available for first-time connection, including explicit `force=1` fallback. |
| -Sign-out revokes the app session and stops at `/signed-out`. |
| +Sign-out revokes the app session, then (home/connect 01166fc, shared chrome |
| +§9.5) hands the same POST on to `{issuer}/signout` with a 307, which ends the |
| +Oficina session too and returns the browser to `/signed-out`. |
| +`HOME_DIRECTORY_ORIGIN` (optional, validated with the rest) names the hub |
| +directory, where the app switcher's "All apps" goes. |
| **Admission is the home's roster — suite §5.3, ruling 4.** memoria holds no |
| @@ -1519,11 +1523,14 @@ No guests, no anonymous link viewers — and after this section, no guest |
| of a *sibling* either. Admission is not authorization: an admitted person |
| still sees only what §4.1 and §4.2 allow. |
| -The suite attribution page (`GET /help/open-source`, 2026-09-11) uses |
| -this same session gate. It serves embedded software notices and retains |
| -the signed-in navigation, without reading content or passage counts. Its |
| -stylesheet is a public static asset, like the other stylesheets. The |
| -credits page omits the passage-count footer; it creates no session. |
| +The suite attribution page (`GET /help/open-source`, 2026-09-11) used |
| +this same session gate until the shared chrome (estilo spec/shared-chrome |
| +§8.4, 2026-09-30), which has the sign-in page link to it while signed |
| +out. It is now **public**: it serves embedded software notices and |
| +nothing about the team — no content, no passage counts, no document |
| +names. Signed in, it still shows the viewer's own bar and account menu; |
| +signed out, the bar's Sign in. It creates no session. Its stylesheet is |
| +a public static asset, like the other stylesheets. |
| --- |
| @@ -3948,9 +3955,13 @@ anyway, because a gate read from a list that matches the app is worth |
| more than one read from memory: |
| `GET /`, `GET /search`, `GET /documents/{id}`, `POST /documents`, |
| -`POST /documents/{id}/delete`, the connector routes under |
| -`/connectors/…`, authenticated `GET /help/open-source`, public static |
| -`GET /static/credits.css`, and the auth routes. |
| +`GET` and `POST /documents/{id}/delete` (the GET asks before the POST |
| +deletes), the connector routes under `/connectors/…`, public |
| +`GET /help/open-source`, `POST /appearance` (the shared chrome's |
| +Appearance cookie: inside CSRF, outside the session gate), public static |
| +`GET /static/credits.css`, and the auth routes (including |
| +`GET /auth/landed`, where a verified link lands on the way back to the |
| +page the person asked for). |
| Two of those need saying out loud. **`GET /query?q=` above is not served |
| and never has been** — the search page is `GET /?q=`, and the paragraph |
| @@ -4201,9 +4212,9 @@ The gates: |
| fail any inbound route not on the person-facing or connector list — |
| the regression gate for the push surface draft 1 shipped by accident. |
| **The list is literal, not descriptive:** `GET /`, `GET /documents/{id}`, |
| - `POST /documents`, `POST /documents/{id}/delete`, authenticated |
| - `GET /help/open-source`, public static `GET /static/credits.css`, |
| - plus the auth routes. |
| + `POST /documents`, `GET` and `POST /documents/{id}/delete`, public |
| + `GET /help/open-source`, `POST /appearance`, public static |
| + `GET /static/credits.css`, plus the auth routes. |
| Written that way so that adding a thread or chat route goes red on the |
| way in (decision 14) rather than being argued about afterwards. |
| **Built 2026-09-04** as `TestNoUndeclaredRoutes`; **rewritten |
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index b2e3501..f1232df 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -1,13 +1,16 @@ |
| package memoria |
| import ( |
| - "amadan.net/oficina/estilo.git/sky" |
| - "amadan.net/oficina/estilo.git/theme" |
| "context" |
| "log/slog" |
| "net/http" |
| + "net/url" |
| "strings" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/sky" |
| + "amadan.net/oficina/estilo.git/theme" |
| + |
| "github.com/go-chi/chi/v5" |
| "amadan.net/oficina/home/connect" |
| @@ -71,14 +74,23 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| logger.Info("reclaimed interrupted syncs", "runs", reclaimed) |
| } |
| - a := &app{db: d, logger: logger, store: store, syncer: NewSyncer(store, logger)} |
| + a := &app{db: d, logger: logger, store: store, syncer: NewSyncer(store, logger), origin: origin} |
| if err := a.wireNative(origin); err != nil { |
| return nil, nil, err |
| } |
| r := chi.NewRouter() |
| + // Outermost, so a panic anywhere in the router is the 500 page with a |
| + // reference. The page reads the session itself (admissionOf), so it |
| + // shows the viewer's own chrome however deep the panic was. |
| + r.Use(a.recoverToErrorPage) |
| r.Use(a.native.Resume) |
| r.Use(a.homeMiddleware(origin)) |
| + // Unknown routes, and a GET of a POST-only path, are the 404 page (a |
| + // browser) or plain text (anything else). They were Go's plain |
| + // "404 page not found" in the browser's monospace. |
| + r.NotFound(a.routeNotFound) |
| + r.MethodNotAllowed(a.methodNotAllowed(r)) |
| r.Post("/native/request", a.native.ServeHTTP) |
| r.Post("/native/exchange", a.native.ServeHTTP) |
| @@ -86,46 +98,51 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| r.Get("/native/authorize", a.native.ServeHTTP) |
| r.Post("/native/authorize", a.native.ServeHTTP) |
| + // Appearance (estilo chrome §8.3): inside the app-wide CSRF check |
| + // (homeMiddleware) and OUTSIDE the session gate, so a session that |
| + // expired mid-page cannot turn the click into a sign-in loop. It sets |
| + // one host-only preference cookie and redirects to a SafeReturn path. |
| + r.Post("/appearance", chrome.HandleAppearance) |
| + |
| // Public: the sign-in page and auth's own callbacks. |
| r.Get("/signin", a.signin) |
| r.Get("/signin/local", func(w http.ResponseWriter, r *http.Request) { |
| if a.home == nil { |
| - http.NotFound(w, r) |
| + a.routeNotFound(w, r) |
| return |
| } |
| a.localSignin(w, r) |
| }) |
| - r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) { |
| - if a.home == nil { |
| - http.NotFound(w, r) |
| - return |
| - } |
| - a.home.routes.SignedOut(w, r) |
| - }) |
| + // Where Sign out lands, standalone and with Home alike. |
| + r.Get("/signed-out", a.signedOut) |
| + // The Open source page is public: the sign-in page links to it (spec |
| + // §8.4), and it says nothing about the team. It still shows the |
| + // viewer's own chrome when somebody is signed in. |
| + r.Get("/help/open-source", a.credits) |
| r.With(a.requireSession).Get("/account/home", func(w http.ResponseWriter, r *http.Request) { |
| if a.home == nil { |
| - http.NotFound(w, r) |
| + a.routeNotFound(w, r) |
| return |
| } |
| a.homeAccount(w, r) |
| }) |
| r.With(a.requireSession).Post("/account/home", func(w http.ResponseWriter, r *http.Request) { |
| if a.home == nil { |
| - http.NotFound(w, r) |
| + a.routeNotFound(w, r) |
| return |
| } |
| a.home.routes.LinkStart(w, r) |
| }) |
| r.With(a.requireSession).Get("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) { |
| if a.home == nil { |
| - http.NotFound(w, r) |
| + a.routeNotFound(w, r) |
| return |
| } |
| a.home.bridge.ConfirmPage(w, r) |
| }) |
| r.With(a.requireSession).Post("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) { |
| if a.home == nil { |
| - http.NotFound(w, r) |
| + a.routeNotFound(w, r) |
| return |
| } |
| a.home.routes.ConfirmPost(w, r) |
| @@ -137,7 +154,6 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| // door. |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireSession) |
| - r.Get("/help/open-source", a.credits) |
| r.Get("/api/native/session", a.nativeSession) |
| r.Post("/api/native/search", a.nativeSearch) |
| r.Post("/api/native/notes", a.nativeNote) |
| @@ -146,6 +162,7 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| r.Get("/search", a.searchResults) |
| r.Post("/documents", a.createDocument) |
| r.Get("/documents/{id}", a.showDocument) |
| + r.Get("/documents/{id}/delete", a.confirmDelete) |
| r.Post("/documents/{id}/delete", a.deleteDocument) |
| // Connector routes sit INSIDE this group, so they inherit |
| @@ -189,15 +206,20 @@ func (a *app) MountAuth(mux *http.ServeMux, origin string) { |
| if a.home == nil { |
| r.Get("/auth/callback", a.auth.Callback) |
| r.Get("/auth/verify", a.auth.Verify) |
| - r.Post("/signout", a.auth.Signout) |
| + // Where a verified link lands (NewAuth's SignedInPath), and on to |
| + // the page the person was heading for (return.go). |
| + r.Get("/auth/landed", a.landed) |
| + r.Post("/signout", signedOutAfter(a.auth.Signout)) |
| } else { |
| r.Get("/auth/callback", a.home.routes.LocalGuard(a.auth.Callback)) |
| r.Get("/auth/verify", a.home.routes.LocalGuard(a.auth.Verify)) |
| + // Chains to id.*'s /signout (a 307), which ends the Oficina |
| + // session too and returns the browser to /signed-out (§9.5). |
| r.Post("/signout", a.home.routes.Signout) |
| r.Get("/auth/start", a.home.bridge.Start) |
| r.Post("/auth/assertion", a.home.bridge.Callback) |
| r.Get("/auth/finish", a.home.bridge.Finish) |
| - r.Get("/auth/local/complete", a.home.routes.LocalComplete) |
| + r.Get("/auth/local/complete", a.landedAfter(a.home.routes.LocalComplete)) |
| } |
| mux.Handle("/auth/", r) |
| mux.Handle("POST /signout", r) |
| @@ -227,10 +249,29 @@ func (a *app) requireSession(next http.Handler) http.Handler { |
| http.Error(w, "sign-in is not configured", http.StatusInternalServerError) |
| return |
| } |
| + // A signed-out page request goes to /signin WITH the page it asked |
| + // for (spec §9.4): rastrillo's own refusal sends a bare /signin, so |
| + // a document link opened signed out landed on the index. Anything |
| + // else keeps rastrillo's answer below (a POST still gets its 403). |
| + if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| + if _, ok := a.sess.From(r); !ok { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) |
| + return |
| + } |
| + } |
| guarded := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| address, ok := a.identity(r) |
| if !ok || !a.Admit(address) { |
| - http.NotFound(w, r) |
| + // Still closed. What changes is what a REMOVED person is |
| + // told: their session is ended and the sign-in page says |
| + // why, where they used to meet a 404 on every page for |
| + // thirty days. |
| + if _, st := a.admissionOf(r); st == removed { |
| + a.offboard(w, r) |
| + return |
| + } |
| + a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| return |
| } |
| next.ServeHTTP(w, r) |
| @@ -260,8 +301,9 @@ func (a *app) requireOwner(next http.Handler) http.Handler { |
| addr, ok := a.identity(r) |
| if !ok || !a.isOwner(addr) { |
| // 404, not 403: AGENTS.md's rule, and a 403 would confirm |
| - // the route exists to someone who may not use it. |
| - http.NotFound(w, r) |
| + // the route exists to someone who may not use it. The same |
| + // page as an unknown address, so it confirms nothing either. |
| + a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| return |
| } |
| next.ServeHTTP(w, r) |
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go| index 3ea2aa1..453b0dd 100644 |
| --- a/internal/memoria/auth.go |
| +++ b/internal/memoria/auth.go |
| @@ -202,7 +202,9 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str |
| // a bug either way and this is the version that says so. |
| return nil, errors.New("sign-in: refusing to wire auth with no admission gate") |
| } |
| - landing := "/" |
| + // Standalone, a verified link lands on /auth/landed, which goes on to |
| + // the page the person was heading for (return.go). |
| + landing := landedPath |
| if env.HomeEnabled { |
| landing = "/auth/local/complete" |
| } |
| @@ -214,7 +216,7 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str |
| Authorize: authorize, |
| SigninPath: signinPath, |
| SignedInPath: landing, |
| - Subject: "Your memoria sign-in link", |
| + Subject: "Sign in to Memoria", |
| Logger: logger, |
| }) |
| if err != nil { |
| @@ -261,7 +263,7 @@ func (a *app) signinBegin(origin string, begin http.HandlerFunc) http.HandlerFun |
| func (a *app) renderOAuth(w http.ResponseWriter, r *http.Request, destination string) { |
| data := connect.OAuthPage(destination) |
| render(w, "signin_oauth", oauthPage{ |
| - page: page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}, |
| + page: page{Frame: a.frameAs(r, viewer{}, frame{parts: []string{data.Title}, signinPage: true}), ModelOn: a.modelOn()}, |
| Data: data, |
| Door: chrome.SignInNotice{ |
| Icon: assets.Path("static/brand/icon.svg"), App: "Memoria", Sky: sky.For(r.Host), |
diff --git a/internal/memoria/chrome.go b/internal/memoria/chrome.go| new file mode 100644 |
| index 0000000..739d814 |
| --- /dev/null |
| +++ b/internal/memoria/chrome.go |
| @@ -0,0 +1,267 @@ |
| +package memoria |
| + |
| +// The shared chrome (estilo chrome, round 2): every page's bar, account |
| +// menu and <title> are built here, once, so no page can drift from |
| +// another. |
| +// |
| +// Before this the header was rastrillo's rst-page-header with a hand-made |
| +// nav: at 1440px it wrapped onto two rows (97px), the Help dropdown was a |
| +// block inside an inline nav and its open panel covered Sources and Sign |
| +// out, Sign out was a button beside navigation, and nothing said who was |
| +// signed in. Titles mixed "—" and "·". |
| +// |
| +// Spec: estilo spec/shared-chrome, docs/superpowers/specs/ |
| +// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account), §10 |
| +// (titles), §12.1 (Memoria's row). Memoria follows Docs, Sheets and Slides. |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + |
| + "amadan.net/oficina/estilo.git/chrome" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +// appName is the brand and the last part of every <title>. |
| +const appName = "Memoria" |
| + |
| +// Frame is what layout.html reads from every page: the bar, the parts of |
| +// the <title> before the app's name, the Appearance preference for |
| +// <html data-theme>, and whether anybody is signed in (the footer's |
| +// passage count and egress claim are said only to someone who can |
| +// search). |
| +type Frame struct { |
| + Chrome chrome.Bar |
| + TitleParts []string |
| + Theme string |
| + Signed bool |
| +} |
| + |
| +// frame describes one page's chrome. The zero value is a page whose |
| +// <title> is just "Memoria". |
| +type frame struct { |
| + // parts precede "Memoria" in <title>, most specific first. The first |
| + // is the page's h1 (spec §10). |
| + parts []string |
| + // context is the open document's title, shown in the bar. |
| + context string |
| + // signinPage is one of the sign-in, sent and signed-out pages: the |
| + // corner is empty, and the brand is plain text, because everything it |
| + // could link to is behind the session gate and would bounce straight |
| + // back. |
| + signinPage bool |
| + // ret, when set, is where Appearance returns to instead of this |
| + // request's own path and query. |
| + ret string |
| +} |
| + |
| +// titled is the frame for a page whose <title> is these parts. |
| +func titled(parts ...string) frame { return frame{parts: parts} } |
| + |
| +// viewer is who a page's chrome is for. |
| +type viewer struct { |
| + address string |
| + method string |
| + ok bool |
| +} |
| + |
| +// admission is what a request's session says about its person. |
| +type admission int |
| + |
| +const ( |
| + // signedOut: no session at all. |
| + signedOut admission = iota |
| + // admitted: a session whose person may use this instance. |
| + admitted |
| + // removed: a session whose person was resolved and is NOT admitted — |
| + // taken off the owners list after signing in. The session is ended and |
| + // the person told so (offboard), rather than being 404'd on every page |
| + // or bounced between /signin and a page that refuses them. |
| + removed |
| + // unresolved: a session that could not be resolved to a person (a Home |
| + // subject whose lookup failed). Not signed out, not removed: nothing is |
| + // ended on a lookup that may be transient. |
| + unresolved |
| +) |
| + |
| +// admissionOf reads the request's session without requiring one, and |
| +// without side effects. It is the one place a page learns who is signed |
| +// in outside the session gate (error pages, the sign-in page's shortcut, |
| +// Appearance), and it runs the SAME predicate the gate does: Admit. |
| +func (a *app) admissionOf(r *http.Request) (viewer, admission) { |
| + if a.allowUnauthenticated { |
| + // AppForTest: the stub identity is signed in and admitted. |
| + if a.testIdentity == "" { |
| + return viewer{}, signedOut |
| + } |
| + return viewer{address: a.testIdentity, method: "magiclink", ok: true}, admitted |
| + } |
| + s, ok := sessions.Current(r) |
| + if !ok && a.sess != nil { |
| + s, ok = a.sess.From(r) |
| + } |
| + if !ok || s.Subject == "" { |
| + return viewer{}, signedOut |
| + } |
| + switch s.Method { |
| + case connect.HomeMethod: |
| + if a.home == nil { |
| + return viewer{}, unresolved |
| + } |
| + person, err := a.home.bridge.Resolve(r.Context(), s.Subject) |
| + if errors.Is(err, linkstore.ErrRefused) { |
| + // The link store refuses a subject whose linked address Admit |
| + // no longer admits (lookupHomeLocalIdentity answers ErrNoRows |
| + // for it), or that has no link at all: either way the session |
| + // speaks for nobody this instance admits. Removed. A storage |
| + // failure (ErrStorage) may be transient, and ends nothing. |
| + return viewer{}, removed |
| + } |
| + if err != nil { |
| + return viewer{}, unresolved |
| + } |
| + if !a.Admit(person.Label) { |
| + return viewer{}, removed |
| + } |
| + return viewer{address: normaliseAddress(person.Label), method: s.Method, ok: true}, admitted |
| + case "magiclink", "keymail": |
| + if !a.Admit(s.Subject) { |
| + return viewer{}, removed |
| + } |
| + return viewer{address: normaliseAddress(s.Subject), method: s.Method, ok: true}, admitted |
| + } |
| + // Any other method is not a sign-in memoria recognises (identity says |
| + // the same), and it is treated as nobody. |
| + return viewer{}, signedOut |
| +} |
| + |
| +// offboard ends the session of a person who is no longer admitted and |
| +// sends a page request to the sign-in page, which says so. Anything else |
| +// gets the 404 the gate has always answered. |
| +func (a *app) offboard(w http.ResponseWriter, r *http.Request) { |
| + if a.sess != nil { |
| + a.sess.SignOut(w, r) |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| + http.Redirect(w, r, signinPath+"?err=removed", http.StatusSeeOther) |
| + return |
| + } |
| + a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| +} |
| + |
| +// frameFor builds a page's Frame for the request's viewer. |
| +func (a *app) frameFor(r *http.Request, f frame) Frame { |
| + var v viewer |
| + if !f.signinPage { |
| + v, _ = a.admissionOf(r) |
| + } |
| + return a.frameAs(r, v, f) |
| +} |
| + |
| +// frameAs builds a page's Frame for viewer v. |
| +func (a *app) frameAs(r *http.Request, v viewer, f frame) Frame { |
| + bar := chrome.Bar{ |
| + App: chrome.App{ |
| + Name: appName, |
| + Slug: "memoria", |
| + Home: "/", |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + }, |
| + Context: f.context, |
| + Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}, |
| + } |
| + switch { |
| + case f.signinPage: |
| + bar.App.Home = "" |
| + case v.ok: |
| + bar.Account = a.account(r, v, f.ret) |
| + bar.Switcher = a.switcher() |
| + bar.Nav, bar.NavLabel = a.nav(r, v), "Memoria" |
| + default: |
| + bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"} |
| + } |
| + return Frame{ |
| + Chrome: bar, |
| + TitleParts: f.parts, |
| + Theme: chrome.ThemeAttr(chrome.Appearance(r)), |
| + Signed: v.ok, |
| + } |
| +} |
| + |
| +// nav is the bar's section links. Sources is rendered only for owners: |
| +// /connectors is behind requireOwner, which 404s everyone else on purpose, |
| +// so a non-owner must never be shown the link (spec §12.1, Memoria). |
| +func (a *app) nav(r *http.Request, v viewer) []chrome.Link { |
| + links := []chrome.Link{{Href: "/", Label: "Search", Current: r.URL.Path == "/"}} |
| + if a.isOwner(v.address) { |
| + links = append(links, chrome.Link{Href: "/connectors", Label: "Sources", Current: r.URL.Path == "/connectors" || strings.HasPrefix(r.URL.Path, "/connectors/")}) |
| + } |
| + return links |
| +} |
| + |
| +// returnTarget is where a person should come back to: this page, if it is |
| +// a page (a GET), otherwise the index. |
| +func returnTarget(r *http.Request) string { |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| + return "/" |
| + } |
| + return r.URL.RequestURI() |
| +} |
| + |
| +// account is the account menu (spec §8.2): who is signed in, "Connect your |
| +// Oficina account" when that means something, Appearance, Help (Open |
| +// source, last), and the one Sign out in the app. |
| +func (a *app) account(r *http.Request, v viewer, ret string) *chrome.Account { |
| + if ret == "" { |
| + ret = returnTarget(r) |
| + } |
| + acc := &chrome.Account{ |
| + TriggerLabel: "Account", |
| + Initials: chrome.Initials("", v.address), |
| + Address: v.address, |
| + Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"}, |
| + Appearance: &chrome.AppearanceMenu{ |
| + Action: "/appearance", |
| + Return: ret, |
| + Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"), |
| + }, |
| + Help: []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}}, |
| + SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"}, |
| + } |
| + if v.address != "" { |
| + acc.TriggerLabel = "Account: " + v.address |
| + } |
| + if a.offersHomeLink(v) { |
| + acc.Items = []estilomenu.Item{{Href: "/account/home", Label: "Connect your Oficina account"}} |
| + } |
| + return acc |
| +} |
| + |
| +// offersHomeLink is whether the account menu offers to connect the |
| +// person's Oficina account: only when Home is configured, and never to |
| +// somebody who signed in through Home, whose account already is the |
| +// connection (spec §12.1 item 4). |
| +func (a *app) offersHomeLink(v viewer) bool { |
| + return a.home != nil && v.ok && (v.method == "magiclink" || v.method == "keymail") |
| +} |
| + |
| +// switcher is the app switcher, or nil when no hub directory is |
| +// configured. It shows "All apps" alone and no tiles (Paul's §13 answer 2): |
| +// a tile today still costs an assertion exchange through the directory |
| +// session. |
| +func (a *app) switcher() *chrome.Switcher { |
| + if a.home == nil || a.home.directory == "" { |
| + return nil |
| + } |
| + return &chrome.Switcher{ |
| + Label: "Oficina apps", |
| + CurrentLabel: "(this app)", |
| + AllApps: estilomenu.Item{Href: a.home.directory + "/", Label: "All apps"}, |
| + } |
| +} |
diff --git a/internal/memoria/chrome_test.go b/internal/memoria/chrome_test.go| new file mode 100644 |
| index 0000000..85f6ccd |
| --- /dev/null |
| +++ b/internal/memoria/chrome_test.go |
| @@ -0,0 +1,215 @@ |
| +package memoria |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +// §6: the switcher is "All apps" alone (Paul's §13 answer 2), to the hub |
| +// directory, and only when Home names one. No directory, no switcher. |
| +func TestTheSwitcherIsAllAppsOnly(t *testing.T) { |
| + f := newHomeRouterFixtureDir(t, "https://directory.example.test") |
| + f.local(t) |
| + b := f.do("GET", "/", "").Body.String() |
| + if !strings.Contains(b, "<details estilo-switcher") || !strings.Contains(b, `href="https://directory.example.test/"`) || !strings.Contains(b, ">All apps<") { |
| + t.Errorf("the bar has no All apps switcher to the directory:\n%s", b) |
| + } |
| + if strings.Contains(b, "estilo-switcher-app") { |
| + t.Error("the switcher shows app tiles; R2 shows All apps alone") |
| + } |
| + g := newHomeRouterFixture(t) |
| + g.local(t) |
| + if strings.Contains(g.do("GET", "/", "").Body.String(), "estilo-switcher") { |
| + t.Error("a switcher with no directory configured") |
| + } |
| +} |
| + |
| +// §12.1 item 4: "Connect your Oficina account" is offered to a local |
| +// session when Home is configured, never to a Home session (whose account |
| +// already is the connection), and never says "Home". |
| +func TestConnectYourOficinaAccountIsOnlyForLocalSessions(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + b := f.do("GET", "/", "").Body.String() |
| + if !strings.Contains(b, `href="/account/home"`) || !strings.Contains(b, "Connect your Oficina account") { |
| + t.Error("a local session with Home configured is not offered the connection") |
| + } |
| + if strings.Contains(b, "Connect Home account") { |
| + t.Error("the menu still says Connect Home account") |
| + } |
| + if w := f.do("GET", "/account/home", ""); w.Code != 200 { |
| + t.Fatal("link page", w.Code) |
| + } |
| + start := homeRedirect(t, f.do("POST", "/account/home", "")) |
| + homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| + f.do("GET", "/account/home/confirm", "") |
| + homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| + f.cookies = map[string]*http.Cookie{} |
| + start = homeRedirect(t, f.do("GET", "/signin", "")) |
| + homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| + b = f.do("GET", "/", "").Body.String() |
| + if !strings.Contains(b, "estilo-account") { |
| + t.Fatalf("the Home session is not signed in:\n%s", b) |
| + } |
| + if strings.Contains(b, `href="/account/home"`) { |
| + t.Error("a Home session is offered to connect the account it signed in with") |
| + } |
| +} |
| + |
| +// The directory origin is read from HOME_DIRECTORY_ORIGIN, the name Home |
| +// itself reads, validated with the rest of the Home credentials, and means |
| +// nothing without them. |
| +func TestHomeDirectoryOriginIsConfigured(t *testing.T) { |
| + env := homeEnv(t) |
| + env["HOME_DIRECTORY_ORIGIN"] = "https://directory.example.test" |
| + cfg, err := LoadConfig(func(k string) string { return env[k] }) |
| + if err != nil || cfg.Home == nil || cfg.Home.Directory != "https://directory.example.test" { |
| + t.Fatalf("directory not read: %v %+v", err, cfg.Home) |
| + } |
| + env["HOME_DIRECTORY_ORIGIN"] = "https://directory.example.test/path" |
| + if _, err := LoadConfig(func(k string) string { return env[k] }); err == nil { |
| + t.Error("a directory origin with a path was accepted") |
| + } |
| + solo := map[string]string{} |
| + for k, v := range env { |
| + if !strings.HasPrefix(k, "MEMORIA_HOME_") { |
| + solo[k] = v |
| + } |
| + } |
| + solo["HOME_DIRECTORY_ORIGIN"] = "https://directory.example.test" |
| + cfg, err = LoadConfig(func(k string) string { return solo[k] }) |
| + if err != nil || cfg.Home != nil { |
| + t.Errorf("a directory without Home configured Home: %v", err) |
| + } |
| +} |
| + |
| +// §3.3: a panic inside the router is the 500 page with a reference, and |
| +// the viewer's own chrome — the recoverer reads the session itself. |
| +func TestAPanicIsTheErrorPageWithTheViewersChrome(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + boom := f.app.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") })) |
| + for _, signed := range []bool{true, false} { |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/boom", nil) |
| + r.Header.Set("Accept", "text/html") |
| + if signed { |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + } |
| + w := httptest.NewRecorder() |
| + boom.ServeHTTP(w, r) |
| + b := w.Body.String() |
| + if w.Code != http.StatusInternalServerError || !strings.Contains(b, "<title>Something went wrong · Memoria</title>") || !strings.Contains(b, "rst-error-ref") { |
| + t.Errorf("signed in=%v: the panic page = %d:\n%s", signed, w.Code, b) |
| + } |
| + if strings.Contains(b, "estilo-account") != signed { |
| + t.Errorf("signed in=%v: the panic page's account menu is wrong", signed) |
| + } |
| + if !strings.Contains(b, `href="/boom">Try again`) { |
| + t.Errorf("signed in=%v: no Try again to the same URL", signed) |
| + } |
| + } |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/boom", nil) |
| + w := httptest.NewRecorder() |
| + boom.ServeHTTP(w, r) |
| + if w.Code != http.StatusInternalServerError || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") { |
| + t.Errorf("a panic answering a non-browser = %d %q, want plain text", w.Code, w.Header().Get("Content-Type")) |
| + } |
| +} |
| + |
| +// A panic's value is never logged — it could carry a passage (gate 4) — |
| +// only its type, beside the reference the page shows. |
| +func TestAPanicLogsNoContent(t *testing.T) { |
| + var buf bytes.Buffer |
| + f := newHomeRouterFixture(t) |
| + f.app.logger = slog.New(slog.NewTextHandler(&buf, nil)) |
| + boom := f.app.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("the secret passage") })) |
| + boom.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", homeTestOrigin+"/boom", nil)) |
| + if strings.Contains(buf.String(), "secret passage") || !strings.Contains(buf.String(), "ref=") { |
| + t.Errorf("the panic log line: %s", buf.String()) |
| + } |
| + _ = io.Discard |
| +} |
| + |
| +// §4: the import's sentence is a callout toned by the run's state. |
| +func TestTheImportMessageIsToned(t *testing.T) { |
| + for _, c := range []struct { |
| + run SyncRun |
| + tone string |
| + }{ |
| + {SyncRun{State: RunFetching}, ""}, |
| + {SyncRun{State: RunDone}, ""}, |
| + {SyncRun{State: RunDone, FailedPages: 2}, "warning"}, |
| + {SyncRun{State: RunInterrupted}, "warning"}, |
| + {SyncRun{State: RunFailed, ErrorCode: "network"}, "warning"}, |
| + } { |
| + if got := runTone(&c.run); got != c.tone { |
| + t.Errorf("%s (failed pages %d): tone %q, want %q", c.run.State, c.run.FailedPages, got, c.tone) |
| + } |
| + } |
| +} |
| + |
| +// §9.4: the return cookie is sealed, bound to this origin, and carries an |
| +// expiry that is checked on read. |
| +func TestTheReturnCookieIsSealedAndExpires(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + now := int64(1_800_000_000) |
| + f.app.clock = func() time.Time { return time.Unix(now, 0) } |
| + w := httptest.NewRecorder() |
| + f.app.rememberReturn(w, "/documents/7") |
| + c := w.Result().Cookies()[0] |
| + raw, _ := base64.RawURLEncoding.DecodeString(c.Value) |
| + if strings.Contains(string(raw), "/documents/7") { |
| + t.Fatal("the return cookie is readable") |
| + } |
| + read := func() (string, bool) { |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/auth/landed", nil) |
| + r.AddCookie(&http.Cookie{Name: c.Name, Value: c.Value}) |
| + return f.app.rememberedReturn(r) |
| + } |
| + if p, ok := read(); !ok || p != "/documents/7" { |
| + t.Fatalf("the cookie did not read back: %q %v", p, ok) |
| + } |
| + now += returnCookieAge + 1 |
| + if _, ok := read(); ok { |
| + t.Error("an expired return cookie was honoured") |
| + } |
| + now -= returnCookieAge + 1 |
| + f.app.origin = "https://other.example.test" |
| + if _, ok := read(); ok { |
| + t.Error("a return cookie from another origin was honoured") |
| + } |
| +} |
| + |
| +// homeEnv is a complete, valid environment with Home configured. |
| +func homeEnv(t *testing.T) map[string]string { |
| + t.Helper() |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(key.SignPub()) |
| + raw, err := json.Marshal(map[string]string{hex.EncodeToString(sum[:]): base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return map[string]string{ |
| + "MEMORIA_DEK": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)), "MEMORIA_INSTANCE_KEY": "test-only-instance", |
| + "MEMORIA_OWNERS": "alice@example.test", "MEMORIA_ALLOW_LOGGED_MAIL": "1", "MEMORIA_ORIGIN": homeTestOrigin, |
| + "MEMORIA_HOME_ISSUER": homeTestIssuer, "MEMORIA_HOME_CONSUMER_ID": "memoria-test", |
| + "MEMORIA_HOME_KEYS_BASE64": base64.StdEncoding.EncodeToString(raw), "MEMORIA_HOME_DEK_BASE64": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)), |
| + } |
| +} |
diff --git a/internal/memoria/config.go b/internal/memoria/config.go| index 7950722..6405155 100644 |
| --- a/internal/memoria/config.go |
| +++ b/internal/memoria/config.go |
| @@ -131,7 +131,12 @@ func LoadConfig(getenv func(string) string) (Config, error) { |
| if c.Origin = getenv("MEMORIA_ORIGIN"); c.Origin == "" { |
| c.Origin = "http://localhost:8080" |
| } |
| - home := connect.Credentials{Issuer: getenv("MEMORIA_HOME_ISSUER"), ConsumerID: getenv("MEMORIA_HOME_CONSUMER_ID"), KeysBase64: getenv("MEMORIA_HOME_KEYS_BASE64"), DEKBase64: getenv("MEMORIA_HOME_DEK_BASE64")} |
| + // Directory is the hub directory origin, where the app switcher's |
| + // "All apps" goes (shared chrome §6.2). HOME_DIRECTORY_ORIGIN, the name |
| + // Home itself reads — unprefixed, like connect.CredentialsFromEnv. It is |
| + // validated by DecodeCredentials (a bare origin, not the issuer), and it |
| + // means nothing without Home: set alone, it is ignored with the rest. |
| + home := connect.Credentials{Issuer: getenv("MEMORIA_HOME_ISSUER"), ConsumerID: getenv("MEMORIA_HOME_CONSUMER_ID"), KeysBase64: getenv("MEMORIA_HOME_KEYS_BASE64"), DEKBase64: getenv("MEMORIA_HOME_DEK_BASE64"), Directory: getenv("HOME_DIRECTORY_ORIGIN")} |
| if home.Issuer != "" || home.ConsumerID != "" || home.KeysBase64 != "" || home.DEKBase64 != "" { |
| c.Home, err = connect.DecodeCredentials(&home, c.Origin) |
| if err != nil { |
diff --git a/internal/memoria/credits.go b/internal/memoria/credits.go| index 2c899af..b873ecd 100644 |
| --- a/internal/memoria/credits.go |
| +++ b/internal/memoria/credits.go |
| @@ -27,8 +27,21 @@ var embeddedCredits = func() creditInventory { |
| func (a *app) credits(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.Header().Set("Cache-Control", "no-store") |
| - w.Header().Set("Referrer-Policy", "no-referrer") |
| - render(w, "credits", map[string]any{"Signed": true, "HomeEnabled": a.showHomeConnection(r), "ModelOn": a.modelOn(), "Data": credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}}) |
| + // strict-origin, not no-referrer (spec §9.5): this page shows the |
| + // account menu, and with Home a Sign out from here is a POST chained |
| + // to id.*, which admits it by Referer — no-referrer strips it and the |
| + // sign-out lands on "Finish signing out". strict-origin still never |
| + // sends a path, and the credit links carry their own no-referrer. |
| + w.Header().Set("Referrer-Policy", "strict-origin") |
| + render(w, "credits", creditsPage{ |
| + page: a.pageFor(r, titled("Open source")), |
| + Data: credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}, |
| + }) |
| +} |
| + |
| +type creditsPage struct { |
| + page |
| + Data credits.PageData |
| } |
| func serveCreditsCSS(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/memoria/credits_test.go b/internal/memoria/credits_test.go| index 825d1f6..270646f 100644 |
| --- a/internal/memoria/credits_test.go |
| +++ b/internal/memoria/credits_test.go |
| @@ -11,29 +11,44 @@ import ( |
| "testing" |
| ) |
| +// The Open source page is PUBLIC since the shared chrome (spec §8.4: the |
| +// sign-in page links to it, "reachable while signed out"). It says nothing |
| +// about the team, and it still shows a signed-in viewer their own chrome: |
| +// the account menu with its one Sign out, and Sources. |
| +// |
| // Mutation-checked 2026-09-11: removing either route fails this test. |
| -func TestCreditsRequireSessionAndContainDependencyNotices(t *testing.T) { |
| +func TestCreditsArePublicAndContainDependencyNotices(t *testing.T) { |
| fixture := newHomeRouterFixture(t) |
| app := fixture.handler |
| anonymous := fixture.do(http.MethodGet, "/help/open-source", "") |
| - if anonymous.Code != http.StatusSeeOther || strings.Contains(anonymous.Body.String(), "estilo-credits") { |
| - t.Fatal("anonymous credits exposed", anonymous.Code) |
| + if anonymous.Code != http.StatusOK || !strings.Contains(anonymous.Body.String(), "estilo-credits") { |
| + t.Fatal("signed-out credits unavailable", anonymous.Code) |
| + } |
| + for _, private := range []string{"estilo-account", "Sign out", "Sources", `rst-refresh="passages"`, "leaves this instance"} { |
| + if strings.Contains(anonymous.Body.String(), private) { |
| + t.Fatal("signed-out credits carry signed-in chrome", private) |
| + } |
| + } |
| + if !strings.Contains(anonymous.Body.String(), "estilo-bar-signin") { |
| + t.Fatal("signed-out credits have no Sign in corner") |
| } |
| fixture.local(t) |
| response := fixture.do(http.MethodGet, "/help/open-source", "") |
| if response.Code != http.StatusOK || response.Header().Get("Location") != "" || len(response.Result().Cookies()) != 0 { |
| t.Fatal("signed-in credits unavailable", response.Code) |
| } |
| - if strings.Contains(response.Body.String(), `rst-refresh="passages"`) { |
| - t.Fatal("credits rendered a passage count") |
| - } |
| - for _, wanted := range []string{"Sign out", "Sources", "No notice file is included in this release."} { |
| + for _, wanted := range []string{"estilo-account", "Sources", "No notice file is included in this release."} { |
| if !strings.Contains(response.Body.String(), wanted) { |
| t.Fatal("credits lost signed-in shell or missing notice label", wanted) |
| } |
| } |
| - if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Referrer-Policy") != "no-referrer" { |
| - t.Fatal("credits shell may retain account context") |
| + if n := strings.Count(response.Body.String(), ">Sign out<"); n != 1 { |
| + t.Fatalf("credits carry %d Sign outs, want exactly one", n) |
| + } |
| + // strict-origin, not no-referrer (spec §9.5): with Home a Sign out |
| + // from this page is chained to id.*, which admits it by Referer. |
| + if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Referrer-Policy") != "strict-origin" { |
| + t.Fatal("credits headers", response.Header().Get("Cache-Control"), response.Header().Get("Referrer-Policy")) |
| } |
| for _, wanted := range []string{"estilo-credits", "Open source", "Lucide and Feather", "gorm.io/gorm", "gormlite/LICENSE", "/static/credits.css"} { |
| if !strings.Contains(response.Body.String(), wanted) { |
diff --git a/internal/memoria/errors.go b/internal/memoria/errors.go| new file mode 100644 |
| index 0000000..c3fda8e |
| --- /dev/null |
| +++ b/internal/memoria/errors.go |
| @@ -0,0 +1,221 @@ |
| +package memoria |
| + |
| +// HTML error pages (shared chrome spec §3). Before this an unknown URL, a |
| +// document that is not there and every handler failure answered Go's |
| +// plain-text "404 page not found" or a one-line string ("could not read |
| +// that document") in the browser's default monospace, with no title, no |
| +// chrome and no way back (polish audit §B7). |
| +// |
| +// Which answers are pages is decided by the request, not the route: a page |
| +// is rendered only when Accept names text/html, as every browser |
| +// navigation and form post does. The upload tray (Accept: |
| +// application/json), the native API and anything else that did not ask for |
| +// a page keep the plain answers they always had. |
| +// |
| +// A document that is missing and one that is not yours are the same 404 |
| +// (AGENTS.md: a row that isn't yours 404s, never 403s): nothing here |
| +// distinguishes them. |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "runtime/debug" |
| + "strings" |
| + |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "github.com/carlosframework/rastrillo" |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// errorKind says which row of spec §3.2 a failure is. |
| +type errorKind int |
| + |
| +const ( |
| + // kindRoute: nothing lives at this address. |
| + kindRoute errorKind = iota |
| + // kindDocument: the document is missing, or not yours. |
| + kindDocument |
| + // kindStatus: the page for the status itself (400, 403, 5xx). |
| + kindStatus |
| +) |
| + |
| +// errorWords is one row of §3.2: the title part, the heading and the body. |
| +type errorWords struct { |
| + title, heading, body string |
| +} |
| + |
| +func errorCopy(status int, kind errorKind) errorWords { |
| + switch { |
| + case status == http.StatusNotFound && kind == kindDocument: |
| + return errorWords{"Document not available", "This document isn’t available", "It may have been deleted, or it hasn’t been shared with you."} |
| + case status == http.StatusNotFound: |
| + return errorWords{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."} |
| + case status == http.StatusForbidden: |
| + return errorWords{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."} |
| + case status == http.StatusBadRequest: |
| + return errorWords{"That didn’t work", "That didn’t work", "Go back and try again."} |
| + case status == http.StatusServiceUnavailable: |
| + return errorWords{appName + " is unavailable", appName + " is unavailable right now", "Try again in a few minutes."} |
| + } |
| + return errorWords{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."} |
| +} |
| + |
| +// errorView is templates/error.html's data. |
| +type errorView struct { |
| + page |
| + Error chrome.ErrorPage |
| +} |
| + |
| +// wantsHTML reports whether the request asked for a page. |
| +func wantsHTML(r *http.Request) bool { |
| + return strings.Contains(r.Header.Get("Accept"), "text/html") |
| +} |
| + |
| +// plainError is the answer to a request that did not ask for a page. |
| +func plainError(w http.ResponseWriter, status int) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Error(w, http.StatusText(status), status) |
| +} |
| + |
| +// routePattern is the matched route, "/documents/{id}" rather than the |
| +// path it matched: a path is safe to log only because it is never logged. |
| +func routePattern(r *http.Request) string { |
| + if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" { |
| + return rc.RoutePattern() |
| + } |
| + return "(unrouted)" |
| +} |
| + |
| +// errorPage answers status: a page inside the normal layout with the |
| +// viewer's own chrome for a browser, plain text otherwise. ref is the |
| +// reference a 500 quotes, empty otherwise. |
| +func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) { |
| + if !wantsHTML(r) { |
| + plainError(w, status) |
| + return |
| + } |
| + c := errorCopy(status, kind) |
| + pg := a.pageFor(r, titled(c.title)) |
| + a.writeErrorPage(w, r, status, kind, ref, pg) |
| +} |
| + |
| +// writeErrorPage renders the page with pg as its chrome. |
| +func (a *app) writeErrorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string, pg page) { |
| + c := errorCopy(status, kind) |
| + body := chrome.ErrorPage{ |
| + Status: status, |
| + Title: c.heading, |
| + Body: c.body, |
| + Home: chrome.Link{Href: "/", Label: "Go to " + appName}, |
| + } |
| + // Try again is the same URL, and only for a GET: re-sending a POST |
| + // from an error page is the one thing it must never do. |
| + if status >= 500 && r.Method == http.MethodGet { |
| + body.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} |
| + } |
| + if ref != "" { |
| + body.Ref, body.RefLabel = ref, "Reference:" |
| + } |
| + if back := sameOriginReferer(r, a.origin); back != "" && status < 500 { |
| + body.Back = &chrome.Link{Href: back, Label: "Back"} |
| + } |
| + var buf bytes.Buffer |
| + if err := pages["error"].ExecuteTemplate(&buf, "layout", errorView{page: pg, Error: body}); err != nil { |
| + // The page could not render: say so in the plainest way left. |
| + plainError(w, status) |
| + return |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + _, _ = buf.WriteTo(w) |
| +} |
| + |
| +// sameOriginReferer is the page a person came from, when it is this app |
| +// and not this page: rastrillo's rule for the error page's Back link. An |
| +// unchecked Referer is an open redirect with better manners. |
| +func sameOriginReferer(r *http.Request, origin string) string { |
| + raw := r.Header.Get("Referer") |
| + if raw == "" || origin == "" { |
| + return "" |
| + } |
| + u, err := url.Parse(raw) |
| + if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin { |
| + return "" |
| + } |
| + back := u.EscapedPath() |
| + if u.RawQuery != "" { |
| + back += "?" + u.RawQuery |
| + } |
| + if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() || |
| + strings.HasPrefix(back, "/auth/") || strings.HasPrefix(back, "/signin") { |
| + return "" |
| + } |
| + return back |
| +} |
| + |
| +// routeNotFound is the router's NotFound: an unknown address. |
| +func (a *app) routeNotFound(w http.ResponseWriter, r *http.Request) { |
| + a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| +} |
| + |
| +// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a |
| +// path that only takes a POST (somebody opening /documents/1/delete from |
| +// history, say) is the same 404 page as an unknown route: to a person there |
| +// is no page there. Any other method gets 405 in plain text, with the Allow |
| +// header set here — chi's default sets it, a custom handler does not. |
| +func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + var allowed []string |
| + for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { |
| + if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { |
| + allowed = append(allowed, m) |
| + } |
| + } |
| + get := false |
| + for _, m := range allowed { |
| + get = get || m == http.MethodGet |
| + } |
| + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { |
| + a.routeNotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Allow", strings.Join(allowed, ", ")) |
| + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| + } |
| +} |
| + |
| +// recoverToErrorPage turns a panic in a handler into the 500 page with a |
| +// reference, logged with the stack under the same reference. The page |
| +// reads the session itself (admissionOf), so it shows the viewer's own |
| +// chrome wherever in the router the panic happened. Panics outside the |
| +// router reach rastrillo.Serve's own recoverer through ErrorPage. |
| +// |
| +// The value is logged as its type only: a panic raised with a passage in |
| +// it would put content in the log (AGENTS.md, gate 4). |
| +func (a *app) recoverToErrorPage(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + defer func() { |
| + v := recover() |
| + if v == nil { |
| + return |
| + } |
| + if v == http.ErrAbortHandler { |
| + panic(v) |
| + } |
| + ref := rastrillo.NewRef() |
| + a.logger.Error("panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack())) |
| + a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| + }() |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +// ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's own |
| +// panic recovery renders, for a panic outside the router. It reads the |
| +// session optionally like every other error page. |
| +func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| + a.errorPage(w, r, status, kindStatus, ref) |
| +} |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 164b9d8..ee9d6f8 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -12,13 +12,16 @@ import ( |
| "net/url" |
| "strconv" |
| "strings" |
| + "time" |
| "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/estilo.git/sky" |
| + "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/rastrillo/auth" |
| "amadan.net/oficina/suite" |
| "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/sessions" |
| "github.com/go-chi/chi/v5" |
| "amadan.net/oficina/memoria/internal/memoria/query" |
| @@ -87,6 +90,16 @@ type app struct { |
| store *Store |
| auth *auth.Auth |
| home *homeIntegration |
| + // sess reads the session cookie auth mints (the same table, the same |
| + // cookie name), for the pages that want to know who is signed in |
| + // without requiring anybody: the error pages, the sign-in page's |
| + // shortcut, the Open source page. Built once, in wireNative. |
| + sess *sessions.Sessions |
| + // origin is MEMORIA_ORIGIN: the error page's Back check and the return |
| + // cookie's binding. |
| + origin string |
| + // clock is time.Now unless a test sets it (the return cookie's expiry). |
| + clock func() time.Time |
| syncer *Syncer |
| // owners is the admission list, and there is exactly one of it. |
| @@ -132,26 +145,25 @@ func (a *app) UseFakeSource(c SourceClient, verify func(ctx context.Context, tok |
| // is no second value to disagree with. |
| func (a *app) UseAuth(x *auth.Auth) { a.auth = x } |
| -// page is the data every template gets. Chunks and Signed are on it |
| -// because the layout states them on every screen: a corpus count is how |
| -// a person tells "nothing matched" from "nothing indexed". |
| +// page is the data every template gets. Frame is the chrome (chrome.go): |
| +// the bar, the <title> and whether anybody is signed in. Chunks is on it |
| +// because the layout states it on every signed-in screen: a corpus count |
| +// is how a person tells "nothing matched" from "nothing indexed". |
| type page struct { |
| - Signed bool |
| - HomeEnabled bool |
| + Frame Frame |
| // ModelOn switches the footer's egress sentence (layout.html). Set on |
| // EVERY page, because the footer is on every page — the first build |
| // updated the README and left every page promising "no document ever |
| // leaves this instance" while a search had just sent passages. |
| - ModelOn bool |
| - Chunks int |
| - Query string |
| - Searched bool |
| - Flash string |
| - FlashKind string |
| - ForceEmail bool |
| - Results []Result |
| - Documents []Summary |
| - Drop dropZone |
| + ModelOn bool |
| + Chunks int |
| + Query string |
| + Searched bool |
| + Flash string |
| + FlashKind string |
| + Results []Result |
| + Documents []Summary |
| + Drop dropZone |
| // Notices is §6.5's loud withholding: what this page could not show |
| // and which app it came from. **Never a count of what the reader may |
| @@ -391,9 +403,9 @@ func newDropZone() dropZone { |
| Hint: fill("…or drop them anywhere on this page. Up to {max} files at once, {limit} each and {total} in all.", |
| "{max}", fileLimit(), "{limit}", uploadLimit(), "{total}", dropLimit()), |
| - TooManyFiles: fill("That drop had {n} files. memoria takes {max} at a time, so none were stored. Drop in smaller batches.", |
| + TooManyFiles: fill("That drop had {n} files. Memoria takes {max} at a time, so none were stored. Drop in smaller batches.", |
| "{max}", fileLimit()), |
| - TooMuchAtOnce: fill("That drop was more than {total} altogether. memoria takes {total} at a time, so none was stored. Drop fewer files at once.", |
| + TooMuchAtOnce: fill("That drop was more than {total} altogether. Memoria takes {total} at a time, so none was stored. Drop fewer files at once.", |
| "{total}", dropLimit()), |
| Folder: "Memoria upload doesn't support folders. Drop the files inside it instead.", |
| @@ -558,12 +570,13 @@ func (a *app) chunkCount() int { |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| - p := page{ |
| - Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), |
| - Chunks: a.chunkCount(), |
| - Query: q, |
| - Searched: q != "", |
| + f := frame{} |
| + if q != "" { |
| + // A search's tab says what was searched for (spec §10). |
| + f = titled("“" + q + "”") |
| } |
| + p := a.pageFor(r, f) |
| + p.Query, p.Searched = q, q != "" |
| a.runSearch(r, &p) |
| docs, err := a.store.Summaries(r.Context()) |
| if err != nil { |
| @@ -584,11 +597,8 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| // (2026-09-29). POST /documents redirects back here. |
| func (a *app) addContent(w http.ResponseWriter, r *http.Request) { |
| drop := newDropZone() |
| - p := page{ |
| - Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), |
| - Chunks: a.chunkCount(), |
| - Drop: drop, |
| - } |
| + p := a.pageFor(r, titled("Add content")) |
| + p.Drop = drop |
| if f := r.URL.Query().Get("stored"); f != "" { |
| p.Flash, p.FlashKind = drop.outcomeCounts(count(f), count(r.URL.Query().Get("refused"))) |
| } |
| @@ -613,7 +623,7 @@ func (a *app) addContent(w http.ResponseWriter, r *http.Request) { |
| // "/" and still gets these results, inside a page. |
| func (a *app) searchResults(w http.ResponseWriter, r *http.Request) { |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| - p := page{Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), Query: q, Searched: q != ""} |
| + p := page{Query: q, Searched: q != ""} |
| a.runSearch(r, &p) |
| // The passage count is only spoken by the no-match copy, and this |
| // route runs once per keystroke. Counting only when the count will |
| @@ -1037,27 +1047,26 @@ type passage struct { |
| } |
| type documentPage struct { |
| - Signed bool |
| - HomeEnabled bool |
| - ModelOn bool |
| - Chunks int |
| - Title string |
| - Source string |
| - Bytes int |
| - Revision int |
| - Passages []passage |
| - Documents []Summary |
| - Query string |
| - Searched bool |
| - Results []Result |
| - Flash string |
| - FlashKind string |
| + page |
| + ID int64 |
| + Title string |
| + Source string |
| + Bytes int |
| + Revision int |
| + Passages []passage |
| } |
| -func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| +// documentID is the {id} in the URL, or false for anything that is not |
| +// one — which is the same 404 as a document that is not there. |
| +func documentID(r *http.Request) (int64, bool) { |
| id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| - if err != nil { |
| - http.NotFound(w, r) |
| + return id, err == nil |
| +} |
| + |
| +func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| + id, ok := documentID(r) |
| + if !ok { |
| + a.errorPage(w, r, http.StatusNotFound, kindDocument, "") |
| return |
| } |
| title, body, err := a.store.Body(r.Context(), id) |
| @@ -1065,7 +1074,7 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| // A row that is not there 404s. It never 403s, so a document |
| // that exists but is not yours is indistinguishable from one |
| // that does not exist. |
| - http.NotFound(w, r) |
| + a.errorPage(w, r, http.StatusNotFound, kindDocument, "") |
| return |
| } |
| // Read the STORED chunks. Re-chunking here would derive ordinals |
| @@ -1073,8 +1082,9 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| // from the one that ran at ingest. |
| stored, err := a.store.Passages(r.Context(), id) |
| if err != nil { |
| - a.logger.Error("read passages", "id", id, "err", err) |
| - http.Error(w, "could not read that document", http.StatusInternalServerError) |
| + ref := rastrillo.NewRef() |
| + a.logger.Error("read passages", "id", id, "ref", ref, "err", err) |
| + a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| return |
| } |
| passages := make([]passage, 0, len(stored)) |
| @@ -1083,13 +1093,13 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| } |
| source, revision, nbytes, err := a.store.Meta(r.Context(), id) |
| if err != nil { |
| - http.NotFound(w, r) |
| + a.errorPage(w, r, http.StatusNotFound, kindDocument, "") |
| return |
| } |
| _ = body |
| render(w, "document", documentPage{ |
| - Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), |
| - Chunks: a.chunkCount(), |
| + page: a.pageFor(r, frame{parts: []string{title}}), |
| + ID: id, |
| Title: title, |
| Source: source, |
| Bytes: nbytes, |
| @@ -1098,10 +1108,33 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| }) |
| } |
| -func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) { |
| - id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| +// confirmDelete is GET /documents/{id}/delete: the question, asked before |
| +// anything is deleted. The index's Delete used to be a one-click POST |
| +// with no undo (polish audit §B3). This is a page rather than a dialog so |
| +// it works with scripts off and leaves nothing live behind a modal; the |
| +// POST to the same path is the delete. |
| +func (a *app) confirmDelete(w http.ResponseWriter, r *http.Request) { |
| + id, ok := documentID(r) |
| + if !ok { |
| + a.errorPage(w, r, http.StatusNotFound, kindDocument, "") |
| + return |
| + } |
| + title, _, err := a.store.Body(r.Context(), id) |
| if err != nil { |
| - http.NotFound(w, r) |
| + a.errorPage(w, r, http.StatusNotFound, kindDocument, "") |
| + return |
| + } |
| + render(w, "delete", documentPage{ |
| + page: a.pageFor(r, frame{parts: []string{"Delete document", title}, context: title}), |
| + ID: id, |
| + Title: title, |
| + }) |
| +} |
| + |
| +func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) { |
| + id, ok := documentID(r) |
| + if !ok { |
| + a.errorPage(w, r, http.StatusNotFound, kindDocument, "") |
| return |
| } |
| if err := a.store.Delete(r.Context(), id); err != nil { |
| @@ -1123,11 +1156,9 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/", http.StatusSeeOther) |
| } |
| -// signinPage is the sign-in page: the layout's page (footer, egress claim |
| -// and all) around the suite's door. Exactly one of Door, Sent and |
| -// SignedOut is set — |
| -// the form, the check-your-email notice that replaces it, or the page |
| -// Home sign-out lands on. |
| +// signinPage is the sign-in page: the layout's page around the suite's |
| +// door. Exactly one of Door, Sent and SignedOut is set — the form, the |
| +// check-your-email notice that replaces it, or the page sign-out lands on. |
| type signinPage struct { |
| page |
| Door *chrome.SignIn |
| @@ -1135,85 +1166,157 @@ type signinPage struct { |
| SignedOut *chrome.SignInNotice |
| } |
| -// signinLede is what the door says under its heading on a plain visit. |
| -const signinLede = "Memoria holds your team's work in one place. Sign in with your email and we'll send you a link." |
| +// The door's words (spec §9.2). Local mode has no lede: "to continue to |
| +// Memoria" says nothing on Memoria's own page. "Memoria holds your team's |
| +// work…" is retired with it. |
| +const ( |
| + signinHelp = "We’ll email you a link that signs you in. It works once and expires in 15 minutes." |
| + signinSentBody = "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes." |
| +) |
| +// localSignin serves the sign-in page: GET /signin standalone, and in Home |
| +// mode the local-address form (/signin/local, and /signin whenever |
| +// connect hands an outcome back: ?sent, ?err, ?force). |
| +// |
| +// Already signed in, standalone, it skips the form (spec §9.2): 303 to a |
| +// safe return_to or the index. The ADMISSION CHECK RUNS FIRST |
| +// (admissionOf, the gate's own predicate): an address taken off the owners |
| +// list is signed out and told so here, rather than sent to a page that |
| +// sends it straight back — the loop Astra found in Docs (#65, finding 1) |
| +// and Sheets (#39, finding 2). In Home mode this is the local form, where a |
| +// Home session goes on purpose to link an account, so it never skips. |
| +// |
| +// return_to (spec §9.4): rastrillo's auth lands a magic link on a fixed |
| +// SignedInPath and never reads return_to, so the value cannot ride the |
| +// emailed link. It is remembered here in a short-lived sealed cookie |
| +// (return.go) and read back where the link lands. |
| func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { |
| - p := signinPage{page: page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}} |
| - p.ForceEmail = a.home != nil && r.URL.Query().Get("force") == "1" |
| - msg, kind := signinOutcome(r.URL.Query()) |
| + q := r.URL.Query() |
| + ret, hasReturn := safeReturn(q) |
| + if a.home == nil { |
| + switch _, st := a.admissionOf(r); st { |
| + case admitted: |
| + dest := "/" |
| + if hasReturn { |
| + dest = ret |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| + return |
| + case removed: |
| + a.offboard(w, r) |
| + return |
| + } |
| + } |
| + if a.home == nil || r.URL.Path == "/signin/local" { |
| + switch { |
| + case hasReturn: |
| + a.rememberReturn(w, ret) |
| + case len(q) == 0: |
| + // A plain /signin is a fresh start: a destination remembered by |
| + // an earlier, abandoned sign-in must not hijack this one. |
| + a.forgetReturn(w) |
| + } |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + forceEmail := a.home != nil && q.Get("force") == "1" |
| + msg, kind := signinOutcome(q) |
| icon, view := assets.Path("static/brand/icon.svg"), sky.For(r.Host) |
| // A sent link is the door's own check-your-email state, not a flash |
| // over the form. The same page for an owner and a stranger: beginGuard |
| // answers both with ?sent=1, and nothing here reads anything else. |
| if kind == "good" { |
| - back := r.URL.Path |
| - if p.ForceEmail { |
| - back += "?force=1" |
| + // "Use a different address" goes back to the form KEEPING the |
| + // return (spec §9.2) — the remembered page, not a bare /signin, |
| + // which would forget it (Astra on Docs #65, finding 2). With Home |
| + // configured it goes back to the local form, not /signin, which |
| + // would leave local recovery for a Home exchange (Astra on Slides |
| + // #14, finding 2). |
| + again := signinPath |
| + if a.home != nil { |
| + again = "/signin/local" |
| } |
| - // signinOutcome's sentence, in the notice's two parts. |
| - p.Sent = &chrome.SignInNotice{ |
| - Icon: icon, App: "Memoria", Sky: view, |
| - Heading: "Check your email", Body: "We've sent you a link to sign in.", |
| - Link: &chrome.Link{Href: back, Label: "Back to sign in"}, |
| + if remembered, ok := a.rememberedReturn(r); ok { |
| + again += "?return_to=" + url.QueryEscape(remembered) |
| } |
| - render(w, "signin", p) |
| + render(w, "signin", signinPage{ |
| + page: page{Frame: a.frameAs(r, viewer{}, frame{parts: []string{"Check your email"}, signinPage: true}), ModelOn: a.modelOn()}, |
| + Sent: &chrome.SignInNotice{ |
| + Icon: icon, App: appName, Sky: view, |
| + Heading: "Check your email", Body: signinSentBody, |
| + Link: &chrome.Link{Href: again, Label: "Use a different address"}, |
| + }, |
| + }) |
| return |
| } |
| door := &chrome.SignIn{ |
| - Icon: icon, App: "Memoria", Sky: view, |
| - Heading: "Sign in", Lede: signinLede, |
| + Icon: icon, App: appName, Sky: view, |
| + Heading: "Sign in", |
| Action: "/auth/begin", |
| AddressLabel: "Email address", |
| + Help: signinHelp, |
| Submit: "Email me a link", |
| + OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| } |
| - if p.ForceEmail { |
| + if hasReturn { |
| + door.Return = ret |
| + } |
| + if forceEmail { |
| door.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}} |
| } |
| switch kind { |
| case "": |
| case "warn": |
| - // ?reauth=1. Not an error, so not the door's negative callout: |
| - // it stands where the lede does, and says why the form is back. |
| + // ?reauth=1. Not an error, so not the door's negative callout: it |
| + // stands where the lede would, and says why the form is back. |
| door.Lede = msg |
| + case "field": |
| + // An address that is not one belongs to the field, not a banner. |
| + door.AddressError = msg |
| default: |
| door.Error = msg |
| } |
| - p.Door = door |
| - render(w, "signin", p) |
| + render(w, "signin", signinPage{ |
| + page: page{Frame: a.frameAs(r, viewer{}, frame{parts: []string{"Sign in"}, signinPage: true}), ModelOn: a.modelOn()}, |
| + Door: door, |
| + }) |
| } |
| // signinOutcome turns the query a sign-in redirect lands with into the |
| // sentence the page says. The vocabulary is not ours: rastrillo's auth |
| // handlers and beginGuard write it (?sent=1, ?err=rate|address|expired| |
| -// keymail|1, ?reauth=1), and this page must read exactly those words. |
| -// It read ?outcome= for months — a spelling nothing ever wrote — so every |
| -// sign-in, sent or failed, came back to a blank form. The regression test |
| -// follows real redirects rather than building the query by hand, because |
| -// a hand-built query is written from the same model as this switch. |
| +// keymail|1, ?reauth=1), and offboard writes ?err=removed. This page must |
| +// read exactly those words. It read ?outcome= for months — a spelling |
| +// nothing ever wrote — so every sign-in, sent or failed, came back to a |
| +// blank form. The regression test follows real redirects rather than |
| +// building the query by hand, because a hand-built query is written from |
| +// the same model as this switch. |
| // |
| // There is no "not on this instance" sentence, deliberately: beginGuard |
| // answers a stranger with ?sent=1 so the page is not an oracle for who |
| -// the owners are. |
| +// the owners are. (err=removed is said only to the person whose own |
| +// session was just ended, so it tells nobody else anything.) |
| func signinOutcome(q url.Values) (msg, kind string) { |
| if q.Get("sent") != "" { |
| - return "Check your email. We've sent you a link to sign in.", "good" |
| + return signinSentBody, "good" |
| } |
| switch q.Get("err") { |
| case "": |
| case "rate": |
| - return "Too many sign-in attempts. Wait a few minutes, then try again.", "danger" |
| + return "Too many attempts. Wait a few minutes, then try again.", "danger" |
| case "address": |
| - return "That doesn't look like an email address. Check it and try again.", "danger" |
| + return "That doesn’t look like an email address.", "field" |
| case "expired": |
| // rastrillo says one thing for unknown, used and expired alike. |
| - return "That sign-in link has expired or was already used. Enter your email for a new one.", "danger" |
| + return "That link has expired or was already used. Enter your email to get a new one.", "danger" |
| + case "removed": |
| + return "Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong.", "danger" |
| case "keymail": |
| - return "Keymail couldn't finish signing you in. Enter your email and we'll send you a link instead.", "danger" |
| + return "Keymail couldn’t finish signing you in. Enter your email and we’ll send you a link instead.", "danger" |
| default: |
| - return "Something went wrong and no link was sent. Try again.", "danger" |
| + return "We couldn’t sign you in. Enter your email to get a new link.", "danger" |
| } |
| if q.Get("reauth") != "" { |
| return "Sign in again to continue.", "warn" |
diff --git a/internal/memoria/handlers_connector.go b/internal/memoria/handlers_connector.go| index 679991a..424702d 100644 |
| --- a/internal/memoria/handlers_connector.go |
| +++ b/internal/memoria/handlers_connector.go |
| @@ -12,14 +12,11 @@ import ( |
| // connectorPage is what the connector templates render. |
| type connectorPage struct { |
| - Signed bool |
| - HomeEnabled bool |
| - ModelOn bool |
| - Chunks int |
| - Flash string |
| - FlashKind string |
| - Configured bool |
| - Verified string |
| + page |
| + Flash string |
| + FlashKind string |
| + Configured bool |
| + Verified string |
| // Run is the most recent run, if any. |
| Run *runView |
| // Pages is how many pages the ledger holds, and Imported how many |
| @@ -51,8 +48,10 @@ type runView struct { |
| Unread int |
| ErrorCode string |
| Message string |
| - Started string |
| - Finished string |
| + // Tone is the callout tone Message is shown in (runTone). |
| + Tone string |
| + Started string |
| + Finished string |
| } |
| // runMessage maps a run to the sentence the page shows. |
| @@ -77,6 +76,28 @@ func runMessage(r *SyncRun) string { |
| return "" |
| } |
| +// runTone is the tone of runMessage's callout (spec §4): info while it |
| +// runs and when it finished cleanly; a warning when it finished with pages |
| +// it could not read, or stopped (interrupted or failed) — both leave the |
| +// reader with less than they asked for, and neither is their mistake. |
| +// |
| +// Info is the EMPTY tone. rastrillo v0.23's tokens.css draws an untoned |
| +// callout as the neutral one and selects no rst-tone~="info", so writing |
| +// the word would be a value nothing reads |
| +// (TestEveryTemplateAttributeIsInTheDesignSystem). |
| +func runTone(r *SyncRun) string { |
| + switch r.State { |
| + case RunEnumerating, RunFetching: |
| + return "" |
| + case RunDone: |
| + if r.FailedPages > 0 { |
| + return "warning" |
| + } |
| + return "" |
| + } |
| + return "warning" |
| +} |
| + |
| // failMessage maps an error CODE to copy. Codes travel, never messages: |
| // Notion's own error bodies quote page ids and connection names. |
| func failMessage(code string) string { |
| @@ -100,12 +121,12 @@ func failMessage(code string) string { |
| return "The import did not finish." |
| } |
| -func (a *app) connectorBase(r *http.Request) connectorPage { |
| - return connectorPage{Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), Chunks: a.chunkCount()} |
| +func (a *app) connectorBase(r *http.Request, title string) connectorPage { |
| + return connectorPage{page: a.pageFor(r, titled(title))} |
| } |
| func (a *app) connectors(w http.ResponseWriter, r *http.Request) { |
| - p := a.connectorBase(r) |
| + p := a.connectorBase(r, "Sources") |
| if _, err := a.store.Connector(r.Context(), "notion"); err == nil { |
| p.Configured = true |
| } |
| @@ -117,7 +138,7 @@ func (a *app) connectors(w http.ResponseWriter, r *http.Request) { |
| } |
| func (a *app) notionConnector(w http.ResponseWriter, r *http.Request) { |
| - p := a.connectorBase(r) |
| + p := a.connectorBase(r, "Notion") |
| if code := r.URL.Query().Get("err"); code != "" { |
| p.Flash, p.FlashKind = connectorFlash(code), "danger" |
| } |
| @@ -164,7 +185,7 @@ func viewRun(r *SyncRun) *runView { |
| v := &runView{ |
| State: r.State, Roots: r.Roots, Fetched: r.Fetched, Skipped: r.Skipped, |
| Empty: r.Empty, Failed: r.FailedPages, Unread: r.UnreadBlocks, |
| - ErrorCode: r.ErrorCode, Message: runMessage(r), |
| + ErrorCode: r.ErrorCode, Message: runMessage(r), Tone: runTone(r), |
| Running: r.State == RunEnumerating || r.State == RunFetching, |
| Started: r.StartedAt.Format("2 Jan 2006, 15:04"), |
| } |
diff --git a/internal/memoria/home.go b/internal/memoria/home.go| index 4006405..9dbebb1 100644 |
| --- a/internal/memoria/home.go |
| +++ b/internal/memoria/home.go |
| @@ -17,6 +17,9 @@ type homeIntegration struct { |
| bridge *connect.Bridge |
| routes *connect.Routes |
| sessions *sessions.Sessions |
| + // directory is the hub directory origin (HOME_DIRECTORY_ORIGIN), where |
| + // the switcher's "All apps" goes. Empty: no switcher. |
| + directory string |
| } |
| // WireHome keeps the local account's address as the subject used for existing |
| @@ -63,7 +66,7 @@ func (a *app) WireHome(mux *http.ServeMux, cfg Config) error { |
| if err != nil { |
| return errHomeIdentity |
| } |
| - a.home = &homeIntegration{bridge: b, routes: routes, sessions: sess} |
| + a.home = &homeIntegration{bridge: b, routes: routes, sessions: sess, directory: h.Directory} |
| return nil |
| } |
| @@ -80,8 +83,18 @@ func (a *app) homeMiddleware(origin string) func(http.Handler) http.Handler { |
| } |
| } |
| +// signin is GET /signin. With Home configured it is connect's handler, |
| +// which remembers return_to for the Home exchange and, for a live session, |
| +// skips straight to the destination. That skip reads the session cookie |
| +// and nothing else, so the ADMISSION CHECK RUNS HERE FIRST: somebody who |
| +// has lost access is signed out and told so, rather than bounced between |
| +// /signin and a page that refuses them (Astra on Docs #65 and Sheets #39). |
| func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| if a.home != nil { |
| + if _, st := a.admissionOf(r); st == removed { |
| + a.offboard(w, r) |
| + return |
| + } |
| a.home.sessions.Middleware(http.HandlerFunc(a.home.routes.Signin)).ServeHTTP(w, r) |
| return |
| } |
| @@ -100,11 +113,3 @@ func (a *app) homeAccount(w http.ResponseWriter, r *http.Request) { |
| } |
| a.home.routes.Account(w, r) |
| } |
| - |
| -func (a *app) showHomeConnection(r *http.Request) bool { |
| - if a.home == nil { |
| - return false |
| - } |
| - session, ok := sessions.Current(r) |
| - return ok && (session.Method == "magiclink" || session.Method == "keymail") |
| -} |
diff --git a/internal/memoria/home_render.go b/internal/memoria/home_render.go| index c71ace2..6376512 100644 |
| --- a/internal/memoria/home_render.go |
| +++ b/internal/memoria/home_render.go |
| @@ -4,8 +4,6 @@ import ( |
| "bytes" |
| "net/http" |
| - "amadan.net/oficina/estilo.git/chrome" |
| - "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/home/connect" |
| ) |
| @@ -14,22 +12,19 @@ type homePage struct { |
| Home connect.PageData |
| } |
| +// renderHome renders connect's account-link pages (link, confirm, failed) |
| +// inside Memoria's layout, with the viewer's own chrome. Signed out is |
| +// Memoria's own signed-out page, the one Sign out lands on either way. |
| func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| - pg := page{Chunks: a.chunkCount(), ModelOn: a.modelOn()} |
| - data := connect.Page(state, label) |
| - tree, view := pages["home"], any(homePage{page: pg, Home: data}) |
| - // Signed out of Home is the door: Home's heading, sentence and link, |
| - // on Memoria's card and sky, like the sign-in page it sits beside. |
| if state == "signedout" { |
| - tree, view = pages["signin"], signinPage{page: pg, SignedOut: &chrome.SignInNotice{ |
| - Icon: assets.Path("static/brand/icon.svg"), App: "Memoria", Sky: sky.For(r.Host), |
| - Heading: data.Title, Body: data.Body, |
| - Link: &chrome.Link{Href: data.Action, Label: data.ActionLabel}, |
| - }} |
| + a.signedOut(w, r) |
| + return |
| } |
| + data := connect.Page(state, label, func(k string) string { return homeCopy[k] }) |
| + pg := a.pageFor(r, titled(data.Title)) |
| var body bytes.Buffer |
| - if err := tree.ExecuteTemplate(&body, "layout", view); err != nil { |
| - http.Error(w, "something went wrong", http.StatusInternalServerError) |
| + if err := pages["home"].ExecuteTemplate(&body, "layout", homePage{page: pg, Home: data}); err != nil { |
| + plainError(w, http.StatusInternalServerError) |
| return |
| } |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| @@ -37,3 +32,25 @@ func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label st |
| w.WriteHeader(status) |
| _, _ = w.Write(body.Bytes()) |
| } |
| + |
| +// homeCopy is connect's account-connection copy with the hub unnamed |
| +// (Paul's §13 answer 3): "your Oficina account", and the verb Connect. |
| +// The same wording Sheets and Slides use. |
| +var homeCopy = func() map[string]string { |
| + out := make(map[string]string, len(connect.English)) |
| + for k, v := range connect.English { |
| + out[k] = v |
| + } |
| + out["menu_action"] = "Connect your Oficina account" |
| + out["start_title"] = "Connect your Oficina account" |
| + out["start_body"] = "Use your Oficina account to sign in to this account. What Memoria holds stays as it is." |
| + out["start_action"] = "Continue" |
| + out["confirm_body"] = "Connect the account below to the Oficina account you just signed in to." |
| + out["unknown_title"] = "Oficina account not connected" |
| + out["unknown_body"] = "Sign in to your existing account, then connect your Oficina account from the account menu." |
| + out["success_body"] = "You can now use your Oficina account to sign in to this account." |
| + out["signedout_body"] = "You’ve signed out of Memoria on this device." |
| + out["signedout_action"] = "Sign in" |
| + out["documents_action"] = "Back to Memoria" |
| + return out |
| +}() |
diff --git a/internal/memoria/home_router_test.go b/internal/memoria/home_router_test.go| index 6ade219..6c1cd3d 100644 |
| --- a/internal/memoria/home_router_test.go |
| +++ b/internal/memoria/home_router_test.go |
| @@ -39,6 +39,13 @@ type homeRouterFixture struct { |
| } |
| func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| + t.Helper() |
| + return newHomeRouterFixtureDir(t, "") |
| +} |
| + |
| +// newHomeRouterFixtureDir is the fixture with a hub directory origin |
| +// (HOME_DIRECTORY_ORIGIN), which is what puts the switcher in the bar. |
| +func newHomeRouterFixtureDir(t *testing.T, directory string) *homeRouterFixture { |
| t.Helper() |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "memoria.db"), logger) |
| @@ -66,7 +73,7 @@ func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - cfg := Config{Origin: homeTestOrigin, Home: &connect.DecodedCredentials{Issuer: homeTestIssuer, ConsumerID: "memoria-test", Keys: map[string][]byte{"key": key.SignPub()}, DEK: bytes.Repeat([]byte{7}, 32)}} |
| + cfg := Config{Origin: homeTestOrigin, Home: &connect.DecodedCredentials{Issuer: homeTestIssuer, ConsumerID: "memoria-test", Keys: map[string][]byte{"key": key.SignPub()}, DEK: bytes.Repeat([]byte{7}, 32), Directory: directory}} |
| if err = a.WireHome(mux, cfg); err != nil { |
| t.Fatal(err) |
| } |
| @@ -169,7 +176,11 @@ func TestHomeRouterLinkKeepsContentAndSiblingAddress(t *testing.T) { |
| t.Fatal("confirmation", w.Code, w.Body.String()) |
| } |
| homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + // Sign out chains to id.* (home/connect 01166fc, shared chrome §9.5): a |
| + // 307, so the hop stays a POST, to the issuer's /signout. |
| + if w := f.do("POST", "/signout", ""); w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" { |
| + t.Fatal("signout did not chain to id.*", w.Code, w.Header().Get("Location")) |
| + } |
| if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| t.Fatal("signout retained app cookie") |
| } |
| @@ -200,15 +211,22 @@ func TestHomeRouterLinkKeepsContentAndSiblingAddress(t *testing.T) { |
| } |
| f.app.home = enabled |
| f.app.SetOwners([]string{"bob@example.test"}) |
| - if w := f.do("GET", "/", ""); w.Code != 404 { |
| - t.Fatal("removed owner still admitted", w.Code) |
| + // Removed: still not admitted, and now told so. The session is ended |
| + // and a page request goes to the sign-in page's "removed" sentence, |
| + // where it used to meet a 404 on every page until the session lapsed. |
| + if w := f.do("GET", "/", ""); w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/signin?err=removed" { |
| + t.Fatal("removed owner still admitted, or not told", w.Code, w.Header().Get("Location")) |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("a removed owner kept their session") |
| } |
| } |
| func TestHomeRouterUnknownSubjectCannotAllocateOrRead(t *testing.T) { |
| f := newHomeRouterFixture(t) |
| start := homeRedirect(t, f.do("GET", "/signin", "")) |
| w := f.exchange(t, start, "opaque-unlinked-alice") |
| - if w.Code != 403 || !strings.Contains(w.Body.String(), "Home account not connected") { |
| + // "Oficina account", never "Home" (Paul's §13 answer 3; homeCopy). |
| + if w.Code != 403 || !strings.Contains(w.Body.String(), "Oficina account not connected") { |
| t.Fatal("unknown subject", w.Code, w.Body.String()) |
| } |
| var n int |
| @@ -330,21 +348,30 @@ func TestHomePagesStateTheModelSwitch(t *testing.T) { |
| return []*http.Cookie{{Name: name, Value: v}, {Name: name, Value: v}} |
| } |
| visits := []visit{ |
| - {path: "/signed-out"}, |
| {path: "/account/home"}, |
| {path: "/account/home/confirm"}, |
| - {path: "/signin/local"}, |
| {path: "/auth/start", extra: dup("__Host-oficina-home-init")}, |
| {path: "/auth/finish"}, |
| {path: "/auth/callback", extra: dup("__Host-rastrillo_pending")}, |
| {path: "/auth/verify", extra: dup("__Host-rastrillo_pending")}, |
| } |
| + // The sign-in pages carry no footer at all (shared chrome §9.2 retires |
| + // the claim there): the claim is about searching, said only to somebody |
| + // signed in. Checked as an absence, so a footer creeping back onto them |
| + // is seen, not skipped. |
| + signinPages := []string{"/signed-out", "/signin/local"} |
| for _, on := range []bool{true, false} { |
| f := newHomeRouterFixture(t) |
| if on { |
| f.app.store.extractor = &recorder{} |
| } |
| f.local(t) |
| + for _, path := range signinPages { |
| + w := f.do("GET", path, "") |
| + if w.Code != 200 || strings.Contains(w.Body.String(), "<footer") || strings.Contains(w.Body.String(), "leaves this instance") { |
| + t.Errorf("AI on=%v, %s (%d): a sign-in page carries the footer's claim", on, path, w.Code) |
| + } |
| + } |
| for _, v := range visits { |
| path := v.path |
| r := httptest.NewRequest("GET", homeTestOrigin+path, nil) |
| @@ -377,24 +404,27 @@ func TestHomePagesStateTheModelSwitch(t *testing.T) { |
| } |
| } |
| -// Where Home sign-out lands is the door, like the sign-in page beside it: |
| -// Memoria's card on the sky, with Home's words and link, and the door's |
| -// stylesheets linked so the card is drawn. |
| +// Where sign-out lands is the door, like the sign-in page beside it: |
| +// Memoria's card on the sky, with the suite's signed-out words (spec §9.2) |
| +// rather than Home's "You can sign in again with Home", and a plain Sign |
| +// in back to /signin. The same page standalone and with Home. |
| func TestSignedOutIsTheDoor(t *testing.T) { |
| f := newHomeRouterFixture(t) |
| w := f.do("GET", "/signed-out", "") |
| if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" { |
| t.Fatalf("/signed-out: %d, Cache-Control %q", w.Code, w.Header().Get("Cache-Control")) |
| } |
| - data := connect.Page("signedout", "") |
| b := html.UnescapeString(w.Body.String()) |
| for _, want := range []string{ |
| "<div estilo-door", "estilo-sky-layer", "<section estilo-signin", "<span>Memoria</span>", |
| - "/static/chrome.", "/static/sky.", "<title>" + data.Title + " · Memoria</title>", |
| - data.Title, data.Body, `href="` + data.Action + `"`, data.ActionLabel, |
| + "/static/chrome.", "/static/sky.", "<title>Signed out · Memoria</title>", |
| + "You’re signed out", "You’ve signed out of Memoria on this device.", `href="/signin"`, ">Sign in<", |
| } { |
| if !strings.Contains(b, want) { |
| t.Errorf("/signed-out lacks %q", want) |
| } |
| } |
| + if strings.Contains(b, "Home") { |
| + t.Error("/signed-out names Home") |
| + } |
| } |
diff --git a/internal/memoria/native.go b/internal/memoria/native.go| index 483da16..67fc244 100644 |
| --- a/internal/memoria/native.go |
| +++ b/internal/memoria/native.go |
| @@ -18,6 +18,7 @@ func (a *app) wireNative(origin string) error { |
| if e != nil { |
| return e |
| } |
| + a.sess = s |
| a.native = nativeauth.New(s, origin, "Memoria", a.requireSession) |
| return nil |
| } |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index 93e33c0..099c494 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -9,6 +9,7 @@ import ( |
| "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/estilo.git/credits" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/home/connect" |
| "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/rastrillo/ui" |
| @@ -54,23 +55,20 @@ func funcs() template.FuncMap { |
| } |
| func init() { |
| - for _, name := range []string{"index", "add", "document", "connectors", "notion", "home", "credits"} { |
| - pages[name] = template.Must(template.New("layout"). |
| - Funcs(funcs()). |
| - ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")) |
| - } |
| - // The sign-in pages are the suite's door (estilo chrome: estilo-signin, |
| - // estilo-signin-sent), which is built from rastrillo's field and |
| - // callout partials — so those two trees also carry ui's templates and |
| - // ui's funcs (dict). ui.Funcs goes first and funcs() over it, so icon |
| + // Every page is the layout, the results region (the index and the |
| + // fragment share it), the page's own file, and the suite's chrome: |
| + // estilo's bar, account menu, switcher, error body and sign-in door |
| + // (chrome), their menu rows (menu), and the rastrillo partials the door |
| + // is built from (ui). ui.Funcs goes first and funcs() over it, so icon |
| // and iconAssets stay this app's own set rather than reverting to |
| // rastrillo's built-in one (ui.FuncsWith's documented trap). |
| - for _, name := range []string{"signin", "signin_oauth"} { |
| - pages[name] = template.Must(template.Must(template.Must(template.New("layout"). |
| + for _, name := range []string{"index", "add", "document", "delete", "connectors", "notion", "home", "credits", "signin", "signin_oauth", "error"} { |
| + pages[name] = template.Must(template.Must(template.Must(template.Must(template.New("layout"). |
| Funcs(ui.Funcs()).Funcs(funcs()). |
| ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")). |
| ParseFS(ui.Templates(), "*.html")). |
| - ParseFS(chrome.Templates(), "templates/*.html")) |
| + ParseFS(chrome.Templates(), "templates/*.html")). |
| + ParseFS(estilomenu.Templates(), "templates/*.html")) |
| } |
| pages["credits"] = template.Must(pages["credits"].ParseFS(credits.Templates(), "templates/credits.html")) |
| pages["home"] = template.Must(pages["home"].ParseFS(connect.Templates(), "templates/home_link.html")) |
| @@ -80,6 +78,17 @@ func init() { |
| ParseFS(appFS, "templates/results.html")) |
| } |
| +// pageFor is the page data every layout needs, for this request: the |
| +// chrome (Frame), and the footer's passage count and AI state. The count |
| +// is read only for somebody signed in, because only they are told it. |
| +func (a *app) pageFor(r *http.Request, f frame) page { |
| + p := page{Frame: a.frameFor(r, f), ModelOn: a.modelOn()} |
| + if p.Frame.Signed { |
| + p.Chunks = a.chunkCount() |
| + } |
| + return p |
| +} |
| + |
| // render executes name's layout into a buffer before anything touches |
| // the wire: a template error becomes a clean 500 instead of garbage |
| // appended to a half-written page, and headers (flash cookies, say) |
diff --git a/internal/memoria/return.go b/internal/memoria/return.go| new file mode 100644 |
| index 0000000..b4d3b39 |
| --- /dev/null |
| +++ b/internal/memoria/return.go |
| @@ -0,0 +1,237 @@ |
| +package memoria |
| + |
| +// Return to the page you came from (shared chrome spec §9.4). |
| +// |
| +// requireSession used to answer a signed-out page request with rastrillo's |
| +// bare /signin, dropping the path: a link to a document, opened signed |
| +// out, landed on the index after sign-in. Now a signed-out GET is sent to |
| +// /signin?return_to=<the page>, and the page is carried to wherever the |
| +// magic link lands. |
| +// |
| +// It cannot ride the emailed link: rastrillo's auth lands a verified link |
| +// on one fixed SignedInPath and never reads return_to. So GET /signin |
| +// remembers it in a short-lived cookie, and the landing reads it back: |
| +// |
| +// - standalone, SignedInPath is /auth/landed (landed, below); |
| +// - with Home, a local link lands on connect's /auth/local/complete, |
| +// which goes to "/" (or /account/home mid-link); landedAfter swaps |
| +// that "/" for the remembered page. With Home and no local detour, |
| +// connect carries return_to itself through the Home exchange. |
| +// |
| +// The cookie is SEALED (seal.PurposeReturn), bound to this origin, and |
| +// carries its own issue and expiry times, checked on read — a browser's |
| +// Max-Age alone is a suggestion (Astra on Slides #14, finding 3). Its |
| +// value is only ever a same-origin path that handoff.SafePath admits (no |
| +// scheme, no host, no "//", no control characters, not /signin or |
| +// /auth/*), checked when written and again when read, so it can never |
| +// send anyone off-site or into a sign-in loop. |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "math" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/sky" |
| + "amadan.net/oficina/home/handoff" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/seal" |
| +) |
| + |
| +// landedPath is rastrillo's SignedInPath in standalone mode: where a |
| +// verified magic link lands, which then goes on to the remembered page. |
| +const landedPath = "/auth/landed" |
| + |
| +// returnCookieAge is how long a remembered page lasts: a link opened after |
| +// ten minutes, or in another browser, lands on the index. Intended. |
| +const returnCookieAge int64 = 600 |
| + |
| +const returnCookieLimit = 4096 |
| + |
| +type returnIntent struct { |
| + Path string `json:"path"` |
| + Origin string `json:"origin"` |
| + Issued int64 `json:"issued"` |
| + Expires int64 `json:"expires"` |
| +} |
| + |
| +func (a *app) secure() bool { return strings.HasPrefix(a.origin, "https://") } |
| + |
| +// returnCookieName is host-only, and __Host- prefixed wherever the origin |
| +// is https. |
| +func (a *app) returnCookieName() string { |
| + if a.secure() { |
| + return "__Host-memoria_return" |
| + } |
| + return "memoria_return" |
| +} |
| + |
| +func (a *app) now() time.Time { |
| + if a.clock != nil { |
| + return a.clock() |
| + } |
| + return time.Now() |
| +} |
| + |
| +// safeReturn is the request's return_to, when there is exactly one and it |
| +// is a same-origin path handoff.SafePath admits. |
| +func safeReturn(q url.Values) (string, bool) { |
| + values, ok := q["return_to"] |
| + if !ok || len(values) != 1 || !handoff.SafePath(values[0]) { |
| + return "", false |
| + } |
| + return values[0], true |
| +} |
| + |
| +func (a *app) rememberReturn(w http.ResponseWriter, path string) { |
| + if !handoff.SafePath(path) || a.store == nil || a.store.sealer == nil { |
| + return |
| + } |
| + now := a.now().Unix() |
| + if now <= 0 || now > math.MaxInt64-returnCookieAge { |
| + return |
| + } |
| + raw, err := json.Marshal(returnIntent{Path: path, Origin: a.origin, Issued: now, Expires: now + returnCookieAge}) |
| + if err != nil { |
| + return |
| + } |
| + sealed, err := a.store.sealer.Seal(seal.PurposeReturn, raw) |
| + if err != nil { |
| + return |
| + } |
| + value := base64.RawURLEncoding.EncodeToString(sealed) |
| + if len(value) > returnCookieLimit { |
| + return |
| + } |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: a.returnCookieName(), Value: value, Path: "/", |
| + MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(), |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| +} |
| + |
| +func (a *app) forgetReturn(w http.ResponseWriter) { |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: a.returnCookieName(), Value: "", Path: "/", |
| + MaxAge: -1, HttpOnly: true, Secure: a.secure(), |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| +} |
| + |
| +// rememberedReturn reads the remembered page back, validated again: the |
| +// cookie is a request header like any other. Exactly one cookie, strict |
| +// base64, a seal that opens under this purpose, canonical JSON (nothing |
| +// added, nothing duplicated), this origin, and a live, bounded window. |
| +func (a *app) rememberedReturn(r *http.Request) (string, bool) { |
| + if a.store == nil || a.store.sealer == nil { |
| + return "", false |
| + } |
| + count := 0 |
| + value := "" |
| + for _, c := range r.Cookies() { |
| + if c.Name == a.returnCookieName() { |
| + count++ |
| + value = c.Value |
| + } |
| + } |
| + if count != 1 || value == "" || len(value) > returnCookieLimit { |
| + return "", false |
| + } |
| + sealed, err := base64.RawURLEncoding.Strict().DecodeString(value) |
| + if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value { |
| + return "", false |
| + } |
| + raw, err := a.store.sealer.Open(seal.PurposeReturn, sealed) |
| + if err != nil || len(raw) > returnCookieLimit { |
| + return "", false |
| + } |
| + var intent returnIntent |
| + if json.Unmarshal(raw, &intent) != nil { |
| + return "", false |
| + } |
| + canonical, _ := json.Marshal(intent) |
| + now := a.now().Unix() |
| + if !bytes.Equal(raw, canonical) || intent.Origin != a.origin || intent.Issued <= 0 || intent.Issued > now || |
| + intent.Expires <= now || intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge || |
| + !handoff.SafePath(intent.Path) { |
| + return "", false |
| + } |
| + return intent.Path, true |
| +} |
| + |
| +// landed serves GET /auth/landed: the remembered return_to, or the index. |
| +// The cookie is spent either way. |
| +func (a *app) landed(w http.ResponseWriter, r *http.Request) { |
| + dest := "/" |
| + if ret, ok := a.rememberedReturn(r); ok { |
| + dest = ret |
| + } |
| + a.forgetReturn(w) |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| +} |
| + |
| +// landedAfter wraps connect's local-link completion (Home mode): where it |
| +// would land on the root, it lands on the remembered page instead. Any |
| +// other destination — /account/home in the middle of linking an account — |
| +// is connect's decision and is left alone. The cookie is spent either way. |
| +func (a *app) landedAfter(complete http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + ret, ok := a.rememberedReturn(r) |
| + a.forgetReturn(w) |
| + rw := &rewriteLocation{ResponseWriter: w, from: "/"} |
| + if ok { |
| + rw.to = ret |
| + } |
| + complete(rw, r) |
| + } |
| +} |
| + |
| +// rewriteLocation swaps one Location for another at the last moment a |
| +// header is still settable. to == "" leaves it alone. |
| +type rewriteLocation struct { |
| + http.ResponseWriter |
| + from, to string |
| +} |
| + |
| +func (rw *rewriteLocation) WriteHeader(status int) { |
| + if rw.to != "" && rw.Header().Get("Location") == rw.from { |
| + rw.Header().Set("Location", rw.to) |
| + } |
| + rw.ResponseWriter.WriteHeader(status) |
| +} |
| + |
| +func (rw *rewriteLocation) Write(b []byte) (int, error) { |
| + return rw.ResponseWriter.Write(b) |
| +} |
| + |
| +// signedOutAfter wraps rastrillo's Signout (standalone), which ends the |
| +// session and redirects to the sign-in form: a page that, with no word |
| +// about what just happened, read like an error (polish audit §B5). It |
| +// goes to /signed-out instead, which says so. (With Home, connect's |
| +// Signout chains to id.* and id.* returns the browser to /signed-out.) |
| +func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + signout(&rewriteLocation{ResponseWriter: w, from: signinPath, to: "/signed-out"}, r) |
| + } |
| +} |
| + |
| +// signedOut serves GET /signed-out: "You’re signed out", and a primary |
| +// Sign in. The same page standalone and with Home. |
| +func (a *app) signedOut(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + render(w, "signin", signinPage{ |
| + page: page{Frame: a.frameAs(r, viewer{}, frame{parts: []string{"Signed out"}, signinPage: true}), ModelOn: a.modelOn()}, |
| + SignedOut: &chrome.SignInNotice{ |
| + Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host), |
| + Heading: "You’re signed out", |
| + Body: "You’ve signed out of " + appName + " on this device.", |
| + Link: &chrome.Link{Href: signinPath, Label: "Sign in"}, |
| + }, |
| + }) |
| +} |
diff --git a/internal/memoria/seal/seal.go b/internal/memoria/seal/seal.go| index caa723a..79eb791 100644 |
| --- a/internal/memoria/seal/seal.go |
| +++ b/internal/memoria/seal/seal.go |
| @@ -99,6 +99,13 @@ const ( |
| // periods, and it never needs the address back. A reversible id here |
| // would make the tally table a roster of who reads what. |
| PurposeAsker = "sibling.asker" |
| + // PurposeReturn seals the page a signed-out visitor was heading for, |
| + // carried in a ten-minute cookie from GET /signin?return_to= to where |
| + // the magic link lands (shared chrome §9.4). Not content, but a path |
| + // can name a document id, and the rule here is one purpose per kind |
| + // of thing sealed: a return cookie can never be opened as anything |
| + // else, nor anything else as one. |
| + PurposeReturn = "signin.return" |
| ) |
| func sha256New() hash.Hash { return sha256.New() } |
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css| index e4f2d4c..5ada8c7 100644 |
| --- a/internal/memoria/static/memoria.css |
| +++ b/internal/memoria/static/memoria.css |
| @@ -6,42 +6,40 @@ |
| * with a test; memoria's equivalent is TestNoColourLiteralsInAppCSS. |
| */ |
| -[rst-page] { margin: 0 auto; max-width: 46rem; padding: 1.5rem 1rem 4rem; } |
| +/* A stable gutter, so the scrollbar arriving between a short page (sign |
| + * in) and a long one does not shift everything sideways. */ |
| +html { scrollbar-gutter: stable; } |
| -[rst-page-header] { |
| - align-items: center; |
| - border-bottom: 1px solid var(--rst-line); |
| - display: flex; |
| - gap: 1rem; |
| - justify-content: space-between; |
| - margin: 0 auto; |
| - max-width: 46rem; |
| - padding: 0.9rem 1rem; |
| -} |
| -[rst-brand] { |
| - color: var(--rst-text); |
| - font-weight: 620; |
| - letter-spacing: -0.01em; |
| - text-decoration: none; |
| -} |
| +[rst-page] { box-sizing: border-box; margin: 0 auto; max-width: 46rem; padding: 1.5rem 1rem 4rem; } |
| + |
| +/* The header is estilo's bar (chrome.css) and is not styled here. The |
| + * footer is exactly the column's width — [rst-page]'s 46rem less its 1rem |
| + * gutters — so its rule and its text start on the same line as every |
| + * heading and rule above it (they sat 16px to the left, polish audit |
| + * §B6). */ |
| [rst-page-footer] { |
| border-top: 1px solid var(--rst-line); |
| + box-sizing: border-box; |
| margin: 2rem auto 0; |
| - max-width: 46rem; |
| - padding: 0.9rem 1rem 2rem; |
| + padding: 0.9rem 0 2rem; |
| + width: min(44rem, 100% - 2rem); |
| } |
| [rst-inline] { display: inline; } |
| -[rst-search] { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } |
| -[rst-search] input[type="search"] { |
| - background: var(--rst-bg); |
| - border: 1px solid var(--rst-line); |
| - border-radius: var(--rst-radius-sm, 6px); |
| - color: var(--rst-text); |
| - flex: 1; |
| - font: inherit; |
| - padding: 0.55rem 0.7rem; |
| -} |
| +/* Muted text. An attribute this app owns, because the design system's |
| + * muted utility is a class, `.rst-cell-mut`, and spelled as an attribute |
| + * — as every template here did, nineteen times — it styled nothing: the |
| + * footer, table cells, result meta and every hint rendered at full body |
| + * colour. The class is wrong for a sentence anyway (nowrap and an |
| + * ellipsis, written for a table cell), so this is colour only. */ |
| +[rst-muted] { color: var(--rst-text-muted); } |
| + |
| +/* The search form is rastrillo's [rst-search] field box (tokens.css): the |
| + * border, radius and focus ring are the FORM's, and the input inside it |
| + * is transparent. This file used to draw a second bordered box on the |
| + * input, inside the first — the double border (polish audit §B3). Only |
| + * the spacing below it is Memoria's. */ |
| +[rst-search] { margin-bottom: 1.5rem; } |
| [rst-section] { border-top: 1px solid var(--rst-line); margin-top: 2.5rem; padding-top: 1rem; } |
| @@ -51,18 +49,52 @@ |
| [rst-stack] [rst-form-bar] { position: static; margin-top: 1rem; } |
| [rst-stack] { display: flex; flex-direction: column; gap: 0.35rem; } |
| [rst-stack] label { font-weight: 560; margin-top: 0.6rem; } |
| +/* border-box: the fields are width 100% PLUS their padding otherwise, |
| + * which ran 22px past the column at 1440 and off the screen at 390 |
| + * (polish audit §B6). Password fields are here too: the Notion token |
| + * fields were the browser's own. */ |
| [rst-stack] input[type="text"], |
| [rst-stack] input[type="email"], |
| +[rst-stack] input[type="password"], |
| [rst-stack] textarea { |
| background: var(--rst-bg); |
| border: 1px solid var(--rst-line); |
| border-radius: var(--rst-radius-sm, 6px); |
| + box-sizing: border-box; |
| color: var(--rst-text); |
| font: inherit; |
| + max-inline-size: 100%; |
| padding: 0.5rem 0.65rem; |
| width: 100%; |
| } |
| -[rst-stack] textarea { font-family: var(--rst-font-mono, ui-monospace, monospace); resize: vertical; } |
| +[rst-stack] input:focus-visible, |
| +[rst-stack] textarea:focus-visible { |
| + border-color: var(--rst-accent); |
| + outline: 2px solid var(--rst-accent); |
| + outline-offset: 1px; |
| +} |
| +/* Pasted prose is prose: the body font, not a code box. */ |
| +[rst-stack] textarea { resize: vertical; } |
| +/* A button is as wide as its label, not the column: [rst-stack] is a |
| + * flex column, which stretched Connect and Replace edge to edge. */ |
| +[rst-stack] > button { align-self: flex-start; } |
| + |
| +/* The file chooser. The native control stays — it is what works with no |
| + * script, and it says which files were chosen — but its button is drawn |
| + * as the app's buttons are, instead of the browser's grey "Choose Files". */ |
| +[rst-stack] input[type="file"] { color: var(--rst-text-muted); font: inherit; font-size: var(--rst-fs-sm); max-inline-size: 100%; } |
| +[rst-stack] input[type="file"]::file-selector-button { |
| + background: var(--rst-surface); |
| + border: 1px solid var(--rst-line-strong); |
| + border-radius: var(--rst-radius-sm, 6px); |
| + color: var(--rst-text); |
| + cursor: pointer; |
| + font: inherit; |
| + font-weight: 560; |
| + margin-inline-end: 0.75rem; |
| + padding: 0.4rem 0.8rem; |
| +} |
| +[rst-stack] input[type="file"]::file-selector-button:hover { background: var(--rst-surface-2); } |
| /* The drop zone. Nothing here paints until static/drop.js has proved it |
| * can put a file in the input and written rst-dropzone="ready" — so a |
| @@ -212,7 +244,21 @@ |
| font-size: 0.8125rem; |
| margin-bottom: 0.1rem; |
| } |
| +/* Row titles — a result's, and a document's in the index table — are |
| + * the row's subject, in the text colour like every list title in the |
| + * suite, and underlined only on hover. The base link rule (estilo |
| + * base.css) is zero-specificity, so these win without a fight; prose |
| + * links (the fact box's citation, "Back to search") keep the accent and |
| + * the underline, because colour alone must not carry "this is a link". */ |
| +[rst-result-title], |
| +[rst-table] td > a:not([rst-btn]) { |
| + color: var(--rst-text); |
| + text-decoration: none; |
| +} |
| +[rst-result-title]:hover, |
| +[rst-table] td > a:not([rst-btn]):hover { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 0.15em; } |
| [rst-result-title] { font-weight: 600; } |
| +[rst-table] td > a:not([rst-btn]) { font-weight: 550; } |
| [rst-result-trail] { |
| /* The token is --rst-text-muted. This said --rst-text-mut, which |
| * theme.css does not define and which had no fallback, so the whole |
| @@ -287,12 +333,47 @@ |
| } |
| [rst-table] th { font-size: 0.8125rem; font-weight: 560; } |
| -/* 320px is the reflow floor the suite holds itself to. */ |
| +/* 320px is the reflow floor the suite holds itself to. The search form |
| + * stays one row: stacked, rastrillo's align-items: center shrank the input |
| + * to a clipped 180px in the middle of the column. */ |
| @media (max-width: 26rem) { |
| - [rst-search] { flex-direction: column; } |
| [rst-table] { font-size: 0.875rem; } |
| + /* The documents table keeps Title, Updated and Delete on a phone: |
| + * with all five columns it ran 23px past a 320px screen. Source and |
| + * Passages are on the document's own page. */ |
| + [rst-table] th:nth-child(2), [rst-table] td:nth-child(2), |
| + [rst-table] th:nth-child(3), [rst-table] td:nth-child(3) { display: none; } |
| +} |
| + |
| +/* A zero-result line while the person is still typing (search.js marks |
| + * the region pending until they pause): whole-token matching finds |
| + * nothing for "rev" on the way to "revenue", and saying so on every |
| + * keystroke is noise. Hidden, not removed, so the region's markup is the |
| + * server's and a pause only has to lift the attribute. */ |
| +[rst-search-pending] [rst-search-empty] { visibility: hidden; } |
| +[rst-search-empty] { margin: 0 0 1rem; } |
| + |
| +/* The Sources page's lists: rows with a rule between them. They were |
| + * rastrillo's rounded rst-list card holding bare <li>s, so browser bullets |
| + * sat inside the card with the text touching its edge. */ |
| +[rst-sources] { border-top: 1px solid var(--rst-line); list-style: none; margin: 0.75rem 0 0; padding: 0; } |
| +[rst-sources] > li { border-bottom: 1px solid var(--rst-line); display: grid; gap: 0.35rem; padding: 0.75rem 0; } |
| +[rst-sources] > li > p { margin: 0; } |
| +[rst-source-name] { font-weight: 600; } |
| + |
| +/* The Notion import's figures: a two-column list, not the browser's |
| + * indented one-per-line definition list. */ |
| +[rst-facts] { display: grid; gap: 0.35rem 1rem; grid-template-columns: minmax(0, max-content) minmax(0, 1fr); margin: 0.75rem 0 0; } |
| +[rst-facts] dt { color: var(--rst-text-muted); } |
| +[rst-facts] dd { margin: 0; } |
| +@media (max-width: 26rem) { |
| + [rst-facts] { grid-template-columns: minmax(0, 1fr); } |
| + [rst-facts] dd { margin-bottom: 0.4rem; } |
| } |
| +/* Delete, asked first (delete.html). */ |
| +[rst-confirm] form { display: flex; flex-wrap: wrap; gap: var(--rst-sp-2); margin-top: 1.25rem; } |
| + |
| /* §8.8's fact box: a validated fact lifted out of the passages below, |
| with its citation. Sits between the withholding notice and the result |
| list, and is styled to read as an answer rather than as a result — |
diff --git a/internal/memoria/static/search.js b/internal/memoria/static/search.js| index 77d0fb1..90010d3 100644 |
| --- a/internal/memoria/static/search.js |
| +++ b/internal/memoria/static/search.js |
| @@ -50,6 +50,11 @@ |
| "use strict"; |
| var DEBOUNCE_MS = 120; |
| + // How long a live zero-result line waits for the person to stop typing. |
| + // Matching is whole-token, so "rev" finds nothing on the way to |
| + // "revenue"; "No matches" on every partial keystroke was noise (polish |
| + // audit §B1). A submitted search is a page render and shows it at once. |
| + var PAUSE_MS = 900; |
| var form = document.querySelector("form[rst-search]"); |
| var input = form && form.querySelector('input[type="search"]'); |
| @@ -59,6 +64,7 @@ |
| if (!region()) return; |
| var timer = null; |
| + var pauseTimer = null; |
| var inflight = null; |
| var inflightFor = null; |
| @@ -188,6 +194,7 @@ |
| el.innerHTML = html; |
| el.setAttribute("rst-search-results-for", q); |
| el.removeAttribute("rst-search-stale"); |
| + var waiting = holdEmpty(el); |
| if (held >= 0) { |
| var all = links(); |
| (all[held] || all[all.length - 1] || input).focus(); |
| @@ -201,7 +208,33 @@ |
| var url = q === "" ? location.pathname : location.pathname + "?q=" + encodeURIComponent(q); |
| history.replaceState(null, "", url); |
| } |
| - announce(); |
| + if (!waiting) announce(); |
| + } |
| + |
| + /* holdEmpty keeps a live zero-result line out of sight until the person |
| + pauses. The line is the server's markup and stays in the region — |
| + [rst-search-pending] only hides it (memoria.css) — so the stamp above |
| + still vouches for what the region holds, and a pause has nothing to |
| + fetch: it lifts the attribute and says the line. Anything else in the |
| + region (results, the search-failed callout) shows at once. */ |
| + function holdEmpty(el) { |
| + if (pauseTimer) { |
| + clearTimeout(pauseTimer); |
| + pauseTimer = null; |
| + } |
| + if (!el.querySelector("[rst-search-empty]")) { |
| + el.removeAttribute("rst-search-pending"); |
| + return false; |
| + } |
| + el.setAttribute("rst-search-pending", ""); |
| + pauseTimer = setTimeout(function () { |
| + pauseTimer = null; |
| + var now = region(); |
| + if (!now) return; |
| + now.removeAttribute("rst-search-pending"); |
| + announce(); |
| + }, PAUSE_MS); |
| + return true; |
| } |
| /* announce repeats the heading the server just sent into the live |
| @@ -219,7 +252,7 @@ |
| return; |
| } |
| var el = region(); |
| - var said = el && el.querySelector("h2, [rst-callout-body]"); |
| + var said = el && el.querySelector("h2, [rst-callout-body], [rst-search-empty]"); |
| status.textContent = said ? said.textContent.trim() : ""; |
| } |
diff --git a/internal/memoria/templates/add.html b/internal/memoria/templates/add.html| index 2c5439e..d67b390 100644 |
| --- a/internal/memoria/templates/add.html |
| +++ b/internal/memoria/templates/add.html |
| @@ -1,4 +1,3 @@ |
| -{{define "title"}}Add content — Memoria{{end}} |
| {{define "content"}} |
| {{/* Adding content, on its own page (2026-09-29, Paul: "remove the Add |
| forms from the index … that should be a separate Add content form"). |
| @@ -10,7 +9,7 @@ |
| <section rst-section> |
| <h1>Add content</h1> |
| <form method="post" action="/documents" enctype="multipart/form-data" rst-stack> |
| - <label for="title">Title <span rst-cell-mut>{{.Drop.TitleHint}}</span></label> |
| + <label for="title">Title <span rst-muted>{{.Drop.TitleHint}}</span></label> |
| <input type="text" id="title" name="title"> |
| <label for="body">Paste text</label> |
| <textarea id="body" name="body" rows="8" placeholder="Paste a document, notes, minutes…"></textarea> |
| @@ -69,12 +68,11 @@ |
| data-reason-unreadable="{{.ReasonUnreadable}}" |
| data-reason-noanswer="{{.ReasonNoAnswer}}"> |
| <input type="file" id="file" name="file" multiple accept=".txt,.md,.markdown,.csv,text/plain,text/markdown"> |
| - {{/* No rst-cell-mut here: that utility is a class, `.rst-cell-mut`, |
| - and spelled as an attribute it styles nothing at all — the hint |
| - would have looked muted in the markup and read as body text on |
| - the screen. The class itself is wrong for a sentence anyway |
| - (nowrap plus an ellipsis, written for a table cell), so the |
| - muting lives in memoria.css on [rst-dropzone-hint]. |
| + {{/* Muted by memoria.css on [rst-dropzone-hint]. (Not the design |
| + system's muted-cell utility: that is a class, and spelled as an |
| + attribute it styled nothing at all — nineteen places looked |
| + muted in the markup and read as body text on the screen. They |
| + are rst-muted now, which memoria.css styles.) |
| Both paragraphs are `hidden` so a scriptless page promises no |
| drop target. drop.js unhides the note while it is still EMPTY, |
| @@ -88,7 +86,7 @@ |
| {{end}} |
| <div rst-form-bar><button rst-btn="primary">Store it</button></div> |
| </form> |
| - {{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| + {{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"{{if eq .FlashKind "danger"}} role="alert"{{end}}><div rst-callout-body><p>{{.Flash}}</p></div></div>{{end}} |
| {{/* The upload tray. It lives OUTSIDE the form on purpose: it is not |
| something the form submits, and a scriptless browser must find the |
diff --git a/internal/memoria/templates/connectors.html b/internal/memoria/templates/connectors.html| index 0872f10..87b3f83 100644 |
| --- a/internal/memoria/templates/connectors.html |
| +++ b/internal/memoria/templates/connectors.html |
| @@ -1,12 +1,11 @@ |
| -{{define "title"}}Sources · Memoria{{end}} |
| {{define "content"}} |
| <h1>Sources</h1> |
| <p rst-lede> |
| - memoria can pull in work that lives outside the suite. What you connect, |
| - memoria keeps: disconnecting a source stops memoria seeing new changes, |
| - it does not remove what memoria already holds. |
| + Memoria can pull in work that lives outside the suite. What you connect, |
| + Memoria keeps: disconnecting a source stops Memoria seeing new changes, |
| + it does not remove what Memoria already holds. |
| </p> |
| {{/* Suite apps first: they are the ones with an owner other than |
| @@ -17,32 +16,36 @@ |
| sentence about it here. */}} |
| {{if .Siblings}} |
| <h2>Suite apps</h2> |
| -<p rst-cell-mut> |
| - Other apps on your team own this content. memoria asks each of them, |
| +<p rst-muted> |
| + Other apps on your team own this content. Memoria asks each of them, |
| every time, whether you may read a passage — so what you see here is |
| their health, not their permissions. |
| </p> |
| -<ul rst-list> |
| +{{/* A plain list of rows, not rastrillo's list card: the card drew |
| + browser bullets inside a rounded box with the text touching its edge, |
| + and the item and title words it was given are ones nothing styles |
| + (polish audit §B3). memoria.css draws [rst-sources]. */}} |
| +<ul rst-sources> |
| {{range .Siblings}} |
| - <li rst-list-item> |
| - <span rst-list-title>{{.App}}</span> |
| + <li> |
| + <span rst-source-name>{{.App}}</span> |
| {{/* Two lines, two states, toned separately. One flag for both put |
| "No refusal anomaly." inside a warning callout for any sibling |
| that simply had not been polled yet — found by looking at the |
| page, which is the only thing that would have. */}} |
| {{if .CurrentAlarming}} |
| <div rst-callout rst-tone="warning"> |
| - <p rst-callout-body>{{.Current}}</p> |
| + <div rst-callout-body><p>{{.Current}}</p></div> |
| </div> |
| {{else}} |
| - <p rst-cell-mut>{{.Current}}</p> |
| + <p rst-muted>{{.Current}}</p> |
| {{end}} |
| {{if .BreakerAlarming}} |
| <div rst-callout rst-tone="warning"> |
| - <p rst-callout-body>{{.Breaker}}</p> |
| + <div rst-callout-body><p>{{.Breaker}}</p></div> |
| </div> |
| {{else}} |
| - <p rst-cell-mut>{{.Breaker}}</p> |
| + <p rst-muted>{{.Breaker}}</p> |
| {{end}} |
| </li> |
| {{end}} |
| @@ -50,10 +53,10 @@ |
| {{end}} |
| <h2>Connectors</h2> |
| -<ul rst-list> |
| - <li rst-list-item> |
| - <a href="/connectors/notion" rst-list-title>Notion</a> |
| - <p rst-cell-mut> |
| +<ul rst-sources> |
| + <li> |
| + <a href="/connectors/notion" rst-source-name>Notion</a> |
| + <p rst-muted> |
| {{if .Configured}}Connected.{{else}}Not connected.{{end}} |
| Imports the pages you share with a Notion integration, and everything nested under them. |
| </p> |
diff --git a/internal/memoria/templates/credits.html b/internal/memoria/templates/credits.html| index c9d1e6d..29c8a97 100644 |
| --- a/internal/memoria/templates/credits.html |
| +++ b/internal/memoria/templates/credits.html |
| @@ -1,5 +1,2 @@ |
| -{{define "title"}}Open source · Memoria{{end}} |
| -{{define "head"}}<link rel="stylesheet" href="/static/credits.css"><style>[rst-brand] { min-block-size: 24px; display: inline-flex; align-items: center; }</style>{{end}} |
| +{{define "head"}}<link rel="stylesheet" href="/static/credits.css">{{end}} |
| {{define "content"}}{{template "estilo/credits" .Data}}{{end}} |
| - |
| -{{define "footer"}}<footer rst-page-footer></footer>{{end}} |
diff --git a/internal/memoria/templates/delete.html b/internal/memoria/templates/delete.html| new file mode 100644 |
| index 0000000..52410a3 |
| --- /dev/null |
| +++ b/internal/memoria/templates/delete.html |
| @@ -0,0 +1,14 @@ |
| +{{/* Delete, asked first (polish audit §B3: one click deleted a document, |
| + with no undo). A page rather than a dialog: it works with scripts off, |
| + and nothing on the page behind it can take a keystroke meant for it. |
| + Data: documentPage, with Title and ID. */}} |
| +{{define "content"}} |
| +<section rst-confirm> |
| + <h1>Delete document</h1> |
| + <p>Delete “{{.Title}}” from Memoria? Nobody here will find it in a search again, and this can’t be undone.</p> |
| + <form method="post" action="/documents/{{.ID}}/delete"> |
| + <button rst-btn="danger" type="submit">Delete</button> |
| + <a rst-btn href="/">Cancel</a> |
| + </form> |
| +</section> |
| +{{end}} |
diff --git a/internal/memoria/templates/document.html b/internal/memoria/templates/document.html| index e80a57e..e4fb353 100644 |
| --- a/internal/memoria/templates/document.html |
| +++ b/internal/memoria/templates/document.html |
| @@ -1,8 +1,7 @@ |
| -{{define "title"}}{{.Title}} — Memoria{{end}} |
| {{define "content"}} |
| <nav rst-breadcrumb><a href="/">← Back to search</a></nav> |
| <h1>{{.Title}}</h1> |
| -<p rst-cell-mut>{{.Source}} · {{.Bytes}} bytes · revision {{.Revision}}</p> |
| +<p rst-muted>{{.Source}} · {{.Bytes}} bytes · revision {{.Revision}}</p> |
| <article rst-prose> |
| {{range .Passages}} |
| <section id="p{{.Ordinal}}" rst-passage> |
diff --git a/internal/memoria/templates/error.html b/internal/memoria/templates/error.html| new file mode 100644 |
| index 0000000..5cc5027 |
| --- /dev/null |
| +++ b/internal/memoria/templates/error.html |
| @@ -0,0 +1,4 @@ |
| +{{/* An error page (shared chrome §3): estilo's error body, inside the |
| + normal layout and bar, so it shows whoever is signed in. Data: |
| + errorView (errors.go). */}} |
| +{{define "content"}}{{template "estilo-error" .Error}}{{end}} |
diff --git a/internal/memoria/templates/home.html b/internal/memoria/templates/home.html| index 241ad7a..a5069b7 100644 |
| --- a/internal/memoria/templates/home.html |
| +++ b/internal/memoria/templates/home.html |
| @@ -1,2 +1 @@ |
| -{{define "title"}}{{.Home.Title}} · Memoria{{end}} |
| {{define "content"}}{{template "home-connect" .Home}}{{end}} |
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html| index 02d824a..ce102fe 100644 |
| --- a/internal/memoria/templates/index.html |
| +++ b/internal/memoria/templates/index.html |
| @@ -1,6 +1,10 @@ |
| -{{define "title"}}Memoria{{end}} |
| {{define "content"}} |
| +{{/* rastrillo's [rst-search] IS the field box: its border, radius and |
| + focus ring are on the form, and the input inside is transparent. The |
| + button sits inside it, as rastrillo's list-bar-search does. memoria.css |
| + used to draw a second bordered box on the input, which is the double |
| + border the polish audit found. */}} |
| <form method="get" action="/" rst-search> |
| <label for="q" class="rst-sr-only">Search the team's memory</label> |
| <input type="search" id="q" name="q" value="{{.Query}}" placeholder="Search the team's memory…" autofocus> |
| @@ -25,13 +29,13 @@ |
| {{/* Adding content lives at /add now. The one message that still lands |
| on this page is a failed delete, which redirects here. */}} |
| -{{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| +{{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"{{if eq .FlashKind "danger"}} role="alert"{{end}}><div rst-callout-body><p>{{.Flash}}</p></div></div>{{end}} |
| <section rst-section> |
| <h2>{{len .Documents}} document{{if ne (len .Documents) 1}}s{{end}}</h2> |
| <p><a rst-btn="primary" href="/add">Add content</a></p> |
| {{if not .Documents}} |
| - <p rst-cell-mut>Nothing stored yet.</p> |
| + <p rst-muted>Nothing stored yet.</p> |
| {{else}} |
| <table rst-table> |
| <thead><tr><th>Title</th><th>Source</th><th>Passages</th><th>Updated</th><th></th></tr></thead> |
| @@ -39,14 +43,12 @@ |
| {{range .Documents}} |
| <tr> |
| <td><a href="/documents/{{.ID}}">{{.Title}}</a></td> |
| - <td rst-cell-mut>{{.Source}}</td> |
| - <td rst-cell-mut>{{.Chunks}}</td> |
| - <td rst-cell-mut>{{.Updated}}</td> |
| - <td> |
| - <form method="post" action="/documents/{{.ID}}/delete" rst-inline> |
| - <button rst-btn="quiet compact" class="rst-danger">Delete</button> |
| - </form> |
| - </td> |
| + <td rst-muted>{{.Source}}</td> |
| + <td rst-muted>{{.Chunks}}</td> |
| + <td rst-muted>{{.Updated}}</td> |
| + {{/* Delete asks first (delete.html): a link to the question, not |
| + a one-click POST. */}} |
| + <td><a rst-btn="quiet compact" href="/documents/{{.ID}}/delete">Delete</a></td> |
| </tr> |
| {{end}} |
| </tbody> |
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html| index 6d3b6d3..d4b6e0e 100644 |
| --- a/internal/memoria/templates/layout.html |
| +++ b/internal/memoria/templates/layout.html |
| @@ -1,13 +1,35 @@ |
| {{define "layout"}}<!doctype html> |
| -<html lang="en"> |
| +{{/* layout — every page's document: the head, the estilo bar, <main> |
| + and the footer. |
| + |
| + The bar is estilo's (chrome.Bar, built once per request in |
| + chrome.go), in place of the hand-made rst-page-header nav, which |
| + wrapped onto two rows at 1440px (97px) with its open Help panel over |
| + the Sources and Sign out buttons. estilo's is 52px at every width, |
| + signed in or out, with the account always last in the same corner. |
| + |
| + data-theme is written into the first bytes of the response, from the |
| + Appearance cookie read server-side (chrome.Appearance), so there is |
| + no flash. It is ABSENT for "system", and that is the mechanism: |
| + theme.css's toggle is :root[data-theme="light"|"dark"], and with no |
| + attribute the browser follows the operating system. |
| + |
| + The <title> is built here and only here, "{part} · {part} · Memoria", |
| + so no handler can drift from the suite's separator (spec §10). It |
| + used to be written per page, and they said "—" and "·" by turns. |
| + |
| + <meta charset> comes first, before anything that could be read as |
| + text. The stylesheets are in the suite's fixed order: tokens, theme, |
| + brand, then estilo's base, menu and chrome (and the door's sky), then |
| + memoria.css, then the page's own (head). */}} |
| +<html lang="en"{{with .Frame.Theme}} data-theme="{{.}}"{{end}}> |
| <head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>{{range .Frame.TitleParts}}{{.}} · {{end}}{{.Frame.Chrome.App.Name}}</title> |
| <link rel="icon" type="image/png" sizes="32x32" href="{{asset "static/brand/icon-32.png"}}"> |
| <link rel="icon" type="image/svg+xml" href="{{asset "static/brand/icon.svg"}}"> |
| <link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}"> |
| -<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| -<meta charset="utf-8"> |
| -<meta name="viewport" content="width=device-width, initial-scale=1"> |
| -<title>{{block "title" .}}Memoria{{end}}</title> |
| <link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css"> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/busy.css"}}"> |
| @@ -18,9 +40,17 @@ |
| rastrillo scaffold's layout omits it, which is a scaffold bug, not |
| a choice: the page does not go grey, individual rules vanish. */}} |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/base.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/menu.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/chrome.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/sky.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/memoria.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| <script defer src="{{asset "static/busy.js"}}"></script> |
| +{{/* chrome.js closes an open bar panel on Escape or an outside click. |
| + Nothing depends on it. */}} |
| +<script defer src="{{asset "static/chrome.js"}}"></script> |
| {{/* drop.js is inert on every page but the one carrying [rst-dropzone]: |
| it looks for the zone first and returns if there is none. It is |
| linked here rather than in index.html because the layout is where |
| @@ -33,22 +63,24 @@ |
| {{block "head" .}}{{end}} |
| </head> |
| <body> |
| -<header rst-page-header> |
| - <a href="/" rst-brand estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Memoria</a> |
| - {{if .Signed}}<nav rst-inline> |
| - <details rst-dropdown name="rst-menus"><summary>Help</summary><div rst-dropdown-menu><a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Open source</a></div></details> |
| - {{if .HomeEnabled}}<a href="/account/home" rst-btn="ghost">Connect Home account</a>{{end}} |
| - <a href="/connectors" rst-btn="quiet compact">Sources</a> |
| - <form method="post" action="/signout" rst-inline><button rst-btn="quiet compact">Sign out</button></form> |
| - </nav>{{end}} |
| -</header> |
| -<main rst-page> |
| +{{/* [estilo-shell] has no styling of its own; it is the wrapper a |
| + full-height page would key its layout on. */}} |
| +<div estilo-shell> |
| +<a rst-skip href="#main">{{.Frame.Chrome.Labels.Skip}}</a> |
| +{{template "estilo-bar" .Frame.Chrome}} |
| +<main rst-page id="main"> |
| {{template "content" .}} |
| </main> |
| -{{block "footer" .}}<footer rst-page-footer> |
| +{{/* The footer is said only to somebody signed in: its count and its |
| + egress claim are statements about searching (spec §9.2 retires the |
| + claim from the sign-in page), and a visitor who cannot search has |
| + nothing they are true or false about. */}} |
| +{{if .Frame.Signed}}{{block "footer" .}}<footer rst-page-footer> |
| {{/* An upload that never navigates cannot change the passage count, |
| so it carries rst-refresh and drop.js swaps it out of a re-fetch |
| - of the page. On /add it is the only region that does. |
| + of the page. On /add it is the only region that does. drop.js |
| + aborts the whole swap if a named region is missing from the |
| + answer, so the span is on every signed-in page. |
| The span wraps ONLY the count: the egress claim beside it is not |
| derived from anything and has no business travelling with it. |
| @@ -57,8 +89,8 @@ |
| the one thing here that must survive a refresh: a region that |
| carried only the number would refresh a failure into a confident |
| zero, which is the failure shape this app has already had once. */}} |
| - <p rst-cell-mut> |
| - <span rst-refresh="passages">{{if .Signed}}{{if lt .Chunks 0}}The passage count is unavailable.{{else}}{{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} indexed.{{end}}{{end}}</span> |
| + <p rst-muted> |
| + <span rst-refresh="passages">{{if lt .Chunks 0}}The passage count is unavailable.{{else}}{{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} indexed.{{end}}</span> |
| {{/* The egress claim, and it depends on the model switch (§8.9). With |
| AI off the strong sentence is true and is stated. With AI on it is |
| FALSE: a search that asks for a postal address sends up to eight |
| @@ -72,7 +104,8 @@ |
| {{else}}Search is lexical only — no assistant, and <strong>no document ever |
| leaves this instance</strong>.{{end}} |
| </p> |
| -</footer>{{end}} |
| +</footer>{{end}}{{end}} |
| +</div> |
| </body> |
| </html> |
| {{end}} |
diff --git a/internal/memoria/templates/notion.html b/internal/memoria/templates/notion.html| index 46c5278..9baeb34 100644 |
| --- a/internal/memoria/templates/notion.html |
| +++ b/internal/memoria/templates/notion.html |
| @@ -1,4 +1,3 @@ |
| -{{define "title"}}Notion · Memoria{{end}} |
| {{/* The status page refreshes itself ONLY while a run is active, and it |
| is a meta refresh because every affordance here works scriptless. |
| @@ -12,22 +11,23 @@ |
| <h1>Notion</h1> |
| -{{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| +{{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"{{if eq .FlashKind "danger"}} role="alert"{{end}}><div rst-callout-body><p>{{.Flash}}</p></div></div>{{end}} |
| {{if not .Configured}} |
| <p rst-lede> |
| - memoria imports the Notion pages you share with an integration, and |
| + Memoria imports the Notion pages you share with an integration, and |
| everything nested underneath them. |
| </p> |
| + {{/* Info: the untoned callout (runTone says why there is no "info"). */}} |
| <div rst-callout> |
| - <p rst-callout-body> |
| - <strong>What you connect, memoria keeps.</strong> Once a page is |
| - imported, it stays in memoria and stays readable by everyone here — |
| + <div rst-callout-body><p> |
| + <strong>What you connect, Memoria keeps.</strong> Once a page is |
| + imported, it stays in Memoria and stays readable by everyone here — |
| even if you later archive it in Notion, unshare it, or revoke this |
| - token. Removing it is something you do in memoria. |
| - </p> |
| + token. Removing it is something you do in Memoria. |
| + </p></div> |
| </div> |
| <section rst-section> |
| @@ -35,8 +35,8 @@ |
| <ol rst-steps> |
| <li>In Notion, create an <strong>internal integration</strong> with |
| read-only capabilities and no user information.</li> |
| - <li>Share the pages you want memoria to hold with that integration. |
| - Sharing a page is what publishes it. memoria imports what the |
| + <li>Share the pages you want Memoria to hold with that integration. |
| + Sharing a page is what publishes it. Memoria imports what the |
| integration can see and nothing else.</li> |
| <li>Paste the integration token below.</li> |
| </ol> |
| @@ -44,7 +44,7 @@ |
| <form method="post" action="/connectors/notion" rst-stack> |
| <label for="token">Integration token</label> |
| <input type="password" id="token" name="token" autocomplete="off" required> |
| - <p rst-cell-mut>Checked with Notion before it is saved. It is stored sealed and is never shown again.</p> |
| + <p rst-muted>Checked with Notion before it is saved. It is stored sealed and is never shown again.</p> |
| <button rst-btn="primary">Connect</button> |
| </form> |
| </section> |
| @@ -53,7 +53,7 @@ |
| <section rst-section> |
| <h2>Connected</h2> |
| - <p rst-cell-mut> |
| + <p rst-muted> |
| {{if .Verified}}Token last checked {{.Verified}}.{{else}}Token stored.{{end}} |
| </p> |
| @@ -73,7 +73,12 @@ |
| {{if .Run}} |
| <section rst-section> |
| <h2>Last import</h2> |
| - <p>{{.Run.Message}}</p> |
| + {{/* A callout toned by the run's state (spec §4, runTone): a run in |
| + progress or finished cleanly is info (untoned), one that finished |
| + with pages it could not read, stopped, or failed is a warning. It used to be a |
| + plain paragraph, so "some pages could not be read" read like |
| + "finished". */}} |
| + <div rst-callout{{with .Run.Tone}} rst-tone="{{.}}"{{end}}><div rst-callout-body><p>{{.Run.Message}}</p></div></div> |
| <dl rst-facts> |
| <dt>Started</dt><dd>{{.Run.Started}}</dd> |
| @@ -93,11 +98,11 @@ |
| <dt>Could not be read</dt> |
| <dd> |
| {{.Run.Failed}} |
| - {{if .Run.Failed}}<span rst-cell-mut>— counted here rather than left out of the totals</span>{{end}} |
| + {{if .Run.Failed}}<span rst-muted>— counted here rather than left out of the totals</span>{{end}} |
| </dd> |
| {{if .Run.Unread}} |
| - <dt>Blocks memoria does not read</dt> |
| - <dd>{{.Run.Unread}} <span rst-cell-mut>— a page that came in thin may be one of these</span></dd> |
| + <dt>Blocks Memoria does not read</dt> |
| + <dd>{{.Run.Unread}} <span rst-muted>— a page that came in thin may be one of these</span></dd> |
| {{end}} |
| </dl> |
| </section> |
| @@ -105,7 +110,7 @@ |
| <section rst-section> |
| <h2>Replace the token</h2> |
| - <p rst-cell-mut> |
| + <p rst-muted> |
| Rotating a token replaces the stored one. The old token is not kept. |
| Nothing already imported is affected. |
| </p> |
| @@ -126,8 +131,8 @@ |
| <h2>Disconnect</h2> |
| <p> |
| This removes the token and stops future imports. <strong>It deletes |
| - nothing memoria has already imported</strong> — those pages stay |
| - here until someone deletes them in memoria. |
| + nothing Memoria has already imported</strong> — those pages stay |
| + here until someone deletes them in Memoria. |
| </p> |
| <form method="post" action="/connectors/notion/disconnect" rst-inline> |
| <button rst-btn="danger">Disconnect Notion</button> |
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html| index 046c804..1ff8b01 100644 |
| --- a/internal/memoria/templates/results.html |
| +++ b/internal/memoria/templates/results.html |
| @@ -14,10 +14,14 @@ |
| {{define "results"}} |
| {{if .SearchFailed}} |
| <div rst-callout rst-tone="negative"> |
| - <p rst-callout-body>{{template "searchFailed"}}</p> |
| + <div rst-callout-body><p>{{template "searchFailed"}}</p></div> |
| </div> |
| {{else if .Searched}} |
| - <h2>{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} for “{{.Query}}”</h2> |
| + {{/* The count heading only when there is something to count. A zero |
| + is said once, below, as one quiet line (spec §4): "0 results for |
| + “rev”" over a callout was what live search showed on every partial |
| + keystroke. */}} |
| + {{if .Results}}<h2>{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} for “{{.Query}}”</h2>{{end}} |
| {{/* §6.5's loud withholding, above the results rather than below them. |
| A reader who scrolls past the last result has already decided what |
| @@ -28,7 +32,7 @@ |
| existence of a passage this person may not have. */}} |
| {{if .Notices}} |
| <div rst-callout rst-tone="warning" rst-withheld> |
| - {{range .Notices}}<p rst-callout-body>{{.}}</p>{{end}} |
| + <div rst-callout-body>{{range .Notices}}<p>{{.}}</p>{{end}}</div> |
| </div> |
| {{end}} |
| {{/* §8.8's fact box. BELOW the notices and above the list, both |
| @@ -69,27 +73,29 @@ |
| </div> |
| {{end}} |
| {{if not .Results}} |
| - <div rst-callout> |
| - <p rst-callout-body> |
| - {{/* Three sentences, and which one is true depends on why the |
| - page is empty. The third — "a real absence" — was the only |
| - one here, and the notices above have just made it a claim |
| - this template cannot support: a page emptied by withholding |
| - is not an absence, and saying so would be this codebase's |
| - own worst failure asserted on the line written to prevent |
| - it. Same rule as the count that fails not being a zero. */}} |
| - {{if .Notices}} |
| - Nothing that could be shown matched. Passages were withheld — |
| - see above — so this is not necessarily a real absence. |
| - {{else if lt .Chunks 0}} |
| - Nothing matched — but the passage count could not be read, so |
| - this may be an empty index rather than a real absence. |
| - {{else}} |
| - Nothing matched. There {{if eq .Chunks 1}}is{{else}}are{{end}} {{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} |
| - indexed, so this is a real absence rather than an empty index. |
| - {{end}} |
| - </p> |
| - </div> |
| + {{/* Not a callout: one muted line (spec §4). Three sentences, and |
| + which one is true depends on why the page is empty. The first — |
| + "no matches" — is only true when nothing was withheld and the |
| + count could be read: a page emptied by withholding is not an |
| + absence, and a count that failed is not a zero (AGENTS.md). Same |
| + rule as ever; plainer words. |
| + |
| + rst-search-empty marks it for search.js, which holds it back |
| + while the person is still typing: whole-token matching means |
| + "rev" finds nothing on the way to "revenue", and saying so on |
| + every keystroke is noise, not an answer. A submitted search, and |
| + a pause, show it. */}} |
| + <p rst-muted rst-search-empty> |
| + {{if .Notices}} |
| + Nothing that could be shown matched “{{.Query}}”. Some passages were |
| + withheld (see above), so there may be more than this. |
| + {{else if lt .Chunks 0}} |
| + No matches for “{{.Query}}” — but the passage count could not be |
| + read, so the index may be empty. |
| + {{else}} |
| + No matches for “{{.Query}}”. |
| + {{end}} |
| + </p> |
| {{end}} |
| <ol rst-results> |
| {{range $i, $r := .Results}} |
| @@ -99,11 +105,11 @@ |
| says which part of the workspace this came from, the heading |
| says where in the page. A result showing only the second is |
| two indistinguishable "Week one"s under different handbooks. */}} |
| - {{if $r.Path}}<span rst-result-path rst-cell-mut>{{$r.Path}}</span>{{end}} |
| + {{if $r.Path}}<span rst-result-path>{{$r.Path}}</span>{{end}} |
| <a href="/documents/{{$r.DocumentID}}#p{{$r.Ordinal}}" rst-result-title>{{$r.Title}}</a> |
| {{if $r.Heading}}<span rst-result-trail>{{$r.Heading}}</span>{{end}} |
| <p rst-result-snippet>{{$r.Snippet | safeSnippet}}</p> |
| - <p rst-result-meta rst-cell-mut>{{$r.Source}} · passage {{$r.Ordinal}}</p> |
| + <p rst-result-meta rst-muted>{{$r.Source}} · passage {{$r.Ordinal}}</p> |
| </li> |
| {{end}} |
| </ol> |
diff --git a/internal/memoria/templates/signin.html b/internal/memoria/templates/signin.html| index 7e3a5da..49a53e2 100644 |
| --- a/internal/memoria/templates/signin.html |
| +++ b/internal/memoria/templates/signin.html |
| @@ -1,8 +1,6 @@ |
| -{{define "title"}}{{with .SignedOut}}{{.Heading}} · Memoria{{else}}Sign in — Memoria{{end}}{{end}} |
| {{/* The suite's door (estilo chrome): one card on the team's sky, the |
| app's mark and name at its head. chrome.css draws the card, sky.css |
| - the sky; both are vendored from estilo (go run ./scripts/writeestilo). |
| - The layout's footer, and with it the egress claim, stays under it. */}} |
| -{{define "head"}}<link rel="stylesheet" href="{{asset "static/chrome.css"}}"> |
| -<link rel="stylesheet" href="{{asset "static/sky.css"}}">{{end}} |
| + the sky; both are vendored from estilo (go run ./scripts/writeestilo) |
| + and linked by the layout. The bar above it carries the brand only: |
| + the corner is empty on the pages that are themselves sign-in. */}} |
| {{define "content"}}{{with .Sent}}{{template "estilo-signin-sent" .}}{{else with .SignedOut}}{{template "estilo-signed-out" .}}{{else}}{{template "estilo-signin" .Door}}{{end}}{{end}} |
diff --git a/internal/memoria/templates/signin_oauth.html b/internal/memoria/templates/signin_oauth.html| index a93182b..6214adf 100644 |
| --- a/internal/memoria/templates/signin_oauth.html |
| +++ b/internal/memoria/templates/signin_oauth.html |
| @@ -7,7 +7,5 @@ |
| does not apply. The refresh is Home's shared template (Estilo |
| homeconnect); the body is the sign-in door's notice (estilo chrome), |
| so the page between the door and Keymail is the same card. */}} |
| -{{define "head"}}{{template "home-oauth-head" .Data}} |
| -<link rel="stylesheet" href="{{asset "static/chrome.css"}}"> |
| -<link rel="stylesheet" href="{{asset "static/sky.css"}}">{{end}} |
| +{{define "head"}}{{template "home-oauth-head" .Data}}{{end}} |
| {{define "content"}}{{template "estilo-signin-sent" .Door}}{{end}} |
diff --git a/internal/memoriatest/app_test.go b/internal/memoriatest/app_test.go| index c28b0b4..40b93ab 100644 |
| --- a/internal/memoriatest/app_test.go |
| +++ b/internal/memoriatest/app_test.go |
| @@ -30,10 +30,13 @@ func TestNoMatchSaysTheCorpusIsNotEmpty(t *testing.T) { |
| h := newApp(t) |
| store(t, h, "Notes", strings.Repeat("hosting migration notes. ", 20)) |
| body := get(t, h, "/?q=xylophone").Body.String() |
| - if !strings.Contains(body, "0 results") { |
| - t.Errorf("no-match page does not state the result count:\n%s", body) |
| + // One quiet line (shared chrome §4), not "0 results" over a callout. |
| + if !strings.Contains(body, "No matches for “xylophone”.") { |
| + t.Errorf("no-match page does not say nothing matched:\n%s", body) |
| } |
| - if !strings.Contains(body, "indexed") { |
| + // The count is on the same page, in the footer, and it is not zero — |
| + // so a reader can tell an absent answer from an empty index. |
| + if !regexp.MustCompile(`[1-9][0-9]* passages? indexed`).MatchString(body) { |
| t.Error("no-match page does not say how much is indexed, so a reader " + |
| "cannot tell an absent answer from an empty index") |
| } |
diff --git a/internal/memoriatest/chrome_client_test.go b/internal/memoriatest/chrome_client_test.go| new file mode 100644 |
| index 0000000..394c53b |
| --- /dev/null |
| +++ b/internal/memoriatest/chrome_client_test.go |
| @@ -0,0 +1,129 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + |
| + memoria "amadan.net/oficina/memoria/internal/memoria" |
| +) |
| + |
| +// client is a browser in miniature for the shared-chrome gates: it keeps |
| +// cookies across requests (set, replace, delete), sends Accept: text/html |
| +// like a navigation does, and never follows a redirect on its own, so each |
| +// hop is asserted where it happens. |
| +type client struct { |
| + t *testing.T |
| + h http.Handler |
| + cookies map[string]*http.Cookie |
| + accept string |
| +} |
| + |
| +func newClient(t *testing.T, h http.Handler) *client { |
| + return &client{t: t, h: h, cookies: map[string]*http.Cookie{}, accept: "text/html,application/xhtml+xml,*/*;q=0.8"} |
| +} |
| + |
| +func (c *client) do(method, target string, form url.Values) *httptest.ResponseRecorder { |
| + c.t.Helper() |
| + var req *http.Request |
| + if form != nil { |
| + req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + } else { |
| + req = httptest.NewRequest(method, target, nil) |
| + } |
| + if method != http.MethodGet && method != http.MethodHead { |
| + req.Header.Set("Origin", testOrigin) |
| + } |
| + req.Header.Set("Accept", c.accept) |
| + for _, ck := range c.cookies { |
| + req.AddCookie(&http.Cookie{Name: ck.Name, Value: ck.Value}) |
| + } |
| + rec := httptest.NewRecorder() |
| + c.h.ServeHTTP(rec, req) |
| + for _, ck := range rec.Result().Cookies() { |
| + if ck.MaxAge < 0 || ck.Value == "" { |
| + delete(c.cookies, ck.Name) |
| + } else { |
| + c.cookies[ck.Name] = ck |
| + } |
| + } |
| + return rec |
| +} |
| + |
| +func (c *client) get(target string) *httptest.ResponseRecorder { |
| + c.t.Helper() |
| + return c.do(http.MethodGet, target, nil) |
| +} |
| + |
| +// location is the redirect a response gave, failing the test if it gave |
| +// none. |
| +func location(t *testing.T, rec *httptest.ResponseRecorder) string { |
| + t.Helper() |
| + if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { |
| + t.Fatalf("status %d, want a redirect; body %.300q", rec.Code, rec.Body.String()) |
| + } |
| + return rec.Header().Get("Location") |
| +} |
| + |
| +// chromeOwner is the one owner of the apps these gates build. |
| +const chromeOwner = "owner@example.test" |
| + |
| +// signedInClient signs chromeOwner in through the real magic-link flow, |
| +// on a production app (memoria.App with real auth), and returns the |
| +// client holding the session. dbPath lets a second app over the same |
| +// database see the same session. |
| +func signedInClient(t *testing.T, dbPath string) (*client, http.Handler) { |
| + t.Helper() |
| + h, mailer := admissionApp(t, dbPath, []string{chromeOwner}) |
| + c := newClient(t, h) |
| + if rec := c.do(http.MethodPost, "/auth/begin", url.Values{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /auth/begin = %d", rec.Code) |
| + } |
| + link := mailer.link() |
| + if link == "" { |
| + t.Fatalf("no magic link was sent: %q", mailer.text()) |
| + } |
| + u, err := url.Parse(link) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + landing := location(t, c.get(u.RequestURI())) |
| + for i := 0; i < 3 && landing != "/"; i++ { |
| + // The standalone landing (/auth/landed) goes on to the index when |
| + // nothing was remembered. |
| + landing = location(t, c.get(landing)) |
| + } |
| + if landing != "/" { |
| + t.Fatalf("sign-in landed on %q, want /", landing) |
| + } |
| + if rec := c.get("/"); rec.Code != http.StatusOK { |
| + t.Fatalf("GET / after sign-in = %d", rec.Code) |
| + } |
| + return c, h |
| +} |
| + |
| +func tempDB(t *testing.T) string { return filepath.Join(t.TempDir(), "app.db") } |
| + |
| +// newAppWithTestApp is newApp with the app's test seams exposed (AsPerson). |
| +func newAppWithTestApp(t *testing.T) (http.Handler, testApp) { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| + if err != nil { |
| + t.Fatalf("db.Open: %v", err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) |
| + if err != nil { |
| + t.Fatalf("AppForTest: %v", err) |
| + } |
| + return mux, a |
| +} |
diff --git a/internal/memoriatest/chrome_test.go b/internal/memoriatest/chrome_test.go| new file mode 100644 |
| index 0000000..e266538 |
| --- /dev/null |
| +++ b/internal/memoriatest/chrome_test.go |
| @@ -0,0 +1,578 @@ |
| +package memoriatest |
| + |
| +// Gates for the shared chrome (estilo spec/shared-chrome, round 2; Memoria |
| +// is its §12.1 row 6). Each was run against origin/main 97b8a23 before the |
| +// change and failed there; the discussion quotes both runs. |
| + |
| +import ( |
| + "encoding/base64" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "golang.org/x/net/html" |
| +) |
| + |
| +var titleRe = regexp.MustCompile(`(?s)<title>(.*?)</title>`) |
| + |
| +func pageTitle(body string) string { |
| + m := titleRe.FindStringSubmatch(body) |
| + if m == nil { |
| + return "" |
| + } |
| + return html.UnescapeString(strings.TrimSpace(m[1])) |
| +} |
| + |
| +func storedID(t *testing.T, h http.Handler) string { |
| + t.Helper() |
| + m := regexp.MustCompile(`href="/documents/(\d+)"`).FindStringSubmatch(get(t, h, "/").Body.String()) |
| + if m == nil { |
| + t.Fatal("no stored document on the index") |
| + } |
| + return m[1] |
| +} |
| + |
| +// §3: an error is a page with the bar, for a browser; plain text for |
| +// anything else. Before: Go's "404 page not found" in monospace, and |
| +// "could not read that document". |
| +func TestHTMLErrorsRenderTheErrorPage(t *testing.T) { |
| + dbPath := tempDB(t) |
| + in, _ := signedInClient(t, dbPath) |
| + h, _ := admissionApp(t, dbPath, []string{chromeOwner}) |
| + out := newClient(t, h) |
| + |
| + cases := []struct { |
| + name, path, title string |
| + }{ |
| + {"unknown route", "/no/such/page", "Page not found · Memoria"}, |
| + {"missing document", "/documents/99999", "Document not available · Memoria"}, |
| + {"bad parameter", "/documents/not-a-number", "Document not available · Memoria"}, |
| + {"GET of a POST-only path", "/connectors/notion/sync", "Page not found · Memoria"}, |
| + } |
| + for _, tc := range cases { |
| + t.Run("signed in, "+tc.name, func(t *testing.T) { |
| + rec := in.get(tc.path) |
| + body := rec.Body.String() |
| + if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { |
| + t.Fatalf("%s = %d %q, want a 404 page", tc.path, rec.Code, rec.Header().Get("Content-Type")) |
| + } |
| + if got := pageTitle(body); got != tc.title { |
| + t.Errorf("title %q, want %q", got, tc.title) |
| + } |
| + if !strings.Contains(body, "<header estilo-bar") || !strings.Contains(body, "<details estilo-account") { |
| + t.Error("the error page does not carry the bar with the viewer's account") |
| + } |
| + if !strings.Contains(body, "rst-error") { |
| + t.Error("the error page is not estilo's error body") |
| + } |
| + }) |
| + } |
| + t.Run("signed out, unknown route", func(t *testing.T) { |
| + rec := out.get("/no/such/page") |
| + body := rec.Body.String() |
| + if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { |
| + t.Fatalf("= %d %q, want a 404 page", rec.Code, rec.Header().Get("Content-Type")) |
| + } |
| + if got := pageTitle(body); got != "Page not found · Memoria" { |
| + t.Errorf("title %q", got) |
| + } |
| + if !strings.Contains(body, "<header estilo-bar") || strings.Contains(body, "estilo-account") || !strings.Contains(body, "estilo-bar-signin") { |
| + t.Error("signed out, the error page should carry the bar with Sign in and no account") |
| + } |
| + }) |
| + t.Run("signed out, a guarded page is sign-in with the way back", func(t *testing.T) { |
| + if got := location(t, out.get("/documents/99999")); got != "/signin?return_to=%2Fdocuments%2F99999" { |
| + t.Errorf("Location %q", got) |
| + } |
| + }) |
| + t.Run("not a browser: plain text", func(t *testing.T) { |
| + in.accept = "application/json" |
| + defer func() { in.accept = "text/html" }() |
| + for _, p := range []string{"/no/such/page", "/documents/99999"} { |
| + rec := in.get(p) |
| + if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") { |
| + t.Errorf("%s with Accept: application/json = %d %q, want plain text", p, rec.Code, rec.Header().Get("Content-Type")) |
| + } |
| + } |
| + }) |
| + t.Run("another method is a 405 that names what is allowed", func(t *testing.T) { |
| + rec := in.do(http.MethodDelete, "/documents/1", nil) |
| + if rec.Code != http.StatusMethodNotAllowed || !strings.Contains(rec.Header().Get("Allow"), "GET") { |
| + t.Errorf("DELETE /documents/1 = %d, Allow %q", rec.Code, rec.Header().Get("Allow")) |
| + } |
| + }) |
| +} |
| + |
| +// §4: one notice vocabulary. rst-notice, a valued rst-callout and the |
| +// "warn" tone style nothing; a <p rst-callout-body> stacks its own margins |
| +// on the callout's padding (Memoria's double-height callouts). |
| +func TestTemplatesUseOnlyCalloutTones(t *testing.T) { |
| + dir := filepath.Join("..", "memoria", "templates") |
| + entries, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"|<p rst-callout-body|rst-tone="info"`) |
| + checked := 0 |
| + for _, e := range entries { |
| + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + checked++ |
| + for _, m := range bad.FindAllString(string(raw), -1) { |
| + t.Errorf("%s writes %q, which is outside the callout vocabulary", e.Name(), m) |
| + } |
| + } |
| + if checked == 0 { |
| + t.Fatal("no templates read") |
| + } |
| +} |
| + |
| +// §10: every <title> is "{part} · … · Memoria", built by the layout, and |
| +// the first part is the page's h1. The titles mixed "—" and "·" before. |
| +func TestPageTitlesFollowTheSuitePattern(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Minutes, 12 September", "We agreed to move the offsite to November.") |
| + id := storedID(t, h) |
| + for path, want := range map[string]string{ |
| + "/": "Memoria", |
| + "/?q=offsite": "“offsite” · Memoria", |
| + "/add": "Add content · Memoria", |
| + "/documents/" + id: "Minutes, 12 September · Memoria", |
| + "/documents/" + id + "/delete": "Delete document · Minutes, 12 September · Memoria", |
| + "/connectors": "Sources · Memoria", |
| + "/connectors/notion": "Notion · Memoria", |
| + "/help/open-source": "Open source · Memoria", |
| + } { |
| + if got := pageTitle(get(t, h, path).Body.String()); got != want { |
| + t.Errorf("%s: title %q, want %q", path, got, want) |
| + } |
| + } |
| + so, _ := signinApp(t) |
| + for path, want := range map[string]string{ |
| + "/signin": "Sign in · Memoria", |
| + "/signin?sent=1": "Check your email · Memoria", |
| + "/signin?err=expired": "Sign in · Memoria", |
| + "/signed-out": "Signed out · Memoria", |
| + } { |
| + if got := pageTitle(get(t, so, path).Body.String()); got != want { |
| + t.Errorf("%s: title %q, want %q", path, got, want) |
| + } |
| + } |
| +} |
| + |
| +// §8.2: one account menu, the last thing in the bar, with the one Sign |
| +// out in the app; Appearance; Help ending in Open source. Before: a Help |
| +// dropdown, Sources and a Sign out button in a nav that wrapped. |
| +func TestEverySignedInPageHasOneSignOutInTheSameCorner(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Notes", "hosting migration notes") |
| + id := storedID(t, h) |
| + for _, path := range []string{"/", "/?q=hosting", "/add", "/documents/" + id, "/documents/" + id + "/delete", "/connectors", "/connectors/notion", "/help/open-source"} { |
| + doc := parsePage(t, get(t, h, path).Body.String()) |
| + signOuts := findAll(doc, func(n *html.Node) bool { |
| + return (n.Data == "button" || n.Data == "a") && strings.TrimSpace(textOf(n)) == "Sign out" |
| + }) |
| + if len(signOuts) != 1 { |
| + t.Errorf("%s: %d Sign outs, want exactly one", path, len(signOuts)) |
| + continue |
| + } |
| + ends := findAll(doc, hasAttr("estilo-bar-end")) |
| + if len(ends) != 1 { |
| + t.Errorf("%s: %d [estilo-bar-end]", path, len(ends)) |
| + continue |
| + } |
| + var last *html.Node |
| + for c := ends[0].FirstChild; c != nil; c = c.NextSibling { |
| + if c.Type == html.ElementNode { |
| + last = c |
| + } |
| + } |
| + if last == nil || last.Data != "details" { |
| + t.Errorf("%s: the bar does not end with the account menu", path) |
| + continue |
| + } |
| + if _, ok := attrOf(last, "estilo-account"); !ok { |
| + t.Errorf("%s: the last thing in the bar is not the account menu", path) |
| + } |
| + if !within(signOuts[0], last) { |
| + t.Errorf("%s: Sign out is not in the account menu", path) |
| + } |
| + account := textOf(last) |
| + for _, want := range []string{"Signed in as", memoriaTestOwner(), "Appearance", "Light", "Dark", "System", "Help", "Open source"} { |
| + if !strings.Contains(account, want) { |
| + t.Errorf("%s: the account menu lacks %q", path, want) |
| + } |
| + } |
| + pressed := findAll(last, func(n *html.Node) bool { |
| + v, ok := attrOf(n, "aria-pressed") |
| + return ok && (v == "true" || v == "false") |
| + }) |
| + if len(pressed) != 3 { |
| + t.Errorf("%s: %d Appearance options carry aria-pressed, want 3", path, len(pressed)) |
| + } |
| + if strings.Contains(textOf(doc), "Connect Home account") || len(findAll(doc, hasAttr("rst-dropdown"))) != 0 { |
| + t.Errorf("%s: the old header's Help dropdown or \"Connect Home account\" is still there", path) |
| + } |
| + } |
| +} |
| + |
| +func memoriaTestOwner() string { return "owner@test" } |
| + |
| +// §8.4: signed out, the corner is a plain "Sign in" back to this page; on |
| +// the sign-in pages themselves it is empty. |
| +func TestTheSignedOutCorner(t *testing.T) { |
| + h, _ := signinApp(t) |
| + body := newClient(t, h).get("/no/such/page?x=1").Body.String() |
| + doc := parsePage(t, body) |
| + signIns := findAll(doc, hasAttr("estilo-bar-signin")) |
| + if len(signIns) != 1 { |
| + t.Fatalf("%d Sign in links in the bar", len(signIns)) |
| + } |
| + if href, _ := attrOf(signIns[0], "href"); href != "/signin?return_to=%2Fno%2Fsuch%2Fpage%3Fx%3D1" { |
| + t.Errorf("Sign in goes to %q, want back to this page", href) |
| + } |
| + for _, path := range []string{"/signin", "/signin?sent=1", "/signed-out"} { |
| + b := get(t, h, path).Body.String() |
| + if !strings.Contains(b, "<header estilo-bar") || strings.Contains(b, "estilo-bar-signin") || strings.Contains(b, "estilo-account") { |
| + t.Errorf("%s: the corner of a sign-in page should be empty, under the bar", path) |
| + } |
| + } |
| +} |
| + |
| +// §12.1 (Memoria): Sources is a nav link with aria-current, rendered only |
| +// for owners — /connectors 404s everyone else, so a non-owner must never |
| +// be shown the link. |
| +func TestSourcesIsANavLinkForOwnersOnly(t *testing.T) { |
| + h, app := newAppWithTestApp(t) |
| + doc := parsePage(t, get(t, h, "/connectors").Body.String()) |
| + links := findAll(doc, func(n *html.Node) bool { |
| + href, _ := attrOf(n, "href") |
| + return n.Data == "a" && href == "/connectors" && n.Parent != nil && hasAttr("estilo-bar-nav")(n.Parent) |
| + }) |
| + if len(links) != 1 { |
| + t.Fatalf("%d Sources links in the bar's nav, want 1", len(links)) |
| + } |
| + if v, _ := attrOf(links[0], "aria-current"); v != "page" { |
| + t.Error("on /connectors the Sources link is not aria-current") |
| + } |
| + app.AsPerson("reader@test") |
| + if b := get(t, h, "/").Body.String(); strings.Contains(b, `href="/connectors"`) { |
| + t.Error("a non-owner is shown the Sources link") |
| + } |
| +} |
| + |
| +// §8.3: Appearance posts to /appearance, sets one host-only cookie and |
| +// returns; the layout writes data-theme from it. Outside the session gate, |
| +// so an expired session cannot turn the click into a sign-in loop. |
| +func TestAppearance(t *testing.T) { |
| + dbPath := tempDB(t) |
| + c, _ := signedInClient(t, dbPath) |
| + rec := c.do(http.MethodPost, "/appearance", url.Values{"mode": {"dark"}, "return_to": {"/add"}}) |
| + if got := location(t, rec); got != "/add" { |
| + t.Errorf("Appearance returned to %q, want /add", got) |
| + } |
| + if c.cookies["estilo_appearance"] == nil || c.cookies["estilo_appearance"].Value != "dark" { |
| + t.Fatal("no estilo_appearance=dark cookie") |
| + } |
| + if !strings.Contains(c.get("/add").Body.String(), `<html lang="en" data-theme="dark">`) { |
| + t.Error("the page does not carry data-theme=dark") |
| + } |
| + h, _ := admissionApp(t, tempDB(t), []string{chromeOwner}) |
| + out := newClient(t, h) |
| + if got := location(t, out.do(http.MethodPost, "/appearance", url.Values{"mode": {"light"}, "return_to": {"//evil.example/"}})); got != "/" { |
| + t.Errorf("an off-site return_to went to %q, want /", got) |
| + } |
| +} |
| + |
| +// §9.4: a document link opened signed out comes back to that document |
| +// after sign-in, through a sealed cookie; "Use a different address" keeps |
| +// it; a plain /signin forgets it. |
| +func TestASignedOutDeepLinkComesBackAfterSignIn(t *testing.T) { |
| + h, mailer := admissionApp(t, tempDB(t), []string{chromeOwner}) |
| + c := newClient(t, h) |
| + const deep = "/documents/7?x=1" |
| + signin := location(t, c.get(deep)) |
| + if signin != "/signin?return_to=%2Fdocuments%2F7%3Fx%3D1" { |
| + t.Fatalf("a signed-out deep link went to %q", signin) |
| + } |
| + c.get(signin) |
| + var ret *http.Cookie |
| + for name, ck := range c.cookies { |
| + if strings.Contains(name, "return") { |
| + ret = ck |
| + } |
| + } |
| + if ret == nil { |
| + t.Fatal("GET /signin?return_to= remembered nothing") |
| + } |
| + raw, _ := base64.RawURLEncoding.DecodeString(ret.Value) |
| + if strings.Contains(ret.Value, "documents") || strings.Contains(string(raw), "documents") || !ret.HttpOnly { |
| + t.Error("the return cookie is not sealed (the path reads straight out of it)") |
| + } |
| + sent := location(t, c.do(http.MethodPost, "/auth/begin", url.Values{"address": {chromeOwner}})) |
| + body := c.get(sent).Body.String() |
| + if !strings.Contains(body, `href="/signin?return_to=%2Fdocuments%2F7%3Fx%3D1"`) || !strings.Contains(body, "Use a different address") { |
| + t.Errorf("\"Use a different address\" does not keep the return:\n%s", body) |
| + } |
| + u, _ := url.Parse(mailer.link()) |
| + dest := location(t, c.get(u.RequestURI())) |
| + for i := 0; i < 3 && dest != deep && strings.HasPrefix(dest, "/auth/"); i++ { |
| + dest = location(t, c.get(dest)) |
| + } |
| + if dest != deep { |
| + t.Errorf("sign-in landed on %q, want %q", dest, deep) |
| + } |
| + |
| + // Tampered, the cookie is ignored: the landing is the index. |
| + c2 := newClient(t, h) |
| + c2.get("/signin?return_to=%2Fadd") |
| + for name, ck := range c2.cookies { |
| + if strings.Contains(name, "return") { |
| + v := []byte(ck.Value) |
| + v[len(v)/2] ^= 1 |
| + c2.cookies[name] = &http.Cookie{Name: name, Value: string(v)} |
| + } |
| + } |
| + if got := location(t, c2.get("/auth/landed")); got != "/" { |
| + t.Errorf("a tampered return cookie still steered the landing to %q", got) |
| + } |
| + // And an off-site return_to is not remembered at all. |
| + c3 := newClient(t, h) |
| + c3.get("/signin?return_to=%2F%2Fevil.example%2F") |
| + if got := location(t, c3.get("/auth/landed")); got != "/" { |
| + t.Errorf("an off-site return_to steered the landing to %q", got) |
| + } |
| +} |
| + |
| +// §9.2: already signed in, /signin skips the form to a safe return_to. And |
| +// the trap Astra found in Docs, Sheets and Slides: the admission check |
| +// runs FIRST, so a removed owner is signed out and told, once — not |
| +// bounced between /signin and a page that refuses them. |
| +func TestSigninWhenAlreadySignedInRunsTheAdmissionCheckFirst(t *testing.T) { |
| + dbPath := tempDB(t) |
| + c, _ := signedInClient(t, dbPath) |
| + if got := location(t, c.get("/signin?return_to=%2Fadd")); got != "/add" { |
| + t.Errorf("signed in, /signin?return_to=/add went to %q", got) |
| + } |
| + if got := location(t, c.get("/signin?return_to=%2F%2Fevil.example")); got != "/" { |
| + t.Errorf("signed in, an off-site return_to went to %q", got) |
| + } |
| + |
| + // The owner is removed. Same database, same session cookie. |
| + h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"}) |
| + c.h = h2 |
| + if got := location(t, c.get("/signin")); got != "/signin?err=removed" { |
| + t.Fatalf("a removed owner at /signin went to %q, want /signin?err=removed", got) |
| + } |
| + rec := c.get("/signin?err=removed") |
| + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Your access has been removed") { |
| + t.Errorf("the removed page = %d, and does not say so", rec.Code) |
| + } |
| + if !strings.Contains(location(t, c.get("/")), "/signin?return_to=") { |
| + t.Error("the removed owner's session survived") |
| + } |
| +} |
| + |
| +// And the gate itself: a removed owner meets the same sentence on any |
| +// page, instead of a 404 on every page until the session lapses. |
| +func TestARemovedOwnerIsToldOnAnyPage(t *testing.T) { |
| + dbPath := tempDB(t) |
| + c, _ := signedInClient(t, dbPath) |
| + h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"}) |
| + c.h = h2 |
| + if got := location(t, c.get("/add")); got != "/signin?err=removed" { |
| + t.Errorf("a removed owner at /add went to %q", got) |
| + } |
| +} |
| + |
| +// §9.2: Sign out lands on "You're signed out", not a bare sign-in form. |
| +func TestSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| + c, _ := signedInClient(t, tempDB(t)) |
| + dest := location(t, c.do(http.MethodPost, "/signout", url.Values{})) |
| + if dest != "/signed-out" { |
| + t.Fatalf("Sign out went to %q", dest) |
| + } |
| + rec := c.get(dest) |
| + b := html.UnescapeString(rec.Body.String()) |
| + if rec.Code != http.StatusOK || !strings.Contains(b, "You’re signed out") || !strings.Contains(b, "You’ve signed out of Memoria on this device.") { |
| + t.Errorf("the signed-out page = %d:\n%s", rec.Code, b) |
| + } |
| +} |
| + |
| +// Delete asks first. The index's Delete was a one-click POST with no undo. |
| +func TestDeleteAsksFirst(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Keep me", "a document someone might delete by accident") |
| + id := storedID(t, h) |
| + index := get(t, h, "/").Body.String() |
| + if strings.Contains(index, `action="/documents/`+id+`/delete"`) { |
| + t.Error("the index still deletes in one click") |
| + } |
| + if !strings.Contains(index, `href="/documents/`+id+`/delete"`) { |
| + t.Error("the index has no way to ask for a delete") |
| + } |
| + confirm := get(t, h, "/documents/"+id+"/delete") |
| + b := html.UnescapeString(confirm.Body.String()) |
| + if confirm.Code != http.StatusOK || !strings.Contains(b, `method="post" action="/documents/`+id+`/delete"`) || !strings.Contains(b, "Keep me") || !strings.Contains(b, ">Cancel<") { |
| + t.Fatalf("the confirmation page = %d:\n%s", confirm.Code, b) |
| + } |
| + if storedCount(t, h) != 1 { |
| + t.Fatal("asking deleted the document") |
| + } |
| + post(t, h, "/documents/"+id+"/delete", url.Values{}) |
| + if storedCount(t, h) != 0 { |
| + t.Error("the confirmed delete did not delete") |
| + } |
| +} |
| + |
| +// Live search does not say "0 results…" while the person is still typing. |
| +// The zero state is one quiet line, marked for search.js to hold back |
| +// until a pause; a submitted search shows it at once. |
| +func TestNoZeroResultsWhileTyping(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Revenue", "revenue grows next year") |
| + frag := get(t, h, "/search?q=reve").Body.String() |
| + if strings.Contains(frag, "0 results") || !strings.Contains(frag, "rst-search-empty") || strings.Contains(frag, "rst-callout") { |
| + t.Errorf("the live zero state is not one marked quiet line:\n%s", frag) |
| + } |
| + js, err := os.ReadFile(filepath.Join("..", "memoria", "static", "search.js")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(string(js), `setAttribute("rst-search-pending"`) || !strings.Contains(string(js), `removeAttribute("rst-search-pending")`) { |
| + t.Error("search.js never holds a live zero state back") |
| + } |
| + if !regexp.MustCompile(`\[rst-search-pending\] \[rst-search-empty\] \{[^}]*visibility: hidden`).Match(css) { |
| + t.Error("memoria.css does not hide a held zero state") |
| + } |
| +} |
| + |
| +// The layout: charset first, then the suite's stylesheet order (§0.5, |
| +// §12.1 item 2): tokens → theme → brand → base → menu → chrome → app. |
| +func TestTheLayoutLinksTheSharedChromeInOrder(t *testing.T) { |
| + body := get(t, newApp(t), "/").Body.String() |
| + head := body[strings.Index(body, "<head>")+len("<head>"):] |
| + if !strings.HasPrefix(strings.TrimSpace(head), `<meta charset="utf-8">`) { |
| + t.Error("<meta charset> is not the first thing in <head>") |
| + } |
| + last := -1 |
| + for _, name := range []string{"tokens", "theme", "brand/brand", "base", "menu", "chrome", "memoria"} { |
| + m := regexp.MustCompile(`href="/static/` + name + `\.[^"]*css"`).FindStringIndex(body) |
| + if m == nil { |
| + t.Errorf("the layout does not link %s.css", name) |
| + continue |
| + } |
| + if m[0] < last { |
| + t.Errorf("%s.css is linked out of the suite's order", name) |
| + } |
| + last = m[0] |
| + } |
| +} |
| + |
| +// Nineteen template sites wrote rst-cell-mut as an attribute, which styles |
| +// nothing (it is a class-only utility): muted text rendered at body colour. |
| +func TestMutedTextIsStyled(t *testing.T) { |
| + dir := filepath.Join("..", "memoria", "templates") |
| + entries, _ := os.ReadDir(dir) |
| + for _, e := range entries { |
| + raw, _ := os.ReadFile(filepath.Join(dir, e.Name())) |
| + if n := regexp.MustCompile(`<[^>]*\srst-cell-mut[\s>]`).FindAll(raw, -1); len(n) > 0 { |
| + t.Errorf("%s writes rst-cell-mut as an attribute %d times", e.Name(), len(n)) |
| + } |
| + } |
| + css, _ := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| + if !regexp.MustCompile(`\[rst-muted\] \{ color: var\(--rst-text-muted\); \}`).Match(css) { |
| + t.Error("memoria.css does not style [rst-muted]") |
| + } |
| + if !strings.Contains(get(t, newApp(t), "/").Body.String(), "<p rst-muted>") { |
| + t.Error("the footer is not muted") |
| + } |
| +} |
| + |
| +// Fields fit the column: width 100% plus padding overflowed by 22px at |
| +// 1440 and off screen at 390 (content-box); password fields were the |
| +// browser's own; the search input drew a second box inside [rst-search]. |
| +func TestFormFieldsFitTheColumn(t *testing.T) { |
| + css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rule := regexp.MustCompile(`(?s)\[rst-stack\] input\[type="text"\],[^{]*\{[^}]*\}`).Find(css) |
| + if rule == nil { |
| + t.Fatal("no [rst-stack] field rule") |
| + } |
| + for _, want := range []string{`input[type="password"]`, "box-sizing: border-box"} { |
| + if !strings.Contains(string(rule), want) { |
| + t.Errorf("the field rule lacks %q", want) |
| + } |
| + } |
| + if regexp.MustCompile(`\[rst-search\] input\[type="search"\] \{`).Match(css) { |
| + t.Error("memoria.css still draws its own box on the search input, inside rastrillo's") |
| + } |
| + if !strings.Contains(string(css), "::file-selector-button") { |
| + t.Error("the file chooser's button is the browser's own") |
| + } |
| +} |
| + |
| +// The Sources lists are rows, not a card of bare bulleted <li>s. |
| +func TestSourcesAreRows(t *testing.T) { |
| + b := get(t, newApp(t), "/connectors").Body.String() |
| + if strings.Contains(b, "rst-list-item") || strings.Contains(b, "<ul rst-list>") { |
| + t.Error("the Sources page still puts bare list items in a card") |
| + } |
| + if !strings.Contains(b, "<ul rst-sources>") { |
| + t.Error("the Sources page has no rows") |
| + } |
| +} |
| + |
| +var _ = strconv.Itoa |
| + |
| +// Estilo's chrome is tested against rastrillo v0.23's tokens in estilo; |
| +// this is the same check against the tokens.css and theme.css THIS app |
| +// serves (spec §12.1 item 1): a var(--rst-*) nothing defines makes its |
| +// whole declaration invalid, and the rule vanishes with no error. |
| +func TestEstilosChromeUsesOnlyDefinedTokens(t *testing.T) { |
| + static := filepath.Join("..", "memoria", "static") |
| + defined := map[string]bool{} |
| + def := regexp.MustCompile(`(?:^|[;{,\s])(--(?:rst|estilo)-[a-zA-Z0-9_-]+)\s*:`) |
| + for _, name := range []string{"tokens.css", "theme.css", "base.css", "menu.css", "chrome.css"} { |
| + raw, err := os.ReadFile(filepath.Join(static, name)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, m := range def.FindAllStringSubmatch(string(raw), -1) { |
| + defined[m[1]] = true |
| + } |
| + } |
| + if !defined["--rst-accent"] || !defined["--estilo-bar-block-size"] { |
| + t.Fatal("the definition pattern found neither --rst-accent nor --estilo-bar-block-size; this gate is measuring nothing") |
| + } |
| + use := regexp.MustCompile(`var\(\s*(--(?:rst|estilo)-[a-zA-Z0-9_-]+)\s*([,)])`) |
| + seen := 0 |
| + for _, name := range []string{"base.css", "menu.css", "chrome.css"} { |
| + raw, err := os.ReadFile(filepath.Join(static, name)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, m := range use.FindAllStringSubmatch(string(raw), -1) { |
| + seen++ |
| + if m[2] == ")" && !defined[m[1]] { |
| + t.Errorf("%s reads var(%s), which neither this app's tokens nor estilo define", name, m[1]) |
| + } |
| + } |
| + } |
| + if seen < 20 { |
| + t.Fatalf("only %d token references in estilo's chrome; the walk is broken", seen) |
| + } |
| +} |
diff --git a/internal/memoriatest/drop_test.go b/internal/memoriatest/drop_test.go| index 99b5b75..9f306db 100644 |
| --- a/internal/memoriatest/drop_test.go |
| +++ b/internal/memoriatest/drop_test.go |
| @@ -879,10 +879,13 @@ func TestTheRefusalsNameTheCapsTheyEnforce(t *testing.T) { |
| } |
| } |
| -// flashText pulls the rendered flash message out of a page. |
| +// flashText pulls the rendered flash message out of a page. The callout |
| +// body is rastrillo's shape, <div rst-callout-body><p>…</p></div> (shared |
| +// chrome §4); it was a <p rst-callout-body>, whose own margins doubled the |
| +// callout's height. |
| func flashText(t *testing.T, body string) string { |
| t.Helper() |
| - m := regexp.MustCompile(`<p rst-callout-body>([^<]*)</p>`).FindStringSubmatch(body) |
| + m := regexp.MustCompile(`<div rst-callout-body><p>([^<]*)</p></div>`).FindStringSubmatch(body) |
| if m == nil { |
| t.Fatalf("no flash on the page:\n%s", body) |
| } |
diff --git a/internal/memoriatest/errors_gate_test.go b/internal/memoriatest/errors_gate_test.go| new file mode 100644 |
| index 0000000..965014c |
| --- /dev/null |
| +++ b/internal/memoriatest/errors_gate_test.go |
| @@ -0,0 +1,103 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Every plain-text error answer left in the app is here, by file and |
| +// function, with why it is not a page (shared chrome §3.3). A new |
| +// http.Error or http.NotFound anywhere else fails this gate: it is either |
| +// a page (errorPage) or it is classified here with its reason. |
| +// |
| +// Keyed by function, not line, so an edit above a site does not churn the |
| +// list; the count per function catches a second site added beside one. |
| +var plainTextErrors = map[string]struct { |
| + n int |
| + why string |
| +}{ |
| + "app.go:requireSession": {1, "a boot misconfiguration (no sign-in wired): every request is refused, and there is no viewer to draw chrome for"}, |
| + "auth.go:beginGuard": {1, "the same misconfiguration on the sign-in POST (no owners list)"}, |
| + "errors.go:plainError": {1, "the plain answer itself, for a request that did not ask for a page"}, |
| + "errors.go:methodNotAllowed": {1, "405 to a method no route takes, with Allow set: never a navigation (a GET of a POST-only path gets the 404 page)"}, |
| + "handlers.go:writeOutcome": {1, "the upload tray's JSON path: a bare non-200 is what the tray reads as \"no answer\" (spec §3.3 keeps it)"}, |
| + "native.go:nativeSearch": {4, "the native client's JSON API; it shows these strings itself"}, |
| + "native.go:nativeNote": {3, "the native client's JSON API; it shows these strings itself"}, |
| + "render.go:render": {1, "a page template failed to execute: the error page is built from the same layout, so the plainest answer is the one left"}, |
| + "render.go:renderFragment": {1, "search.js's results fragment: a fetch, not a navigation, and search.js reads a non-200 as a search that did not run"}, |
| +} |
| + |
| +func TestEveryPlainTextErrorIsClassified(t *testing.T) { |
| + dir := filepath.Join("..", "memoria") |
| + entries, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := map[string]int{} |
| + seen := 0 |
| + for _, e := range entries { |
| + name := e.Name() |
| + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { |
| + continue |
| + } |
| + fset := token.NewFileSet() |
| + file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.SkipObjectResolution) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, decl := range file.Decls { |
| + fn, ok := decl.(*ast.FuncDecl) |
| + if !ok || fn.Body == nil { |
| + continue |
| + } |
| + ast.Inspect(fn.Body, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + sel, ok := call.Fun.(*ast.SelectorExpr) |
| + if !ok { |
| + return true |
| + } |
| + pkg, ok := sel.X.(*ast.Ident) |
| + if !ok || pkg.Name != "http" || (sel.Sel.Name != "Error" && sel.Sel.Name != "NotFound") { |
| + return true |
| + } |
| + seen++ |
| + found[name+":"+fn.Name.Name]++ |
| + return true |
| + }) |
| + } |
| + } |
| + if seen == 0 { |
| + t.Fatal("found no http.Error at all: the walk is broken, not the app clean") |
| + } |
| + var problems []string |
| + for key, n := range found { |
| + want, ok := plainTextErrors[key] |
| + switch { |
| + case !ok: |
| + problems = append(problems, fmt.Sprintf("%s: %d unclassified plain-text error(s) — render errorPage, or classify it here with its reason", key, n)) |
| + case want.n != n: |
| + problems = append(problems, fmt.Sprintf("%s: %d plain-text error(s), classified %d", key, n, want.n)) |
| + case want.why == "": |
| + problems = append(problems, key+": classified with no reason") |
| + } |
| + } |
| + for key := range plainTextErrors { |
| + if found[key] == 0 { |
| + problems = append(problems, key+": classified, but no longer there — remove it from the list") |
| + } |
| + } |
| + sort.Strings(problems) |
| + for _, p := range problems { |
| + t.Error(p) |
| + } |
| +} |
diff --git a/internal/memoriatest/footer_model_test.go b/internal/memoriatest/footer_model_test.go| index 6d96d41..d2ad6f1 100644 |
| --- a/internal/memoriatest/footer_model_test.go |
| +++ b/internal/memoriatest/footer_model_test.go |
| @@ -29,11 +29,18 @@ var noLayoutHere = map[string]string{ |
| "GET /native/start": "wired by AppForTest, but 404 without a pending native request id; with one it redirects (303)", |
| "GET /native/authorize": "wired by AppForTest, but 404 without a pending id; with one it serves the native library's standalone consent template, which has no layout footer", |
| "GET /auth/local/complete": "a redirect on every GET path — the Home navigation guard either passes and redirects, or redirects; it renders nothing (read in home/connect navigation.go)", |
| + // The sign-in pages carry no footer at all: the count and the egress |
| + // claim are said only to somebody signed in (layout.html; shared chrome |
| + // §9.2 retires the claim there). memoria.TestHomePagesStateTheModelSwitch |
| + // asserts the absence with Home configured. |
| + "GET /signed-out": "a sign-in page: no footer by design (layout.html's .Frame.Signed)", |
| + "GET /signin/local": "Home-only, and a sign-in page: no footer by design — memoria.TestHomePagesStateTheModelSwitch", |
| + // AppForTest is signed in, and a signed-in GET /signin skips the form. |
| + "GET /signin": "signed in under this harness, so it answers 303 to the index (localSignin's shortcut) and renders nothing", |
| + "GET /auth/landed": "a redirect: where a verified link lands, and on to the remembered page (return.go)", |
| // These render the footer when Home is configured, which this harness |
| // does not do. They are checked there, by name, including their |
| // failure pages at 400. |
| - "GET /signed-out": "Home-only — memoria.TestHomePagesStateTheModelSwitch", |
| - "GET /signin/local": "Home-only — memoria.TestHomePagesStateTheModelSwitch", |
| "GET /account/home": "Home-only — memoria.TestHomePagesStateTheModelSwitch", |
| "GET /account/home/confirm": "Home-only — memoria.TestHomePagesStateTheModelSwitch", |
| "GET /auth/start": "Home-only; its failure page renders the footer — memoria.TestHomePagesStateTheModelSwitch", |
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go| index 15d1c80..2925b41 100644 |
| --- a/internal/memoriatest/gates_test.go |
| +++ b/internal/memoriatest/gates_test.go |
| @@ -891,23 +891,16 @@ func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) { |
| // can shrink; a blanket "ignore what is missing" would be the absence |
| // of the gate wearing its name. |
| // |
| - // `rst-cell-mut` is here for a sharper reason than the rest: it is a |
| - // UTILITY, and tokens.css says utilities keep the class spelling and |
| - // have no attribute twin. Written as an attribute it selects nothing. |
| - // Every template in this app writes it that way, which is a change |
| - // too wide to make in the commit that noticed it — recorded here |
| - // rather than quietly tolerated. |
| + // The list shrank with the shared chrome (2026-09-30): rst-cell-mut |
| + // (a class-only utility, written as an attribute nineteen times) is |
| + // now memoria.css's [rst-muted]; the Sources page's rst-list-item and |
| + // rst-list-title are [rst-sources] rows; rst-facts is styled; the |
| + // header's rst-brand and the Delete button's rst-danger are gone. |
| unstyled := map[string]bool{ |
| - "rst-lede": true, |
| - "rst-list-item": true, |
| - "rst-list-title": true, |
| - "rst-steps": true, |
| - "rst-facts": true, |
| - "rst-cell-mut": true, |
| - "rst-brand": true, |
| - "rst-inline": true, |
| - "rst-danger": true, // a utility; class-only, like rst-cell-mut |
| - "rst-bg": true, |
| + "rst-lede": true, |
| + "rst-steps": true, |
| + "rst-inline": true, |
| + "rst-bg": true, |
| } |
| // **Values this app writes that the design system does not define.** |
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go| index 71ac5e2..7da4663 100644 |
| --- a/internal/memoriatest/routes_test.go |
| +++ b/internal/memoriatest/routes_test.go |
| @@ -280,10 +280,23 @@ var declaredChiRoutes = map[string]bool{ |
| "POST /documents": true, |
| "GET /documents/{id}": true, |
| "POST /documents/{id}/delete": true, |
| - "POST /auth/begin": true, |
| - "GET /auth/callback": true, |
| - "GET /auth/verify": true, |
| - "POST /signout": true, |
| + // The shared chrome (estilo spec/shared-chrome, round 2), declared |
| + // 2026-09-30: |
| + // - GET /documents/{id}/delete asks before a delete (it was a |
| + // one-click POST); same session gate as the POST beside it. |
| + // - POST /appearance is estilo's chrome.HandleAppearance: one |
| + // host-only preference cookie and a SafeReturn redirect. Inside |
| + // the app-wide CSRF check, deliberately OUTSIDE the session gate |
| + // (§8.3), and it reads and writes no content. |
| + // - GET /auth/landed is where a verified magic link lands |
| + // (standalone), and on to the page the person was heading for. |
| + "GET /documents/{id}/delete": true, |
| + "POST /appearance": true, |
| + "GET /auth/landed": true, |
| + "POST /auth/begin": true, |
| + "GET /auth/callback": true, |
| + "GET /auth/verify": true, |
| + "POST /signout": true, |
| // The connector surface (§5.1), added by the Notion work and |
| // declared here on 2026-09-06 when this gate caught it — which is |
diff --git a/internal/memoriatest/search_test.go b/internal/memoriatest/search_test.go| index 2c98a01..ba39f11 100644 |
| --- a/internal/memoriatest/search_test.go |
| +++ b/internal/memoriatest/search_test.go |
| @@ -222,11 +222,11 @@ func calloutBody(body string) string { |
| return "" |
| } |
| rest := body[i:] |
| - open := strings.Index(rest, "<p rst-callout-body>") |
| + open := strings.Index(rest, "<div rst-callout-body><p>") |
| if open < 0 { |
| return "" |
| } |
| - rest = rest[open+len("<p rst-callout-body>"):] |
| + rest = rest[open+len("<div rst-callout-body><p>"):] |
| shut := strings.Index(rest, "</p>") |
| if shut < 0 { |
| return "" |
diff --git a/internal/memoriatest/signin_door_test.go b/internal/memoriatest/signin_door_test.go| index 35f865f..d94155c 100644 |
| --- a/internal/memoriatest/signin_door_test.go |
| +++ b/internal/memoriatest/signin_door_test.go |
| @@ -40,17 +40,25 @@ func TestSigninPageIsTheDoor(t *testing.T) { |
| "estilo-door", |
| `<span>Memoria</span>`, |
| `<h1>Sign in</h1>`, |
| - "Memoria holds your team's work in one place.", |
| + // The suite's copy (shared chrome §9.2): the help under the field, |
| + // and Open source reachable while signed out (§8.4). |
| + "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| + `href="/help/open-source"`, |
| `action="/auth/begin"`, |
| `>Email me a link</button>`, |
| - // The layout's footer, and its egress claim, stays under the door. |
| - "<footer", |
| - "leaves this instance", |
| } { |
| - if !strings.Contains(body, want) { |
| + if !strings.Contains(html.UnescapeString(body), html.UnescapeString(want)) { |
| t.Errorf("the sign-in page lacks %q", want) |
| } |
| } |
| + // Retired (§9.2): the lede "Memoria holds your team's work…", and the |
| + // footer's egress claim, which is about searching and is said only to |
| + // somebody signed in. |
| + for _, gone := range []string{"holds your team", "<footer", "leaves this instance"} { |
| + if strings.Contains(body, gone) { |
| + t.Errorf("the sign-in page still carries %q", gone) |
| + } |
| + } |
| // Every sky layer the door links: the two inline placeholders (which |
| // is why CSP must allow img-src data:) and the served ones. |
diff --git a/internal/memoriatest/signin_keymail_test.go b/internal/memoriatest/signin_keymail_test.go| index 658f96a..ebf25ee 100644 |
| --- a/internal/memoriatest/signin_keymail_test.go |
| +++ b/internal/memoriatest/signin_keymail_test.go |
| @@ -180,9 +180,11 @@ func TestAKeymailSigninCanLeaveThePage(t *testing.T) { |
| if !strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), "\n"), "rastrillo_pending") { |
| t.Errorf("the pending cookie did not survive: %q", rr.Header().Values("Set-Cookie")) |
| } |
| - // Rendered as a memoria page, footer and all. |
| - if !strings.Contains(body, "<footer") { |
| - t.Error("the continuation page is not inside memoria's layout") |
| + // Rendered as a Memoria page: the layout, and its bar. (No footer: it |
| + // is a sign-in page, and the footer is said only to somebody signed |
| + // in.) |
| + if !strings.Contains(body, "<header estilo-bar") || strings.Contains(body, "<footer") { |
| + t.Error("the continuation page is not inside Memoria's layout") |
| } |
| } |
diff --git a/internal/memoriatest/signinoutcome_test.go b/internal/memoriatest/signinoutcome_test.go| index 9b32145..d655034 100644 |
| --- a/internal/memoriatest/signinoutcome_test.go |
| +++ b/internal/memoriatest/signinoutcome_test.go |
| @@ -76,7 +76,7 @@ func TestSigninPageSaysWhatHappened(t *testing.T) { |
| h, _ := signinApp(t) |
| rr := beginSignin(t, h, address) |
| body := followToSignin(t, h, rr, "sent=1") |
| - if !strings.Contains(body, `<p estilo-signin-body role="status">We've sent you a link to sign in.</p>`) { |
| + if !strings.Contains(body, `<p estilo-signin-body role="status">If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.</p>`) { |
| t.Errorf("the sent page is not the door's check-your-email notice:\n%s", body) |
| } |
| if !strings.Contains(body, "<h1>Check your email</h1>") { |
| @@ -101,7 +101,10 @@ func TestSigninPageSaysWhatHappened(t *testing.T) { |
| h, _ := signinAppWithOwners(t, []string{owner}) |
| rr := beginSignin(t, h, owner) |
| body := followToSignin(t, h, rr, "err=address") |
| - assertFlash(t, body, "negative", "look like an email address") |
| + // In the field's own error slot, not a banner (spec §9.2). |
| + if !strings.Contains(body, "That doesn’t look like an email address.") || strings.Contains(body, `rst-tone="negative"`) { |
| + t.Errorf("the address error is not in the field's slot:\n%s", body) |
| + } |
| }) |
| t.Run("a rate-limited address says wait", func(t *testing.T) { |
| @@ -116,7 +119,7 @@ func TestSigninPageSaysWhatHappened(t *testing.T) { |
| } |
| } |
| body := followToSignin(t, h, rr, "err=rate") |
| - assertFlash(t, body, "negative", "Too many sign-in attempts") |
| + assertFlash(t, body, "negative", "Too many attempts. Wait a few minutes, then try again.") |
| }) |
| t.Run("a dead magic link says it expired", func(t *testing.T) { |
diff --git a/internal/memoriatest/vendored_test.go b/internal/memoriatest/vendored_test.go| index 7bde1cd..2b13ba1 100644 |
| --- a/internal/memoriatest/vendored_test.go |
| +++ b/internal/memoriatest/vendored_test.go |
| @@ -1,8 +1,10 @@ |
| package memoriatest |
| import ( |
| + "amadan.net/oficina/estilo.git/base" |
| "amadan.net/oficina/estilo.git/busy" |
| "amadan.net/oficina/estilo.git/chrome" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/estilo.git/sky" |
| "bytes" |
| "os" |
| @@ -105,9 +107,15 @@ func TestSkyRouteIsEstilosPrefix(t *testing.T) { |
| // fingerprinted like every other stylesheet. Pinned to the estilo this |
| // app builds against; after an upgrade: go run ./scripts/writeestilo. |
| func TestDoorStylesAreEstilos(t *testing.T) { |
| + // And the rest of the shared chrome (round 2): links (base.css), the |
| + // floating panels (menu.css) and chrome.js's Escape and outside-click |
| + // close. |
| for name, want := range map[string][]byte{ |
| "chrome.css": chrome.CSS(), |
| "sky.css": sky.CSS(), |
| + "base.css": base.CSS(), |
| + "menu.css": estilomenu.CSS(), |
| + "chrome.js": chrome.JS(), |
| } { |
| got, err := os.ReadFile(filepath.Join("..", "memoria", "static", name)) |
| if err != nil { |