diff --git a/AGENTS.md b/AGENTS.md index 0217604..cdf9e70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,14 @@ them. Revisit only if the stated reason stops holding. workspace dir, so ci.sh's web-image build races another run's container creation on the `-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites. +- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is + deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an + author that using a new icon means registering it there. So the palette may narrow when the last + reference to an id goes — `i-gear` left with the settings menu 2026-08-05 — and a plugin needing one + gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon + instead of failing loud; the `every icon resolves to a defined ` e2e test catches it for + anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the + registry — a few ids are registered ahead of a caller (see `todo.md`). ## Docker only — no host tooling diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 6be79f7..5edcf83 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -101,6 +101,7 @@ test("the public landing at / is ungated and links to sign in + register", async await expect(page.locator(".landing")).toBeVisible(); // the same app shell every page renders — the menu shows even signed out (permission-filtered). await expect(page.locator(".sidebar")).toBeVisible(); + await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login"); await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration"); await shot(page, "live-05-public-landing"); diff --git a/public/css/styles.css b/public/css/styles.css index bbea499..9cb0e5f 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -282,7 +282,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */ outline: 2px solid var(--focus); outline-offset: 1px; } -/* profile / settings row */ .footer-actions { display: flex; align-items: center; gap: 4px; } .profile { display: flex; align-items: center; gap: 9px; flex: 1 1 auto; diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 0fa49a1..293c56d 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -151,9 +151,7 @@ const messages = { "shell.guest": "Guest", "shell.mainNav": "Main navigation", "shell.openMenu": "Open menu", - "shell.preferences": "Preferences", "shell.profile": "Profile", - "shell.settings": "Settings", "shell.sidebar": "Primary", "shell.signedInAs": "Signed in as {{name}}", "shell.signIn": "Sign in", diff --git a/src/i18n/locales/sv-SE.ts b/src/i18n/locales/sv-SE.ts index a2cf95e..2df132f 100644 --- a/src/i18n/locales/sv-SE.ts +++ b/src/i18n/locales/sv-SE.ts @@ -137,9 +137,7 @@ const messages: CoreMessages = { "shell.guest": "Gäst", "shell.mainNav": "Huvudmeny", "shell.openMenu": "Öppna menyn", - "shell.preferences": "Inställningar", "shell.profile": "Profil", - "shell.settings": "Inställningar", "shell.sidebar": "Primär", "shell.signedInAs": "Inloggad som {{name}}", "shell.signIn": "Logga in", diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 95bd3ef..3ea8ffe 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -16,7 +16,6 @@ export const ICON_NAMES: Record = { "i-copy": "copy", "i-download": "download", "i-edit": "pencil", - "i-gear": "settings", "i-globe": "globe", "i-grid": "layout-grid", "i-kebab": "ellipsis-vertical", diff --git a/src/ui/shell.test.ts b/src/ui/shell.test.ts index d654288..68c0877 100644 --- a/src/ui/shell.test.ts +++ b/src/ui/shell.test.ts @@ -51,6 +51,7 @@ test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a p const html = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x" }); // no user, no signInHref → default assert.match(html, /href="\/login"[^>]*>[\s\S]*?Sign in/); // a path to sign in (default target) assert.doesNotMatch(html, /action="\/logout"/); // a guest has no session to end + assert.doesNotMatch(html, /.` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer. ### Architectural review findings (2026-07-02) @@ -46,6 +46,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API, - [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.) - [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/.ts` for the host, `plugins//i18n/.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE` → `Accept-Language` → `en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.) - [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.) +- [x] There is a "Settings" in the bottom left (a little cog) showing a "Preferences" in a little menu when clicked. That is not in any spec, it exists when not even logged in and erh. Just remove. (Dropped from the sidebar footer in `views/partials/shell.ejs`, which now carries the profile menu — or Sign in when anonymous — plus the language picker. The `shell.settings`/`shell.preferences` catalog keys went with it in both locales, as did the then-unreferenced `i-gear` icon: `ICON_NAMES` is by definition the icons the UI references, so `views/partials/icons.ejs` was regenerated from it. Kratos' own `/settings` account flow is a different thing and is untouched. Covered by `src/ui/shell.test.ts` signed-in and anonymous, plus the public-landing case in `e2e-tests/visual.spec.ts`.) ### Architectural review findings (2026-07-02) diff --git a/views/partials/icons.ejs b/views/partials/icons.ejs index a446e21..47ed253 100644 --- a/views/partials/icons.ejs +++ b/views/partials/icons.ejs @@ -12,7 +12,6 @@ - diff --git a/views/partials/shell.ejs b/views/partials/shell.ejs index 4226976..cf33667 100644 --- a/views/partials/shell.ejs +++ b/views/partials/shell.ejs @@ -84,12 +84,6 @@ <% } %> <%- include("locale-switch") %> - - <%- include("menu", { - up: true, - trigger: { class: "btn icon-btn", label: t("shell.settings"), html: '' }, - items: [{ head: t("shell.settings") }, { label: t("shell.preferences"), icon: "i-gear" }], - }) %>