Keep credits within Memoria admission and address review findings
10 files changed,
+80
−24
Makefile+1 −0docs/superpowers/specs/2026-09-03-memoria-design.md+11 −8go.mod+3 −1go.sum+2 −2implementation/open-source-credits/proposal.json+5 −1internal/memoria/app.go+1 −1internal/memoria/credits.go+8 −4internal/memoria/credits_test.go+44 −4internal/memoria/templates/credits.html+3 −1internal/memoria/templates/layout.html+2 −2
CI passed — run details
diff --git a/Makefile b/Makefile| index 12d8a51..f77e342 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -1,3 +1,4 @@ |
| +export GOTOOLCHAIN := go1.26.5 |
| export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git |
| APP := memoria |
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md| index 2b876a8..edc74a6 100644 |
| --- a/docs/superpowers/specs/2026-09-03-memoria-design.md |
| +++ b/docs/superpowers/specs/2026-09-03-memoria-design.md |
| @@ -1494,6 +1494,12 @@ 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. |
| + |
| --- |
| ## 5. Ingestion |
| @@ -2914,7 +2920,8 @@ more than one read from memory: |
| `GET /`, `GET /search`, `GET /documents/{id}`, `POST /documents`, |
| `POST /documents/{id}/delete`, the connector routes under |
| -`/connectors/…`, and the auth routes. |
| +`/connectors/…`, authenticated `GET /help/open-source`, public static |
| +`GET /static/credits.css`, and the auth routes. |
| 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 |
| @@ -2969,12 +2976,6 @@ renders on its own origin. The home never sees `q`. |
| ## 10. Testing gates |
| -The suite attribution request adds public `GET /help/open-source` and |
| -`GET /static/credits.css` on 2026-09-11. They serve embedded software |
| -notices and shared Estilo styles. Neither route reads team content, |
| -checks passage counts, nor grants a session. Help links from the signed-in |
| -header to the notices page. The route inventory declares both explicitly. |
| - |
| **Two rules govern this list, and §1.4 is why.** Three of the first five |
| gates built could not fail for the thing they named. |
| @@ -3171,7 +3172,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`, plus the auth routes. |
| + `POST /documents`, `POST /documents/{id}/delete`, authenticated |
| + `GET /help/open-source`, 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 4f00b50..5c4d3d0 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -2,8 +2,10 @@ module amadan.net/oficina/memoria |
| go 1.26 |
| +toolchain go1.26.5 |
| + |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260911162556-f5fa1128b794 |
| + amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d |
| amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d |
diff --git a/go.sum b/go.sum| index 5ddca98..70db724 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,5 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260911162556-f5fa1128b794 h1:2oBeIneg/f+m3/n8qLsQGlpEhdnAfW3kOI80yRv60pw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260911162556-f5fa1128b794/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| +amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d h1:3HsB96tGs+P0tGf2VzqpqA8yqYBhJ+apBh2txAoz9qI= |
| +amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| 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/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
diff --git a/implementation/open-source-credits/proposal.json b/implementation/open-source-credits/proposal.json| index 89e6bb5..7234ae5 100644 |
| --- a/implementation/open-source-credits/proposal.json |
| +++ b/implementation/open-source-credits/proposal.json |
| @@ -17,5 +17,9 @@ |
| "320/1200px Chromium reflow and keyboard with scripts disabled", |
| "Five mutation checks caught removed routes, notice drift, invalid attribute, undeclared route; reverted" |
| ], |
| - "review": "Pending Claude Fable; no deploy" |
| + "review": "Fable5.1 requested public-readiness changes at2ceb79c; this revision addresses them and requires a new review.", |
| + "access": "Credits page now uses existing requireSession; CSS remains a public static asset. Both literal spec route lists and admission section updated.", |
| + "behavior": "Signed-in header retained; passage-count footer omitted, with no count query. Embedded inventory parsed at boot; missing notice explicitly labelled. CSS revalidates; release/generator/test GOTOOLCHAIN fixed togo1.26.5.", |
| + "shared": "Estilo0bcb928: corrected divider token, original provenance gate, heading/newline fix and missing-notice label.", |
| + "verification": "Full make ci passed; session-boundary and served-CSS-token mutations failed correctly; native320/1200 keyboard/reflow/separator checks passed. Final brand-size adjustment rechecked." |
| } |
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index 4b1d685..3f9c121 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -75,7 +75,6 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| r.Use(a.homeMiddleware(origin)) |
| // Public: the sign-in page and auth's own callbacks. |
| - r.Get("/help/open-source", a.credits) |
| r.Get("/signin", a.signin) |
| r.Get("/signin/local", func(w http.ResponseWriter, r *http.Request) { |
| if a.home == nil { |
| @@ -126,6 +125,7 @@ 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("/", a.index) |
| r.Get("/search", a.searchResults) |
| r.Post("/documents", a.createDocument) |
diff --git a/internal/memoria/credits.go b/internal/memoria/credits.go| index ca6c6b7..9a7ba3e 100644 |
| --- a/internal/memoria/credits.go |
| +++ b/internal/memoria/credits.go |
| @@ -16,19 +16,23 @@ type creditInventory struct { |
| MissingNotices []string `json:"missing_notices"` |
| } |
| -func (a *app) credits(w http.ResponseWriter, r *http.Request) { |
| +var embeddedCredits = func() creditInventory { |
| var inventory creditInventory |
| if err := json.Unmarshal(creditNotices, &inventory); err != nil { |
| - http.Error(w, "something went wrong", http.StatusInternalServerError) |
| - return |
| + panic(err) |
| } |
| + return inventory |
| +}() |
| + |
| +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{"Data": credits.PageData{Title: "Open source", Components: inventory.Components}}) |
| + render(w, "credits", map[string]any{"Signed": true, "HomeEnabled": a.showHomeConnection(r), "Data": credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}}) |
| } |
| func serveCreditsCSS(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-cache") |
| w.Write(credits.CSS()) |
| } |
diff --git a/internal/memoria/credits_test.go b/internal/memoria/credits_test.go| index f9d9aa9..825d1f6 100644 |
| --- a/internal/memoria/credits_test.go |
| +++ b/internal/memoria/credits_test.go |
| @@ -12,13 +12,25 @@ import ( |
| ) |
| // Mutation-checked 2026-09-11: removing either route fails this test. |
| -func TestCreditsArePublicAndContainDependencyNotices(t *testing.T) { |
| +func TestCreditsRequireSessionAndContainDependencyNotices(t *testing.T) { |
| fixture := newHomeRouterFixture(t) |
| app := fixture.handler |
| - response := httptest.NewRecorder() |
| - app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, homeTestOrigin+"/help/open-source", nil)) |
| + 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) |
| + } |
| + 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("credits required authentication", response.Code) |
| + 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."} { |
| + 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") |
| @@ -59,3 +71,31 @@ func TestCreditsArePublicAndContainDependencyNotices(t *testing.T) { |
| t.Fatal("new missing notice requires review") |
| } |
| } |
| + |
| +// Mutation-checked 2026-09-11: a served undefined token fails this gate. |
| +func TestCreditStylesUseServedTokens(t *testing.T) { |
| + definitions := map[string]bool{} |
| + for _, path := range []string{"static/tokens.css", "static/theme.css"} { |
| + raw, err := appFS.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, m := range regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`).FindAllSubmatch(raw, -1) { |
| + definitions[string(m[1])] = true |
| + } |
| + } |
| + response := httptest.NewRecorder() |
| + serveCreditsCSS(response, httptest.NewRequest(http.MethodGet, "/static/credits.css", nil)) |
| + if response.Header().Get("Cache-Control") != "no-cache" { |
| + t.Fatal("shared CSS can be stale") |
| + } |
| + references := regexp.MustCompile(`var\(\s*(--rst-[a-z0-9-]+)`).FindAllSubmatch(response.Body.Bytes(), -1) |
| + if len(references) == 0 { |
| + t.Fatal("credit token references disappeared") |
| + } |
| + for _, m := range references { |
| + if !definitions[string(m[1])] { |
| + t.Fatal("undefined served credit token", string(m[1])) |
| + } |
| + } |
| +} |
diff --git a/internal/memoria/templates/credits.html b/internal/memoria/templates/credits.html| index 8ba92f4..9f026c3 100644 |
| --- a/internal/memoria/templates/credits.html |
| +++ b/internal/memoria/templates/credits.html |
| @@ -1,3 +1,5 @@ |
| {{define "title"}}Open source · memoria{{end}} |
| -{{define "head"}}<link rel="stylesheet" href="/static/credits.css">{{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 "content"}}{{template "estilo/credits" .Data}}{{end}} |
| + |
| +{{define "footer"}}<footer rst-page-footer></footer>{{end}} |
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html| index 1e59e76..5fbd414 100644 |
| --- a/internal/memoria/templates/layout.html |
| +++ b/internal/memoria/templates/layout.html |
| @@ -38,7 +38,7 @@ |
| <main rst-page> |
| {{template "content" .}} |
| </main> |
| -<footer rst-page-footer> |
| +{{block "footer" .}}<footer rst-page-footer> |
| {{/* The passage count is stale for the same reason the document list |
| is — an upload that never navigates cannot change either — so it |
| carries rst-refresh and drop.js swaps it out of the same answer. |
| @@ -55,7 +55,7 @@ |
| Search is lexical only — no assistant, and <strong>no document ever |
| leaves this instance</strong>. |
| </p> |
| -</footer> |
| +</footer>{{end}} |
| </body> |
| </html> |
| {{end}} |