Files
plainpages/todo.md
T
2026-08-05 15:08:31 +02:00

32 KiB
Raw Blame History

Primary todo

Unfinnished work

  • Deleting a group orphans its permission grants. groupsDelete removes every Group:<name>#members tuple, but nothing removes Permission:X#granted@Group:<name>#members — so re-creating a group with the same name silently restores every permission it used to hold. Pre-existing (the same orphan was reachable via the old Permissions screen), but granting to a group is now a one-click flow on the group's own page, which raises the odds a lot. Raised by the stability review 2026-08-05.
  • Guard the group paths to self-lockout, or accept them explicitly. The self-revoke guard covers only your own direct grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Same scope the deleted Permissions screen had, and recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query. Raised by the stability review 2026-08-05.
  • The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change (standard lost-update on a set-based form — and the natural "two of us are onboarding the new hire" workflow produces exactly it). Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying. Fits the existing "the form is the whole truth" model instead of fighting it. Raised by the product review 2026-08-05.
  • A grant whose plugin was uninstalled is invisible and unremovable in the GUI. grantDiff deliberately never revokes an undeclared name (so an unrelated save can't drop it), but nothing shows it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action. Raised by the product review 2026-08-05.
  • A plugin may gate a route on a permission it never declares — declaring stays optional on purpose (mandatory declaration would warn on the legitimate cross-plugin sharing case). The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error, no warning, and a permanent 403 as the operator's only clue. Sketch: a discovery warning (not an error) naming the gated-but-undeclared permission. Raised by the product review 2026-08-05.
  • Saving permissions gives no confirmation, and a partial failure is silent. applyGrants loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed" (PRG back to the same page, checkboxes as the only feedback). The alert alert-pos pattern the recovery-code banner uses is already available. Raised by the product review 2026-08-05.
  • Add the read-only operator to README → Overview's personas. users:read now makes a support/helpdesk account possible for the first time, and it is a distinct persona from the three listed (end user, non-technical user, plugin author) — the one whose screens must render without write affordances. Writing it down makes read-only rendering a stated requirement rather than something the next reviewer rediscovers. Raised by the product review 2026-08-05.
  • The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done)
  • In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
  • Decide whether e2e-tests/ should be typechecked. It is outside tsconfig.include, so the gate never checks the most logic-bearing file in it (console-guard.ts) — Playwright strips its types without checking them. Including it needs the DOM lib and @playwright/test present wherever npm run typecheck runs, which today is the web image that installs neither. Raised by review 2026-08-05.
  • Decide whether Playwright's workers should be pinned. It is unset, so Playwright sizes the pool from os.cpus(), which reports the host's cores regardless of a container CPU quota — and with retries: 0 a starved runner turns a slow test straight into a red gate rather than a retry. Fine on the current act_runner; revisit if CI ever runs constrained. Raised by review 2026-08-05.
  • Record the browser floor Plainpages actually requires, and whether the fallback is the contract or a courtesy. The stylesheet already needs :has() (Dec 2023); the menus now need the popover API (Safari 17, Sep 2023) and CSS anchor positioning for placement (newer still, and unguarded — the @supports test covers popover only). An iPadOS 16 tablet — capped at Safari 16, and exactly the "tablet on a factory floor, old thin client at a reception desk" README → Overview sells the zero-JS stance on — therefore gets panels flowing inline rather than working menus. Either state a supported floor in the README or accept the fallback as the answer for those devices; nobody has rendered that path on real hardware. Raised by the architecture review 2026-08-05.
  • Decide whether the profile dropdown still earns a dropdown. With the dead Profile link gone it holds one item, Sign out, behind a click — and its "Signed in as X" head only repeats the name and email the trigger already shows. Either put Sign out in the footer directly, or give the menu a second reason to exist. Overlaps the outside-click item above. Raised by review 2026-08-05.
  • When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
  • Guard against the double-clicked submit, without client-side JavaScript. The README's non-technical persona double-clicks a button that doesn't respond instantly, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only (no client JS — priority: zero-JS spine), and it must not break an action that is legitimately repeatable (an increase-by-one button is not a duplicate, it is two increments). Sketch to evaluate: a CSS-only affordance so the second click has nothing to hit (:active/:focus state, or the submit visually and semantically settling), paired with the host recognising a duplicate on the server — same session, same route, same payload, within a short window — and then logging it and dropping the second rather than replaying it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form is stronger than hashing the payload, and the CSRF plumbing already mints per-request tokens), how long the window is, where the record lives given the app is stateless (in-memory like the revoke denylist, or push it to the upstream the plugin already writes to), and how a plugin declares a route as repeatable — an opt-out on the route, or opt-in per form. Raised 2026-08-04 with the personas.
  • Decide the caching contract for rendered pages. Responses now carry Vary: Accept-Language (they content-negotiate), but nothing sets Cache-Control — so a shared cache in front of the app has no instruction, and a signed-in page is not marked private. Pre-existing, surfaced by the i18n review 2026-08-03: either set the headers deliberately (public pages cacheable, gated pages private, no-store) or record in AGENTS.md that the reverse proxy owns this.
  • Decide whether the single generic Keto Resource namespace should become per-domain namespaces (Shift, Document, …), as Ory's own examples model it. One global Resource bucket is the project's own "no catch-all names" rule (utils, helpers, misc) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
  • Decide what ICON_NAMES (src/ui/icons.ts) actually is. Its comment says "the icons the UI actually references", but i-chart, i-copy, i-download and i-sliders have no caller anywhere — so either they go the way i-gear just did, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression (see AGENTS.md → the ICON_NAMES deviation). Pre-existing, surfaced by the review 2026-08-05.
  • Decide (once) whether the CSRF token staying unbound to sub/session is accepted. src/auth/csrf.ts signs <nonce>.<HMAC(secret, nonce)> 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)

Prioritized. Overall verdict: architecture is sound (contract-first plugin API, functional core/imperative shell, strong test seams); these are refinements.

  • MEDIUM — Add complexity/method-size static analysis to the CI gate. Only tsc --strict today; a size/complexity rule would have caught the app.ts growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free visual suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote).
  • MEDIUM — De-duplicate examples/plugins/admin/admin-groups.ts and admin-permissions.ts (~80% identical). Same "Keto membership object admin" concept twice; extract a parameterized helper keyed on { namespace, base, labels, columns }, leave permissions' effective-access view as the only delta. Matters extra because this is the reference plugin people copy.
  • MEDIUM→LOW — Add a list-page view-model helper in src/ui/. Every list screen (users, groups, permissions, shifts) hand-rewrites the same ~40 lines bridging parseListQuery/paginate to the EJS partials; at minimum a buildPaginationModel(page, hrefFor) block.
  • LOW→MEDIUM — Retire src/ui/shell-context.ts. ShellModel/buildShellContext has one consumer left (dashboard) and duplicates PageChrome on almost every field, incl. identical brand-assembly in chrome.ts and shell-context.ts. Fold the dashboard onto ctx.chrome + title/breadcrumbs; keep shellUser as the shared primitive.
  • LOW — Fix stale doc references to removed docs/plugin-contract.md in views/index.ejs (user-visible dashboard text; also links /scheduling as if pre-installed) and examples/plugins/scheduling/views/shifts.ejs.
  • LOW — Decide (once) on a ctx.system facade. #plugin-api exposes raw Ory client shapes, so an Ory client refactor is a major apiVersion bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision.
  • LOW — README/AGENTS.md gaps: state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population (first-party vs external) to justify the versioning machinery.

Finnished work

  • Document permissions format so it is folled going forward: :, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → Naming a permission is the one home for the rule, and the host enforces it at discovery — isValidPermissionName in src/plugin-host/plugin.ts, checked by shapeError over every route/nav permission and every declared name — so a badly-named permission stops the boot like any other bad manifest, for every plugin rather than only ones the admin GUI touches. admin is gone, split per screen into users:, groups: and oauth2-clients: × read/write. The read/write split is real, not cosmetic: users:read opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own :read — hold none of the three and composeNav drops the emptied header (which needs the header to carry no href, now asserted). Two things had to be fixed to get here. The permission path validator was the group regex with no colon, so /admin/permissions/scheduling:read already 404'd. And ADMIN_PERMISSIONS defaulting to empty exposed that bootstrap never bind-mounted plugins/ at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives in compose.override.yml (dev-only, mirroring web's .:/app) because the base file gives both services the same baked copy and a base-file mount would collide with the e2e stacks that bind plugins inside that path. Quick start now says docker compose up -d, which re-runs the one-shot. Verified end to end on a live stack.)
  • Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. (The host collects every installed plugin's declarations into one catalog — declaredPermissions()ctx.declaredPermissions, deduped and sorted, computed once at wiring — and that catalog is the fixed list. The Permissions screen is deleted outright: its module, tests, three views, two partials and 29 catalog keys per locale. Users and Groups each gained a checkbox list of the catalog, ticked where held; the whole set posts back, so what is submitted is the desired state and grantDiff turns it into grants + revokes. Two properties earn their tests: a crafted POST cannot grant a name no plugin declares, and a held-but-undeclared name — left over from an uninstalled plugin — is never silently revoked by an unrelated save, since the picker only speaks for what it showed. A user's own change revokes their live tokens; a group's reaches members at their next re-mint, the documented transitive lag. Keto stays optional on the Users screen: without it the page still lists and edits, minus the picker. Maintainer's call 2026-08-05 to keep the OAuth2-clients screen and gate it oauth2-clients:read/write — permissions and OAuth2 are orthogonal, scopes say what an app may see and permissions what a user may do, so the screen only ever needed a gate.)
  • In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its test from e2e-tests/console-guard.ts, which watches every page a test opens — console.error, console.warning, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test allowConsole(/…/) — used once, by the 404 spec, whose own navigation Chromium and WebKit log. All browsers is now literal for the Ory-free suites: visual.spec.ts + language.spec.ts run in Chromium, Firefox and WebKit — the per-test @engines tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected console.warn failed the test in all three engines and an injected console.error failed on full-flow's shared serial page — which also caught the guard registering that page twice. src/e2e-console-guard.test.ts locks the wiring in the unit gate, since a spec importing test straight from Playwright would run unwatched and green.)
  • Don't run tests when only markdown files in the root have changed. (Already shipped for any *.md, anywhere in the tree — ci.sh's docs_only() no-ops the gate when every path changed since main ends in .md, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested examples/plugins/admin/README.md edit is as safe to skip as README.md, and narrowing it would spend the full gate on one. What was actually broken was rename detection — git mv src/app.ts notes.md names only the destination under git diff --name-only, and collapses to a single R src/app.ts -> notes.md line under git status --porcelain, so moving code onto a .md path skipped the gate over a source file that was gone. Both channels now pass --no-renames; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged and committed — the last two failing before the fix and passing after. src/ci-gate.test.ts locks both flags; it stays a text guard because the test image is node:alpine with neither git nor bash.)
  • The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the Popover API. All three popup menus — language picker, profile, row kebab — are now a <button popovertarget> plus a [popover] panel instead of <details>/<summary>, so the browser owns open/close: clicking anywhere outside dismisses one, Esc dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where .table-wrap's overflow can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs position-anchor: auto to bind to the button that opened it — a bare anchor() resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. data-table.ejs stopped hand-rolling its kebab and calls the menu partial, so the pattern lives in one file. Each panel is named by its caller (locale-menu, profile-menu, row-actions-1) and the partial fails loud without an id, since popovertarget is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. <details> stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. e2e-tests/visual.spec.ts drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
  • Organize the files in src in to folders so it is easier to understand the structure of the code.
  • Move docs/plugin-contract.md into README.md and remove the docs folder.
  • The plugins/scheduling is an example and shouldn't be committed to the plugins directory since that should be empty to be able to be mounted in via docker or other means for the users/develoeprs using this application/framework. Put it in the examples folder instead.
  • The config folder should be empty and the current settings in the menu.ts should be the fallback default. IF a menu.ts where to appear in that folder, it should override the default settings with whatever is in it. The idea is the folder should be empty by default and you mount it in your docker container with your config.
  • Make the internal admin pages for users groups etc into a plugin instead in the examples folder and remove them from the internal source. Add a part in the quick start about copying this plugin into the plugins folder to enable GUI user- and group admining.
  • CI/CD - Test on push to any branch except main. (.gitea/workflows/ci.yml runs bash ci.sh; the one-time act_runner setup it needs is documented in README → CI/CD.)
  • CI/CD - Require PR to main and don't allow merge if tests does not pass. Only allow linear history and history that leaves the last commit hash on main the exact same as on the branch we just merged in. (Gitea branch protection on main + fast-forward-only merge style, set via API; documented in README → CI/CD.)
  • CI/CD - Sync up to github after every successful merge to main, URL: git@github.com:larvit/plainpages.git - also note the true home top of the README. Force push to github, it should only ever be a mirror of the gitea.larvit.se repository. (.gitea/workflows/mirror.yml force-pushes main + tags over HTTPS with a dedicated account's PAT in the MIRROR_GITHUB_TOKEN secret; setup documented in README → CI/CD.)
  • CI/CD - Build docker images as part of the requirements to be able to merge to main. Push them with the git commit hash as docker tag. Push to container registry at Gitea. (ci.yml builds + pushes gitea.larvit.se/larvit/plainpages:<commit hash> after a green gate — with ff-only merges that is the main commit's image; auth via the DOCKER_REGISTRY_USER variable + DOCKER_REGISTRY_TOKEN secret, retention via an org cleanup rule; documented in README → CI/CD.)
  • CI/CD - Re-tag docker images from git hash to semver when a semver git tag is pushed. (release.yml on a vX.Y.Z tag pulls the commit-hash image and re-tags it X.Y.Z/X.Y/X/latest, failing loud if the gated image is missing; tag pushes also trigger the GitHub mirror; documented in README → CI/CD.)
  • CI/CD - Sync docker images to docker hub after each re-tag to git tags. (release.yml pushes the same X.Y.Z/X.Y/X/latest tags to docker.io/larvit/plainpages after the Gitea re-tag — releases only, no hash tags; auth via the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret; documented in README → CI/CD.)
  • Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via docker run … tar + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
  • CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (renovate.yml runs the self-hosted renovate/renovate image nightly against renovate.json — this repo only, via the shared renovate@larvit.se bot + RENOVATE_TOKEN secret, mirroring the pwrpln/core pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow run: steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
  • CI/CD - Renovate: set a read-only GITHUB_COM_TOKEN env in renovate.yml so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, actions/checkout) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the RENOVATE_GITHUB_TOKEN secret — a scopeless read-only github.com PAT; Gitea rejects GITHUB_-prefixed secret names — into the container as GITHUB_COM_TOKEN; documented in README → CI/CD.)
  • CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (renovate.yml gains an auto-release job (needs: renovate) that cuts one vX.Y.Z tag per run for what Renovate merged; level = highest Release-Bump: trailer Renovate stamps via commitBody, any dep's major/minor/patch mapped straight through (default patch). Decoupled from apiVersion (tag-only, HOST_API_VERSION untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure auto-release/next-version.ts + unit tests; tag pushed with renovate-bot's PAT so release.yml fires; documented in README → CI/CD.)
  • Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (serve all --dev) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
  • Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: ci.yml builds and pushes gitea.larvit.se/larvit/plainpages:<commit hash> behind the green gate, release.yml re-tags it to semver and syncs those tags to Docker Hub.)
  • The human developer understands the security model in the auth in this project. (Two README sections. 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 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.)
  • Add i18n support. (Catalogs are TS modules per locale — src/i18n/locales/<tag>.ts for the host, plugins/<id>/i18n/<tag>.ts for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by ?locale=sv-SEAccept-Languageen-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, <html lang dir> 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.)
  • Settle the identity-vs-user vocabulary. (Plainpages says user everywhere — Keto namespace User, subjects user:<kratos-id>, 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.)
  • On the first page there is a button saying "Log in" and in the bottom left corner another button says "Sign in". Use a uniform language. (English now says sign in / sign out / create account everywhere; Swedish was already uniform. Three outliers went: the landing's landing.signIn "Log in", the registration submit kratos.1040001 "Sign up" — under a "Create account" heading, and sv-SE already said "Skapa konto" — and oauth.logoutExpired's "This logout request", whose sign-in twin said "sign-in request". The first-run banner says "sign in at" too, and the admin example's email hint says "the sign-in identifier". The rule is recorded in AGENTS.md → Rules and held by the author: a unit test asserting the verb shipped first and was dropped on the maintainer's call, since a build that fails on a word removes the judgment a growing UI needs. The two e2e specs that clicked "Log in" now scope to #main-content, since the anonymous sidebar carries a "Sign in" link of its own.)
  • When logged in, there is a "profile" link in the little box when I've clicked my username in the bottom left corner. There is no profile, so the link is dead. Remove it. (The <button type="button"> in the sidebar's profile menu had no handler and — zero-JS spine — could never get one; gone from views/partials/shell.ejs along with the shell.profile key in both locales. Sign out is now the menu's only item; the profile block itself (avatar, name, email) is the summary and stays. src/ui/shell.test.ts asserts the menu holds no dead type="button", and e2e-tests/full-flow.spec.ts asserts Sign out is the only item once the dropdown is open.)
  • 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)

  • HIGH — Split handleRequest in src/http/app.ts (~380 lines). It mixes the request pipeline with inline implementations of ~10 built-in endpoints (Kratos flows, /oauth2/*, /auth/complete, /logout, /, /dashboard, 404/405). Extract each endpoint into a named handler (auth/OAuth2 group → src/auth/ route module) with the same (req, res, ctx) shape plugin routes use; reduce handleRequest to pipeline → internal route table → sendResult.