Adopt the shared chrome in Memoria
The 52px estilo bar replaces the header that wrapped to 97px (Search, plus Sources for owners). The account menu shows who is signed in, plus Appearance, Help > Open source, one Sign out, and 'All apps' when Home names a directory. Errors render as pages with the viewer's chrome, split by Accept; a panic is a 500 page with a reference. Titles read '<Page> · Memoria'. Callouts use real tones. Search loses its double border and waits for a pause before saying 'No matches'. Inputs fit at 390 and 320. Sources are rows, Delete confirms on its own page, and the TensorX notice shows only to people who are signed in. The sign-in button reads 'Sign in'. Also lands feat/home-consumer (#20): connect 01166fc, HOME_DIRECTORY_ORIGIN, return_to kept in Home mode. Reviewed by GPT-6-Astra via Codex (#21). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from feat/r2-shared-chrome into main.
54 files changed,
+3147
−407
cmd/memoria/main.go+4 −0docs/superpowers/specs/2026-09-03-memoria-design.md+23 −12go.mod+1 −1go.sum+2 −2internal/memoria/app.go+66 −21internal/memoria/auth.go+5 −3internal/memoria/chrome.go+267 −0internal/memoria/chrome_test.go+306 −0internal/memoria/config.go+5 −0internal/memoria/credits.go+15 −2internal/memoria/credits_test.go+24 −9internal/memoria/errors.go+221 −0internal/memoria/handlers.go+206 −95internal/memoria/handlers_connector.go+36 −15internal/memoria/home.go+11 −8internal/memoria/home_consumer_test.go+117 −0internal/memoria/home_render.go+31 −14internal/memoria/home_router_test.go+53 −17internal/memoria/native.go+1 −0internal/memoria/render.go+21 −12internal/memoria/return.go+237 −0internal/memoria/seal/seal.go+7 −0internal/memoria/static/base.css+15 −0internal/memoria/static/chrome.js+82 −0internal/memoria/static/memoria.css+116 −32internal/memoria/static/menu.css+143 −0internal/memoria/static/search.js+35 −2internal/memoria/templates/add.html+9 −11internal/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+598 −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+16 −6internal/memoriatest/signin_keymail_test.go+5 −3internal/memoriatest/signinoutcome_test.go+6 −3internal/memoriatest/vendored_test.go+8 −0scripts/writeestilo/main.go+9 −2
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.796s ok amadan.net/oficina/memoria/internal/memoria/chunk (cached) ok amadan.net/oficina/memoria/internal/memoria/fact (cached) ? amadan.net/oficina/memoria/internal/memoria/icons [no test files] ok amadan.net/oficina/memoria/internal/memoria/index (cached) ok amadan.net/oficina/memoria/internal/memoria/notion (cached) ok amadan.net/oficina/memoria/internal/memoria/provider (cached) ok amadan.net/oficina/memoria/internal/memoria/query (cached) ok amadan.net/oficina/memoria/internal/memoria/seal 0.002s ok amadan.net/oficina/memoria/internal/memoria/sibling (cached) ok amadan.net/oficina/memoria/internal/memoriatest 2.101s ? 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/go.mod b/go.mod| index 3ec360d..0dfacdb 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,7 +6,7 @@ toolchain go1.26.5 |
| require ( |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 |
| - amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| + amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d |
| amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 |
diff --git a/go.sum b/go.sum| index a4586d6..945bb9f 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,7 @@ |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 h1:Qww+xGIYJaj+6TIX4Knr71ghxeYJjtgFm6WukX6NkGg= |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M= |
| -amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d h1:x6Bq9+QzpFYAyV+zRzgyHwD/RmZ4Hy4biwtxK7CdaSs= |
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index b2e3501..c848a57 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,32 @@ 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). |
| + // Both modes: with Home, connect's Signin keeps a safe return_to |
| + // through the exchange (feat/home-consumer added the Home half; |
| + // a.sess reads the same cookie and table as Home's sessions). |
| + 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 +304,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..adfb1cd |
| --- /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.directory == "" { |
| + return nil |
| + } |
| + return &chrome.Switcher{ |
| + Label: "Oficina apps", |
| + CurrentLabel: "(this app)", |
| + AllApps: estilomenu.Item{Href: a.directory + "/", Label: "All apps"}, |
| + } |
| +} |
diff --git a/internal/memoria/chrome_test.go b/internal/memoria/chrome_test.go| new file mode 100644 |
| index 0000000..905a37e |
| --- /dev/null |
| +++ b/internal/memoria/chrome_test.go |
| @@ -0,0 +1,306 @@ |
| +package memoria |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "html" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "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)), |
| + } |
| +} |
| + |
| +// Astra on #21, finding 1: with Home configured, both ways into the local |
| +// form — connect's forced /signin?force=1 and /signin/local — keep an |
| +// explicit return_to through sent → "Use a different address" → a second |
| +// submission → verification. The forced entry used to land on "/". |
| +func TestHomeLocalRetryKeepsTheReturnFromEitherEntry(t *testing.T) { |
| + for _, entry := range []string{"/signin?force=1&return_to=%2Fadd%3Ffrom%3Dmail", "/signin/local?return_to=%2Fadd%3Ffrom%3Dmail"} { |
| + t.Run(entry, func(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + if w := f.do("GET", entry, ""); w.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d", entry, w.Code) |
| + } |
| + sent := homeRedirect(t, f.do("POST", "/auth/begin", "address=alice%40example.test&force=1")) |
| + page := f.do("GET", sent, "").Body.String() |
| + m := regexp.MustCompile(`<a href="([^"]+)">Use a different address</a>`).FindStringSubmatch(page) |
| + if m == nil { |
| + t.Fatalf("no \"Use a different address\" on the sent page:\n%s", page) |
| + } |
| + again := html.UnescapeString(m[1]) |
| + if again != "/signin/local?return_to=%2Fadd%3Ffrom%3Dmail" { |
| + t.Fatalf("\"Use a different address\" goes to %q, want the local form with the return", again) |
| + } |
| + if w := f.do("GET", again, ""); w.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d", again, w.Code) |
| + } |
| + f.mail.body = "" |
| + homeRedirect(t, f.do("POST", "/auth/begin", "address=alice%40example.test&force=1")) |
| + link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body) |
| + u, err := url.Parse(link) |
| + if err != nil || link == "" { |
| + t.Fatal("no second link") |
| + } |
| + dest := homeRedirect(t, f.do("GET", u.RequestURI(), "")) |
| + for i := 0; i < 3 && strings.HasPrefix(dest, "/auth/"); i++ { |
| + dest = homeRedirect(t, f.do("GET", dest, "")) |
| + } |
| + if dest != "/add?from=mail" { |
| + t.Errorf("sign-in landed on %q, want /add?from=mail", dest) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Astra on #21, finding 2: the panic tests above build recoverToErrorPage |
| +// by hand, so removing it from the router left them green. This one panics |
| +// inside a REAL route (the return cookie's clock, read by /signin/local) |
| +// through the production router, signed in and out, and checks the generic |
| +// 500 page with a reference, the viewer's chrome, and that the panic's |
| +// value reaches neither the page nor the log. |
| +func TestARoutePanicIsTheErrorPageThroughTheRouter(t *testing.T) { |
| + const sentinel = "secret-sentinel-passage" |
| + for _, signed := range []bool{true, false} { |
| + f := newHomeRouterFixture(t) |
| + if signed { |
| + f.local(t) |
| + } |
| + var logs bytes.Buffer |
| + f.app.logger = slog.New(slog.NewTextHandler(&logs, nil)) |
| + f.app.clock = func() time.Time { panic(sentinel) } |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/signin/local?return_to=%2Fadd", nil) |
| + r.Header.Set("Accept", "text/html") |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + func() { |
| + defer func() { |
| + if v := recover(); v != nil { |
| + t.Fatalf("signed in=%v: the panic escaped the router: %v", signed, v) |
| + } |
| + }() |
| + f.handler.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: = %d, want the 500 page with a reference:\n%s", signed, w.Code, b) |
| + } |
| + if strings.Contains(b, "estilo-account") != signed { |
| + t.Errorf("signed in=%v: the 500 page's chrome is not the viewer's", signed) |
| + } |
| + if strings.Contains(b, sentinel) || strings.Contains(logs.String(), sentinel) { |
| + t.Errorf("signed in=%v: the panic's value reached the page or the log", signed) |
| + } |
| + if !strings.Contains(logs.String(), "ref=") { |
| + t.Errorf("signed in=%v: no reference in the log: %s", signed, logs.String()) |
| + } |
| + } |
| +} |
diff --git a/internal/memoria/config.go b/internal/memoria/config.go| index 7950722..b027eca 100644 |
| --- a/internal/memoria/config.go |
| +++ b/internal/memoria/config.go |
| @@ -133,6 +133,11 @@ func LoadConfig(getenv func(string) string) (Config, error) { |
| } |
| 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")} |
| if home.Issuer != "" || home.ConsumerID != "" || home.KeysBase64 != "" || home.DEKBase64 != "" { |
| + // The hub directory "All apps" links to: unprefixed, the name and |
| + // value Home itself reads, as connect.CredentialsFromEnv does. It |
| + // is optional and enables nothing, so it does not count toward |
| + // "Home is configured"; DecodeCredentials validates it. |
| + home.Directory = getenv("HOME_DIRECTORY_ORIGIN") |
| c.Home, err = connect.DecodeCredentials(&home, c.Origin) |
| if err != nil { |
| return c, err |
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..d8a7d10 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,20 @@ 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 |
| + // directory is the hub directory origin (HOME_DIRECTORY_ORIGIN) the |
| + // switcher's "All apps" goes to; set only in Home mode, and empty means |
| + // no switcher. |
| + directory string |
| syncer *Syncer |
| // owners is the admission list, and there is exactly one of it. |
| @@ -132,26 +149,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 |
| @@ -383,7 +399,7 @@ func newDropZone() dropZone { |
| MaxFiles: maxDropFiles, |
| SniffBytes: sniffBytes, |
| - TitleHint: "(Optional. Gives a title to pasted text)", |
| + TitleHint: "(optional)", |
| FileLabel: "…or upload text files", |
| // Derived, so the hint cannot come to disagree with the caps it |
| // describes — nor with the refusals, which say them in the same |
| @@ -391,9 +407,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 +574,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 +601,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 +627,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 +1051,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 +1078,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 +1086,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 +1097,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 +1112,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 +1160,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 +1170,161 @@ 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 |
| + } |
| + } |
| + // Remember an explicit, validated return whenever this form renders — |
| + // including Home's forced /signin?force=1 entry, which connect hands |
| + // here at /signin (Astra on #21, finding 1). The fresh-start reset |
| + // stays where it was: a bare standalone /signin or /signin/local. |
| + if a.home == nil || r.URL.Path == "/signin/local" || hasReturn { |
| + 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", |
| - Submit: "Email me a link", |
| + Help: signinHelp, |
| + Submit: "Sign in", // Paul, 2026-10-01: the door says "Sign in" |
| + 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..78e76fd 100644 |
| --- a/internal/memoria/home.go |
| +++ b/internal/memoria/home.go |
| @@ -64,6 +64,7 @@ func (a *app) WireHome(mux *http.ServeMux, cfg Config) error { |
| return errHomeIdentity |
| } |
| a.home = &homeIntegration{bridge: b, routes: routes, sessions: sess} |
| + a.directory = h.Directory |
| return nil |
| } |
| @@ -80,8 +81,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 +111,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_consumer_test.go b/internal/memoria/home_consumer_test.go| new file mode 100644 |
| index 0000000..5c41319 |
| --- /dev/null |
| +++ b/internal/memoria/home_consumer_test.go |
| @@ -0,0 +1,117 @@ |
| +package memoria |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +const homeTestDirectory = "https://directory.example.test" |
| + |
| +// newHomeConsumerFixture loads Home the way production does, through |
| +// LoadConfig from the environment, so HOME_DIRECTORY_ORIGIN (unprefixed, |
| +// the name Home itself reads) is read exactly as it is live. |
| +func newHomeConsumerFixture(t *testing.T, directory string) *homeRouterFixture { |
| + t.Helper() |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + manifest, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + env := 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(manifest), |
| + "MEMORIA_HOME_DEK_BASE64": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)), |
| + "HOME_DIRECTORY_ORIGIN": directory, |
| + } |
| + cfg, err := LoadConfig(func(k string) string { return env[k] }) |
| + if err != nil || cfg.Home == nil { |
| + t.Fatalf("LoadConfig: %v", err) |
| + } |
| + return newHomeRouterFixtureWith(t, key, cfg) |
| +} |
| + |
| +// Sign out in Memoria ends the id.* session too: the app session ends and |
| +// the same POST is handed on (307) to {issuer}/signout, which returns the |
| +// browser to /signed-out. Without it the next "Sign in" passed silently |
| +// through the live id.* session (spec shared-chrome §9.5). |
| +func TestHomeSignoutChainsToIssuer(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + w := f.do("POST", "/signout", "") |
| + if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" { |
| + t.Fatalf("POST /signout: status %d Location %q, want 307 to %s/signout", w.Code, w.Header().Get("Location"), homeTestIssuer) |
| + } |
| + if got := w.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { |
| + t.Fatalf("Referrer-Policy %q: id.* admits the null-Origin hop by Referer", got) |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("sign-out kept the Memoria session cookie") |
| + } |
| +} |
| + |
| +// A signed-in person who follows a "Sign in" link goes straight to where |
| +// they were headed, with no second exchange. |
| +func TestHomeSigninWhenSignedInReturnsToPage(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + if got := homeRedirect(t, f.do("GET", "/signin?return_to=%2Fconnectors", "")); got != "/connectors" { |
| + t.Fatalf("signed-in /signin went to %q, want /connectors", got) |
| + } |
| +} |
| + |
| +// "All apps" goes to the hub directory, from HOME_DIRECTORY_ORIGIN, on |
| +// every signed-in page, and is absent when no directory is configured. |
| +func TestAllAppsLinksToDirectory(t *testing.T) { |
| + link := `href="` + homeTestDirectory + `/"` |
| + f := newHomeConsumerFixture(t, homeTestDirectory) |
| + f.local(t) |
| + for _, path := range []string{"/", "/connectors", "/help/open-source"} { |
| + body := f.do("GET", path, "").Body.String() |
| + if !strings.Contains(body, link) || !strings.Contains(body, ">All apps<") { |
| + t.Fatalf("GET %s: no All apps link to %s/", path, homeTestDirectory) |
| + } |
| + } |
| + |
| + bare := newHomeConsumerFixture(t, "") |
| + bare.local(t) |
| + if body := bare.do("GET", "/", "").Body.String(); strings.Contains(body, "All apps") { |
| + t.Fatal("All apps shown with no directory configured") |
| + } |
| +} |
| + |
| +// A signed-out visit to a page in Home mode goes to sign-in carrying the |
| +// page, and Home sign-in comes back to that exact page, not the root. |
| +func TestHomeSigninReturnsToTheExactPage(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + if w := f.do("GET", "/account/home", ""); w.Code != http.StatusOK { |
| + t.Fatal("link page", w.Code) |
| + } |
| + start := homeRedirect(t, f.do("POST", "/account/home", "")) |
| + if homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) != "/account/home/confirm" { |
| + t.Fatal("wrong proof landing") |
| + } |
| + homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| + f.do("POST", "/signout", "") |
| + |
| + signin := homeRedirect(t, f.do("GET", "/connectors", "")) |
| + if signin != "/signin?return_to=%2Fconnectors" { |
| + t.Fatalf("signed-out /connectors went to %q, want /signin?return_to=%%2Fconnectors", signin) |
| + } |
| + start = homeRedirect(t, f.do("GET", signin, "")) |
| + if got := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); got != "/connectors" { |
| + t.Fatalf("Home sign-in landed on %q, want /connectors", got) |
| + } |
| +} |
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..8c9e16b 100644 |
| --- a/internal/memoria/home_router_test.go |
| +++ b/internal/memoria/home_router_test.go |
| @@ -39,6 +39,24 @@ 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() |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return newHomeRouterFixtureWith(t, key, 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}}) |
| +} |
| + |
| +// newHomeRouterFixtureWith wires Home from cfg, signed by key under the |
| +// key ID "key". |
| +func newHomeRouterFixtureWith(t *testing.T, key *assertioncrypto.Keypair, cfg Config) *homeRouterFixture { |
| t.Helper() |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "memoria.db"), logger) |
| @@ -62,11 +80,6 @@ func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| t.Fatal(err) |
| } |
| a.UseAuth(auth) |
| - key, err := assertioncrypto.Generate() |
| - 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)}} |
| if err = a.WireHome(mux, cfg); err != nil { |
| t.Fatal(err) |
| } |
| @@ -169,7 +182,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 +217,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 +354,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 +410,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/base.css b/internal/memoria/static/base.css| new file mode 100644 |
| index 0000000..9873e71 |
| --- /dev/null |
| +++ b/internal/memoria/static/base.css |
| @@ -0,0 +1,15 @@ |
| +/* base/base.css: links. Zero specificity on purpose: any component rule wins. |
| + Prose links keep their underline, because colour must never carry meaning |
| + alone. Chrome links (bar, nav, menus, rows, buttons) set their own colour |
| + and text-decoration, and every one of them outranks :where(). */ |
| +:where(a:any-link) { |
| + color: var(--rst-accent); |
| + text-decoration-line: underline; |
| + text-decoration-thickness: 1px; |
| + text-underline-offset: 0.15em; |
| +} |
| +:where(a:any-link:hover) { text-decoration-thickness: 2px; } |
| +:where(a:any-link:focus-visible) { |
| + outline: 2px solid var(--rst-accent); |
| + outline-offset: 2px; |
| +} |
diff --git a/internal/memoria/static/chrome.js b/internal/memoria/static/chrome.js| new file mode 100644 |
| index 0000000..1f583c6 |
| --- /dev/null |
| +++ b/internal/memoria/static/chrome.js |
| @@ -0,0 +1,82 @@ |
| +// chrome/chrome.js: optional behaviour for estilo-bar and the one top-level |
| +// menu group. Load it with <script defer>. Everything already works |
| +// without it; this only adds what <details> and the platform cannot do for |
| +// themselves: |
| +// |
| +// - Escape closes the open member of the group (name="estilo-menus": the |
| +// bar's Menu, switcher and account, and every menubar menu) and returns |
| +// focus to its title; |
| +// - a click outside the open member closes it; |
| +// - Escape in the editable title restores the saved value and leaves |
| +// the field; |
| +// - a row or button that opens a dialog (command="show-modal") closes |
| +// the menu it sat in, returns focus to that menu's title when the |
| +// dialog closes, and opens the dialog itself on an engine without |
| +// invoker commands. |
| +// |
| +// The group is one group in every app, so closing it is estilo's job, not |
| +// each app's: Sheets' menubar lost Escape and outside-click entirely when |
| +// it left rastrillo's dropdown. |
| +// |
| +// Both listen in the capture phase. A document app has to stop key |
| +// events from propagating out of its menus — Sheets' grid library acts on |
| +// every key it hears on document, so an arrow in an open menu would walk |
| +// the cell cursor too — and a bubble-phase listener on document never |
| +// hears the Escape such an app stops. Capture hears it first. An app that |
| +// closes its own menu on Escape (Docs' menubar) finds nothing open and |
| +// does nothing, so nothing is handled twice. |
| +;(() => { |
| + const group = 'details[name="estilo-menus"][open]' |
| + const openPanel = () => document.querySelector(group) |
| + |
| + // Focus returns to a menu's title; in a menubar with a roving tab stop |
| + // (one title tabbable, the rest tabindex=-1) the stop moves with it, so |
| + // Tab then Shift+Tab comes back to the menu you were in, not to File |
| + // (Astra, estilo #11). A menubar without one is left without one. |
| + const returnTo = (title) => { |
| + title.focus() |
| + const bar = title.closest('[estilo-menubar]') |
| + if (!bar) return |
| + const titles = bar.querySelectorAll('[estilo-menubar-title]') |
| + if (!Array.prototype.some.call(titles, (t) => t.hasAttribute('tabindex'))) return |
| + Array.prototype.forEach.call(titles, (t) => { t.tabIndex = t === title ? 0 : -1 }) |
| + } |
| + |
| + document.addEventListener('keydown', (e) => { |
| + if (e.key !== 'Escape' || e.defaultPrevented) return |
| + const input = e.target.closest && e.target.closest('[estilo-bar-title-input]') |
| + if (input) { |
| + input.value = input.defaultValue |
| + input.blur() |
| + e.preventDefault() |
| + return |
| + } |
| + const open = openPanel() |
| + if (!open) return |
| + open.open = false |
| + const summary = open.querySelector('summary') |
| + if (summary) returnTo(summary) |
| + e.preventDefault() |
| + }, true) |
| + |
| + document.addEventListener('click', (e) => { |
| + const open = openPanel() |
| + if (open && !open.contains(e.target)) open.open = false |
| + }, true) |
| + |
| + const invokers = typeof HTMLButtonElement !== 'undefined' && 'command' in HTMLButtonElement.prototype |
| + document.addEventListener('click', (e) => { |
| + const button = e.target.closest && e.target.closest('button[commandfor][command="show-modal"]') |
| + if (!button || button.disabled) return |
| + const dialog = document.getElementById(button.getAttribute('commandfor')) |
| + const holder = button.closest('details[name="estilo-menus"]') |
| + if (holder) { |
| + holder.open = false |
| + const summary = holder.querySelector('summary') |
| + if (dialog && summary) dialog.addEventListener('close', () => returnTo(summary), { once: true }) |
| + } |
| + if (invokers || !dialog || typeof dialog.showModal !== 'function' || dialog.open) return |
| + dialog.showModal() |
| + e.preventDefault() |
| + }) |
| +})() |
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css| index e4f2d4c..13c4bdc 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,55 @@ |
| [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; } |
| +/* A hint inside a label is quiet: muted and regular weight, not the |
| + * label's 560 (polish audit r3 B7). */ |
| +[rst-hint] { font-weight: 400; } |
| +/* 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 +247,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 +336,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/menu.css b/internal/memoria/static/menu.css| new file mode 100644 |
| index 0000000..2ddf029 |
| --- /dev/null |
| +++ b/internal/memoria/static/menu.css |
| @@ -0,0 +1,143 @@ |
| +/* menu/menu.css: the one floating panel, and the menu bar it hangs from. |
| + Every floating surface in the suite uses [estilo-menu]: the account |
| + menu, the switcher, the narrow Menu sheet and every menubar menu. |
| + A float is rounded and so is a highlight inside it; a menubar title is |
| + in the page flow, so it is square (the suite's square rule). */ |
| +:root { |
| + --estilo-float-radius: 9px; /* rastrillo's hard-coded dropdown value, named. PUBLIC */ |
| + --estilo-float-padding: 0.25rem; |
| + --estilo-item-padding-block: 0.4rem; |
| + --estilo-item-padding-inline: 0.65rem; |
| +} |
| +[estilo-menu] { |
| + box-sizing: border-box; |
| + /* Sized to its rows, not to what it hangs from. Every panel is absolutely |
| + positioned inside a <details> no wider than its title, and an absolute |
| + box's shrink-to-fit width is capped by that, so without this a row with |
| + a shortcut wrapped ("Decrease / indent") inside a menu with room to |
| + spare. Docs found it on first adoption. */ |
| + inline-size: max-content; |
| + min-inline-size: 12rem; |
| + max-inline-size: min(22rem, calc(100vw - 1rem)); |
| + /* The 8rem floor is for short frames, where the viewport maths alone can |
| + leave a panel a few pixels tall: an overflowing menu can be scrolled, |
| + an invisible one cannot. */ |
| + max-block-size: max(calc(100dvh - var(--estilo-bar-block-size, 3.25rem) - 1rem), 8rem); |
| + overflow-y: auto; |
| + overscroll-behavior: contain; |
| + padding: var(--estilo-float-padding); |
| + background: var(--rst-surface); |
| + color: var(--rst-text); |
| + border: 1px solid var(--rst-line); |
| + border-radius: var(--estilo-float-radius); |
| + box-shadow: var(--rst-shadow-pop); |
| + font-size: var(--rst-fs-sm); |
| + text-align: start; |
| + z-index: 30; |
| +} |
| +[estilo-menu~="sheet"] { |
| + position: absolute; |
| + inset-inline: 0.5rem; |
| + inset-block-start: calc(100% + 4px); |
| + inline-size: auto; |
| + max-inline-size: none; |
| +} |
| +[estilo-menu] form { margin: 0; } |
| +[estilo-menu-item] { |
| + display: flex; |
| + align-items: center; |
| + gap: 0.5rem; |
| + box-sizing: border-box; |
| + inline-size: 100%; |
| + min-block-size: 2rem; |
| + padding: var(--estilo-item-padding-block) var(--estilo-item-padding-inline); |
| + border: 0; |
| + border-radius: var(--rst-radius-sm); /* inset and rounded: inside a float */ |
| + background: none; |
| + color: var(--rst-text); |
| + font: inherit; |
| + text-align: start; |
| + text-decoration: none; |
| + cursor: pointer; |
| +} |
| +[estilo-menu-item]:hover, |
| +[estilo-menu-item]:focus-visible { background: var(--rst-accent-soft); } |
| +[estilo-menu-item]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; } |
| +[estilo-menu-item][aria-disabled="true"], |
| +[estilo-menu-item]:disabled { |
| + color: var(--rst-text-faint); |
| + background: none; |
| + cursor: default; |
| +} |
| +[estilo-menu-check] { |
| + display: inline-flex; |
| + flex: none; |
| + inline-size: 1rem; |
| + block-size: 1rem; |
| + visibility: hidden; |
| + color: var(--rst-accent); |
| +} |
| +[estilo-menu-check] svg { inline-size: 100%; block-size: 100%; } |
| +[estilo-menu-item][aria-pressed="true"] [estilo-menu-check], |
| +[estilo-menu-item][aria-current] [estilo-menu-check] { visibility: visible; } |
| +[estilo-menu-label] { flex: 1 1 auto; min-inline-size: 0; } |
| +[estilo-menu-key] { |
| + flex: none; |
| + margin-inline-start: auto; |
| + padding-inline-start: 1.5rem; |
| + color: var(--rst-text-muted); |
| + font: inherit; |
| + font-size: var(--rst-fs-xs); |
| + font-variant-numeric: tabular-nums; |
| + white-space: nowrap; |
| +} |
| +[estilo-menu] hr { |
| + border: 0; |
| + border-block-start: 1px solid var(--rst-line); |
| + margin: 0.25rem 0; |
| +} |
| +[estilo-menu-heading] { |
| + margin: 0; |
| + padding: 0.5rem var(--estilo-item-padding-inline) 0.25rem; |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-xs); |
| + font-weight: 600; /* no text-transform */ |
| +} |
| + |
| +/* The document menu bar: File, Edit, View... Titles are in the page flow, |
| + so their highlight is square. Every title joins the one top-level |
| + exclusivity group, name="estilo-menus", with the bar's own triggers. */ |
| +[estilo-menubar] { display: flex; flex-wrap: wrap; align-items: stretch; min-block-size: 2rem; } |
| +[estilo-menubar-menu] { position: relative; } |
| +[estilo-menubar-menu] > [estilo-menu] { |
| + position: absolute; |
| + inset-inline-start: 0; |
| + inset-block-start: calc(100% + 2px); |
| +} |
| +[estilo-menubar-title] { |
| + display: flex; |
| + align-items: center; |
| + box-sizing: border-box; |
| + block-size: 100%; |
| + min-block-size: 2rem; |
| + padding-inline: 0.6rem; |
| + border-radius: 0; /* square: in-flow */ |
| + color: var(--rst-text); |
| + font-size: var(--rst-fs-sm); |
| + white-space: nowrap; |
| + list-style: none; |
| + cursor: default; |
| + user-select: none; |
| +} |
| +[estilo-menubar-title]::-webkit-details-marker { display: none; } |
| +[estilo-menubar-title]::marker { content: ""; } |
| +[estilo-menubar-title]:hover { background: var(--rst-surface-2); } |
| +[open] > [estilo-menubar-title] { background: var(--rst-accent-soft); } |
| +[estilo-menubar-title]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; } |
| +/* A panel hung from a title near the inline end overflows a phone, so |
| + below 30rem every menubar panel spans the viewport instead. */ |
| +@media (max-width: 30rem) { |
| + [estilo-menubar-menu] { position: static; } |
| + [estilo-menubar] { position: relative; } |
| + [estilo-menubar-menu] > [estilo-menu] { inset-inline: 0.5rem; inline-size: auto; min-inline-size: 0; max-inline-size: none; } |
| +} |
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..2687f85 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"). |
| @@ -6,11 +5,11 @@ |
| here unchanged; POST /documents still receives them, and now redirects |
| back HERE with its outcome rather than to the index. The index keeps |
| search and the document list, and links here. */}} |
| -<p><a href="/">← Back to search</a></p> |
| -<section rst-section> |
| +<nav rst-breadcrumb><a href="/">← Back to search</a></nav> |
| +<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 rst-hint>{{.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..933856a |
| --- /dev/null |
| +++ b/internal/memoriatest/chrome_test.go |
| @@ -0,0 +1,598 @@ |
| +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) |
| + } |
| +} |
| + |
| +// polish audit r3 B7 and C5: the Title hint read as bold label text, and |
| +// /add put a section rule between "← Back to search" and its h1, where the |
| +// document page uses the breadcrumb nav. |
| +func TestTheAddPageReadsLikeTheDocumentPage(t *testing.T) { |
| + b := get(t, newApp(t), "/add").Body.String() |
| + if !strings.Contains(b, `<label for="title">Title <span rst-muted rst-hint>(optional)</span></label>`) { |
| + t.Error("the Title hint is not a quiet \"(optional)\"") |
| + } |
| + if !strings.Contains(b, `<nav rst-breadcrumb><a href="/">← Back to search</a></nav>`) { |
| + t.Error("/add's back link is not the breadcrumb the document page uses") |
| + } |
| + if regexp.MustCompile(`(?s)<section rst-section>\s*<h1>Add content</h1>`).MatchString(b) { |
| + t.Error("a section rule still sits between the back link and the h1") |
| + } |
| + css, _ := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| + if !regexp.MustCompile(`\[rst-hint\] \{[^}]*font-weight: 400`).Match(css) { |
| + t.Error("the hint inherits the label's weight") |
| + } |
| +} |
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..1aceb81 100644 |
| --- a/internal/memoriatest/signin_door_test.go |
| +++ b/internal/memoriatest/signin_door_test.go |
| @@ -40,17 +40,27 @@ 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", |
| + // Paul, 2026-10-01: the door's button says "Sign in". |
| + `>Sign in</button>`, |
| } { |
| - 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): "Email me a link" (Paul, 2026-10-01), 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{"Email me a link", "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 { |
diff --git a/scripts/writeestilo/main.go b/scripts/writeestilo/main.go| index a0a37b8..7ecdfca 100644 |
| --- a/scripts/writeestilo/main.go |
| +++ b/scripts/writeestilo/main.go |
| @@ -1,16 +1,20 @@ |
| // Command writeestilo re-writes the files internal/memoria/static takes from |
| // the estilo this module pins: theme.css (Calma, with Memoria's accent), |
| // and rastrillo's busy rule as estilo carries it — rastrillo.js without |
| -// the old rule, busy.js and busy.css — and the sign-in door's two |
| -// stylesheets, chrome.css and sky.css. Run it from the repository root |
| +// the old rule, busy.js and busy.css — and the shared chrome: base.css |
| +// (links), menu.css (floating panels), chrome.css (the bar, the account |
| +// menu and the sign-in door), chrome.js (Escape and outside-click close) |
| +// and sky.css (the door's sky). Run it from the repository root |
| // after upgrading estilo; the pin tests in internal/memoriatest fail until you do. |
| package main |
| import ( |
| "os" |
| + "amadan.net/oficina/estilo.git/base" |
| "amadan.net/oficina/estilo.git/busy" |
| "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/estilo.git/theme" |
| ) |
| @@ -25,7 +29,10 @@ func main() { |
| "rastrillo.js": busy.ShimJS(), |
| "busy.js": busy.JS(), |
| "busy.css": busy.CSS(), |
| + "base.css": base.CSS(), |
| + "menu.css": menu.CSS(), |
| "chrome.css": chrome.CSS(), |
| + "chrome.js": chrome.JS(), |
| "sky.css": sky.CSS(), |
| } { |
| if err := os.WriteFile("internal/memoria/static/"+name, body, 0o644); err != nil { |