31 KiB
31 KiB
Primary todo
Unfinnished work
- 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.
grantDiffdeliberately 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.
applyGrantsloops 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). Thealert alert-pospattern 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:readnow 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 outsidetsconfig.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/testpresent wherevernpm run typecheckruns, which today is thewebimage that installs neither. Raised by review 2026-08-05. - Decide whether Playwright's
workersshould be pinned. It is unset, so Playwright sizes the pool fromos.cpus(), which reports the host's cores regardless of a container CPU quota — and withretries: 0a 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@supportstest 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/:focusstate, 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 setsCache-Control— so a shared cache in front of the app has no instruction, and a signed-in page is not markedprivate. Pre-existing, surfaced by the i18n review 2026-08-03: either set the headers deliberately (public pages cacheable, gated pagesprivate, no-store) or record in AGENTS.md that the reverse proxy owns this. - Decide whether the single generic Keto
Resourcenamespace should become per-domain namespaces (Shift,Document, …), as Ory's own examples model it. One globalResourcebucket 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", buti-chart,i-copy,i-downloadandi-slidershave no caller anywhere — so either they go the wayi-gearjust 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 → theICON_NAMESdeviation). 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.tssigns<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 withSECURE_COOKIES=false) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behindSameSite=Lax+ HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce tosub(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 --stricttoday; a size/complexity rule would have caught theapp.tsgrowth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-freevisualsuite; 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.tsandadmin-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 bridgingparseListQuery/paginateto the EJS partials; at minimum abuildPaginationModel(page, hrefFor)block. - LOW→MEDIUM — Retire
src/ui/shell-context.ts.ShellModel/buildShellContexthas one consumer left (dashboard) and duplicatesPageChromeon almost every field, incl. identical brand-assembly inchrome.tsandshell-context.ts. Fold the dashboard ontoctx.chrome+ title/breadcrumbs; keepshellUseras the shared primitive. - LOW — Fix stale doc references to removed
docs/plugin-contract.mdinviews/index.ejs(user-visible dashboard text; also links /scheduling as if pre-installed) andexamples/plugins/scheduling/views/shifts.ejs. - LOW — Decide (once) on a
ctx.systemfacade.#plugin-apiexposes raw Ory client shapes, so an Ory client refactor is a majorapiVersionbump. 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 —
isValidPermissionNameinsrc/plugin-host/plugin.ts, checked byshapeErrorover every route/navpermissionand 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.adminis gone, split per screen intousers:,groups:andoauth2-clients:×read/write. The read/write split is real, not cosmetic:users:readopens 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 andcomposeNavdrops the emptied header (which needs the header to carry nohref, 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:readalready 404'd. AndADMIN_PERMISSIONSdefaulting to empty exposed thatbootstrapnever bind-mountedplugins/at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives incompose.override.yml(dev-only, mirroringweb'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 saysdocker 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 andgrantDiffturns 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 itoauth2-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
testfrome2e-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-testallowConsole(/…/)— 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.tsrun in Chromium, Firefox and WebKit — the per-test@enginestag 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 injectedconsole.warnfailed the test in all three engines and an injectedconsole.errorfailed on full-flow's shared serial page — which also caught the guard registering that page twice.src/e2e-console-guard.test.tslocks the wiring in the unit gate, since a spec importingteststraight 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'sdocs_only()no-ops the gate when every path changed sincemainends 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 nestedexamples/plugins/admin/README.mdedit is as safe to skip asREADME.md, and narrowing it would spend the full gate on one. What was actually broken was rename detection —git mv src/app.ts notes.mdnames only the destination undergit diff --name-only, and collapses to a singleR src/app.ts -> notes.mdline undergit status --porcelain, so moving code onto a.mdpath 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.tslocks both flags; it stays a text guard because the test image isnode:alpinewith neithergitnorbash.) - 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,Escdismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where.table-wrap'soverflowcan no longer clip a row kebab. Placement is CSS anchor positioning; the panel needsposition-anchor: autoto bind to the button that opened it — a bareanchor()resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach.data-table.ejsstopped hand-rolling its kebab and calls themenupartial, 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 anid, sincepopovertargetis 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.tsdrives 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.ymlrunsbash 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.ymlforce-pushes main + tags over HTTPS with a dedicated account's PAT in theMIRROR_GITHUB_TOKENsecret; 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.ymlbuilds + pushesgitea.larvit.se/larvit/plainpages:<commit hash>after a green gate — with ff-only merges that is the main commit's image; auth via theDOCKER_REGISTRY_USERvariable +DOCKER_REGISTRY_TOKENsecret, 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.ymlon avX.Y.Ztag pulls the commit-hash image and re-tags itX.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.ymlpushes the sameX.Y.Z/X.Y/X/latesttags todocker.io/larvit/plainpagesafter the Gitea re-tag — releases only, no hash tags; auth via theDOCKERHUB_USERvariable +DOCKERHUB_TOKENsecret; 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.ymlruns the self-hostedrenovate/renovateimage nightly againstrenovate.json— this repo only, via the sharedrenovate@larvit.sebot +RENOVATE_TOKENsecret, mirroring thepwrpln/corepattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflowrun: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_TOKENenv inrenovate.ymlso 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 theRENOVATE_GITHUB_TOKENsecret — a scopeless read-only github.com PAT; Gitea rejectsGITHUB_-prefixed secret names — into the container asGITHUB_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.ymlgains anauto-releasejob (needs: renovate) that cuts onevX.Y.Ztag per run for what Renovate merged; level = highestRelease-Bump:trailer Renovate stamps viacommitBody, any dep's major/minor/patch mapped straight through (default patch). Decoupled fromapiVersion(tag-only,HOST_API_VERSIONuntouched — 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. Pureauto-release/next-version.ts+ unit tests; tag pushed with renovate-bot's PAT sorelease.ymlfires; 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.ymlbuilds and pushesgitea.larvit.se/larvit/plainpages:<commit hash>behind the green gate,release.ymlre-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_SECRETSguards onlyCSRF_SECRET, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-expguard gained a test insrc/auth/jwt-middleware.test.ts.) - Add i18n support. (Catalogs are TS modules per locale —
src/i18n/locales/<tag>.tsfor the host,plugins/<id>/i18n/<tag>.tsfor 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?localeonto the links it renders andctx.localeHref()does it for a plugin's.ctx.t(key, vars)plust/locale/locales/localeHref/dirmerged into every view (any include depth);{{var}}interpolation, plurals viaIntl.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.tsin the visual gate; documented in README → Languages, decisions in AGENTS.md.) - Settle the identity-vs-user vocabulary. (Plainpages says user everywhere — Keto namespace
User, subjectsuser:<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 theIdentityDTO insrc/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 submitkratos.1040001"Sign up" — under a "Create account" heading, and sv-SE already said "Skapa konto" — andoauth.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 fromviews/partials/shell.ejsalong with theshell.profilekey 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.tsasserts the menu holds no deadtype="button", ande2e-tests/full-flow.spec.tsasserts 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. Theshell.settings/shell.preferencescatalog keys went with it in both locales, as did the then-unreferencedi-gearicon:ICON_NAMESis by definition the icons the UI references, soviews/partials/icons.ejswas regenerated from it. Kratos' own/settingsaccount flow is a different thing and is untouched. Covered bysrc/ui/shell.test.tssigned-in and anonymous, plus the public-landing case ine2e-tests/visual.spec.ts.)
Architectural review findings (2026-07-02)
- HIGH — Split
handleRequestinsrc/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; reducehandleRequestto pipeline → internal route table →sendResult.