18 KiB
18 KiB
Primary todo
Unfinnished work
- 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?
- 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
- 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.