25 Commits

Author SHA1 Message Date
58398481ca §10 review pass: address the architecture + product reviewers (todo §10); hide the gated Dashboard nav node from anonymous visitors in buildPluginChrome (a no-permission link to /dashboard only dead-ended them at /login) and dedup it into a shared DASHBOARD_NAV (admin-nav.ts, reused by chrome + adminNav). New chrome.signInHref bakes the current page in as return_to for the shell's anonymous Sign-in link (shell.ejs + reference overview.ejs), mirrored as optional ShellModel.signInHref so the typed builder is complete. ctx.chrome is now a lazy, memoized getter (context.ts chrome option = a factory) so a json/redirect handler or the public "/" with a standalone home never composes the global menu — app.ts passes the app-level memoized factory at every site. Default /dashboard prints a "Starter dashboard" note framing the mock-data home as a replaceable demo (signals its inert affordances); stale "until §4" comments fixed. RESERVED_PLUGIN_IDS drift-guard test derives the built-in segments from AUTH_FLOWS + ADMIN_*_BASE + host literals (home stays deliberately unreserved). Refreshed the stale plugin-contract status blurb and documented the chrome.*→partials/shell mapping. Reviewers: architecture + product APPROVE (no addressable findings remain), stability APPROVE (no Critical/High/Medium). typecheck + 356 units + visual(10) + full-flow(7) E2E green. 2026-06-21 01:19:40 +02:00
7bdeb24b7f §10 public pages + menu items, the blessed explicit alias (todo §10); a plugin may mark a page and its menu option public. A no-permission route/nav node is already anonymous-reachable, so per the human's pick this BLESSES that as a first-class, explicit choice (keep the default; add an explicit alias — not a secure-by-default flip). New optional public?: boolean on Route (src/plugin.ts) + NavNode (src/nav.ts) = "open to everyone, signed in or not", honored outright in isAuthorized (router.ts) + filterByRoles (nav.ts), and MUTUALLY EXCLUSIVE with permission — discovery shapeError recursively rejects a route/nav node setting both, failing the boot loud (never silently picks one). public is filter-only (toRenderNode never emits it). The shell (views/partials/shell.ejs) now renders a Sign in link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (ctx.chrome; ctx.user may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public /scheduling Overview route + a public "Overview" nav child (the "Scheduling" header now shows for everyone), the shifts list still behind scheduling:read. Hardened the latent gap the shell newly leans on: claimsToUser rejects an empty email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (public open + reject-both + loads), shell (anon → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: plugin-contract.md ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: visual.spec asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: APPROVE, no Critical/High/Medium (addressed its one Low — the empty-email hardening). typecheck + 354 units + full scripts/ci.sh gate (visual 10 · auth 1 · oauth 2 · full 7) green. 2026-06-20 18:12:46 +02:00
7787ed4ea4 §10 split landing into a public "/" + gated "/dashboard", both plugin-replaceable (todo §10 follow-up); per human feedback, "/" is now an ungated public landing (default views/home.ejs: brand + intro + prominent Log in / Create account links, or "go to dashboard" when signed in) and "/dashboard" is the gated post-login app home (anonymous → /login?return_to=/dashboard). Both are fully replaceable via two optional RouteHandlers on PluginManifest — home? (public /) and dashboard? (gated /dashboard) — rendered against the plugin's own views with the native shell via ctx.chrome (full route parity: HEAD, void-return, response hooks, fresh CSRF cookie; a home handler is public so ctx.user may be null). Single-slot + loud: findConflicts errors on >1 owner of either slot (new "home"/"dashboard" kinds), discovery rejects a non-function handler, and "dashboard" is reserved so a plugin folder can't shadow it ("/" can't be shadowed — route paths carry the /<id> prefix). Post-login + already-signed-in redirects and the global Dashboard/People nav hrefs moved to /dashboard. Tests-first (348 units): public-/ + gated-/dashboard + dual plugin-override in app.test; per-slot conflict in plugin.test; non-function/reserved/two-owners in discovery.test. Docs: plugin-contract "The landing pages" section + README. E2E: visual.spec plants a session for /dashboard design-system tests + a cookie-free public-landing test; full-flow repointed to /dashboard. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 348 units + visual(10) + full-flow(7) green. 2026-06-20 17:43:01 +02:00
2eb5b84ccf §10 gate the dashboard + make "/" replaceable by a plugin (todo §10); "/" is now gated to a signed-in session (anonymous → /login via loginRedirect, query preserved as return_to) and fully replaceable via a new optional home?: RouteHandler on PluginManifest — a handler with the same signature as any route (the most ergonomic shape). The app.ts "/" branch gates first, then renders the single home plugin's handler against its own views/ with the native shell via ctx.chrome (HEAD / void-return / response-hook parity with a plugin route), else the built-in mock-data People list. home mounts at the root above the /<id> namespace, so it can't shadow or be shadowed by a built-in route. Single-slot + loud: findConflicts errors on >1 home (new "home" kind), discovery rejects a non-function home — never last-write-wins. Tests-first (338 → 344 units): app.test.ts gate + home-override; plugin.test.ts home conflict; discovery.test.ts home validation. Docs: plugin-contract.md (manifest table + "The dashboard (home)" section + conflict row), README. E2E: visual.spec plants a dev-signed session (the anonymous plugin-gate probe uses the cookie-free request fixture); all e2e web/gateway healthchecks repointed from the gated "/" to /public/css/styles.css. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 344 units + visual(9) + full-flow(7) E2E green. 2026-06-20 17:18:30 +02:00
66ea68a91b §9 whole-project arch+product review pass (todo §9); ran systems-architect + product-owner on the whole project (no Critical/High — a converged scaffold) and addressed the in-scope §9 customer-facing/security findings. (1) return_to deep-link login, open-redirect-safe: a gated request hit while signed out (plugin-route gate, requireSession, requireAdmin) bounces to /login?return_to=<host-relative path> via new loginRedirect(ctx) (GET/HEAD, skips /); /login bakes it into the Kratos flow — a host-relative target is wrapped through <origin>/auth/complete?return_to=<path> so the JWT mints before landing, an absolute target (§6 OAuth2 login challenge) passes to Kratos as-is; /auth/complete redirects to the requested page. (2) safeUrl()+localPath() in new pure src/safe-url.ts: safeUrl sanitises an untrusted href/src to relative-or-http(s) (else "#"), exported via plugin-api.ts (closes the contract's "planned for §9" pointer); localPath is the host-relative redirect-allowlist guard for return_to, re-checked at both /login and /auth/complete. (3) honest 503 on Ory-unreachable sign-in (views/503.ejs) instead of the misattributed catch-all 500; expired-flow 4xx still restarts. Tests-first throughout; stability-reviewer APPROVE (addressed its Medium — scoped the 503 catch so a template bug hits the 500 with a stack, not a 503). typecheck + 339 units + full scripts/ci.sh gate green. Deferred with justification: the app.ts route-table refactor (standalone change + §10 prereq), mock dashboard + public-page blessing (§10 lines 139/140), success-flash (known). 2026-06-20 16:39:09 +02:00
bea9a71d6f §9 trace all fetch + ENV service name + leveled logging (todo §9 follow-up); route every outbound fetch through the request logger, make the OTLP service name implementer-configurable, and add proper leveled logging throughout. An AsyncLocalStorage<Log> makes the per-request logger ambient (runWithLog/currentLog), so all outbound fetch traces with no signature churn: tracedFetch (a typeof fetch) routes through the active request log (client span + propagated W3C traceparent) for string/URL inputs, else plain fetch; server.ts wires it under the Ory timeout into every Kratos/Keto/Hydra + JWKS call (timeout still honoured — log.fetch spreads {...init,headers}). RequestContext gained ctx.log (request logger; additive/contract-stable, silent default) so a handler/plugin logs in-trace and ctx.log.fetch(url) traces upstream calls; the reference plugin's createUpstream defaults to tracedFetch and its handlers log via ctx.log; plugin-api.ts exports tracedFetch + the Log class. SERVICE_NAME (config + createLogger({serviceName})) brands the OTLP service.name. Leveled logging: who-did-what audit info lines on every admin write (user/group/role/client create·delete·assign — actor/target, no secrets), info on login (session mint) + logout, warn on missing-role 403 + CSRF rejections + Ory-unreachable, debug on a JWKS kid-miss reload. app.ts's handler body was extracted to handleRequest run inside runWithLog; end() now fires exactly once after BOTH the handler unwinds AND the response closes, so a client abort mid-handler can't end the log out from under a still-running ctx.log/tracedFetch (regression-tested) and the happy-path access line is never dropped. bootstrap.ts wraps main in runWithLog + traces the seed calls. Tests extended (logger: serviceName/runWithLog/currentLog/tracedFetch-continues-trace; config: SERVICE_NAME; context: ctx.log default+passthrough; app: ctx.log in-trace + ctx.log.fetch propagation + the abort race; plugin-api: tracedFetch+Log). Stability-reviewer: APPROVE, no Critical/High (fixed the abort-race end(); green nits addressed). docs/plugin-contract.md (ctx.log/ctx.log.fetch/tracedFetch) + README (config, Observability tracing/serviceName, plugin note, Layout) updated. typecheck + 333 units + the full scripts/ci.sh E2E gate green (326 → 333). 2026-06-20 15:46:48 +02:00
a20f3507e0 §8 review convergence (todo §8); re-ran the architecture + product reviewers to convergence — 5 rounds, until both returned zero new actionable findings. Fixed across rounds 1-4 (tests-first): bounded every outbound Ory fetch with a timeout (src/fetch-timeout.ts withTimeout + ORY_TIMEOUT_SEC default 5, incl. the http JWKS fetch) so a hung Ory can't park a request handler; anonymous on a permission-gated plugin route now 303→/login (was a dead-end 403; signed-in-without-role still 403); an already-signed-in user is sent home from /login + /registration; the onRequest hook short-circuit now sets the fresh CSRF cookie; admin-users malformed :id → 404 (was 500) via safeDecode; parseJwks validates key element shape (fails loud at load); removed the dead COOKIE_SECRET (loaded + enforced + documented but never read); documented HYDRA_ADMIN_URL; admin recovery shows the code + links to the public /recovery instead of the browser-unreachable admin-API link; reference-plugin breadcrumb-label + pagination/datetime README notes; corrected the contract doc to not over-promise a post-login "retry". Declined: unconditional base-ctx chrome (would build the menu per request, regressing the lazy hot path). Deferred → §9: return_to-preservation for deep-link login. Stability-reviewer on the cumulative diff: APPROVE, no Critical/High (addressed its Low nits). typecheck + 310 units + the full scripts/ci.sh gate (visual 9 · auth 1 · oauth 2 · full 6) green. 2026-06-20 00:42:23 +02:00
98784a3239 §7 comment cleanup (todo §7); targeted density pass over the §7 accretion. The §7 modules were authored dense (the reference plugin is a teaching artifact, the host additions concise), so two wins: tightened chrome.ts's module header (7→5 lines, dropped the input-list duplicated by ChromeOptions + the nav-composition restatement already carried by the nav field/markCurrent comments); fixed a stale forward-ref in docs/plugin-contract.md (the safeUrl() helper said "§5/§7" but §7 deferred it to §9). Left intact: the reference plugin's instructive comments, the EJS view config-doc headers, and the contract doc + plugin README (authored concise in §7). README Status/_(planned)_/Layout refresh stays §9. Docs/comments-only; typecheck + 301 units green. 2026-06-19 15:38:36 +02:00
4e97fb619e §7 review checkpoint (todo §7); ran the architecture + product reviewers on the whole project and addressed findings, no Critical from either. Made permissions honest + decoupled the host from the plugin: new pure seedRoles + bootstrap discoverPlugins() seeds the demo admin admin(/ADMIN_ROLES) ∪ every discovered plugin's declared tokens, dropped the hardcoded scheduling:* from compose ADMIN_ROLES (clean-clone unchanged); docs now state a route/nav permission is a coarse role granted as Keto Role:<token>#members. Added src/plugin-api.ts — the stable author barrel the reference plugin now imports from instead of deep src/* (the contract boundary in code). Made per-plugin CSS usable: shell styles slot + plugins/scheduling/public/scheduling.css linked from the views. Reference now demonstrates hooks.onBoot validating SCHEDULING_UPSTREAM fail-loud (assertHttpUrl). Build ctx.chrome at most once per request (memoized). Doc honesty: fixed the false visual.spec coverage comment, softened the "every plugin ships a Playwright test" claim (authed flow = §8), added an Upstream contract block to the plugin README. Added LICENSE (MIT). Stability-reviewer APPROVE, no Critical/High; addressed both Low nits. typecheck + 301 units green. Deferred: internal route-table (M1)→§9, safeUrl()→§9, data-table empty-state + success-flash→§8/polish, apiVersion-literal enforcement (prose), permission→requireRole rename (future minor). 2026-06-19 15:31:53 +02:00
45d9b2ede9 §7 verify plugin contract end-to-end vs README (todo §7); cross-checked every contract claim in README/docs/plugin-contract.md/plugins/scheduling against the implementation + shipped reference plugin — the contract holds (derived id/mount, apiVersion+checkApiVersion, gated nav/routes, all RouteResult shapes, ctx.chrome native shell, ctx.verifyCsrf, can()/GuardError, view-resolver including core + own partials, registered icons, upstream-fetch statelessness). Fixed one doc drift: docs/plugin-contract.md reserved-id list named 8 ids but RESERVED_PLUGIN_IDS has 10 — the §5 admin + §6 oauth2 mounts were missing (discovery refuses them, test-covered), so the doc now lists all 10. Docs-only; typecheck + 296 units green. 2026-06-19 15:02:59 +02:00
f189f88942 §7 reference plugin (todo §7); plugins/scheduling is the worked example of the plugin contract — a list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, permission-gated nav. shifts.ts: an injectable-fetch upstream REST client (stateless stand-in for the customer backend) + thin handler factories (list filters by ?q + degrades to a recoverable page on upstream-down; create CSRF-guards via ctx.verifyCsrf, validates, forwards, PRG, 502 on upstream 4xx). plugin.ts: apiVersion literal, namespaced scheduling:read/write perms, nav gated so the whole Scheduling header vanishes for non-holders. Views compose the core building blocks around the native app shell, incl. the plugin's own partials/shift-form. New host capability so a plugin page is native + secure (src/chrome.ts buildPluginChrome): ctx.chrome = brand/global-nav/user/theme/csrf for partials/shell (global menu = Dashboard + every plugin nav fragment + gated admin section, role-filtered + current-marked); ctx.verifyCsrf = the host's bound double-submit verifier (secret stays in the host). Both added to RequestContext (defaulted in buildContext), built per plugin route in app.ts (CSRF cookie set when fresh). Dashboard merges plugin nav fragments too (gated => invisible to anonymous, visual E2E byte-identical). Out of the box: bootstrap grants the demo admin scheduling:read/write (seedAdmin generalized to a roles list, env ADMIN_ROLES); dev compose runs a tiny stdlib mock upstream (examples/shifts-upstream, SCHEDULING_UPSTREAM). plugins/ added to tsconfig + the npm test glob. Tests-first across shifts/chrome/app/dashboard/bootstrap. README Building-a-plugin + Layout and docs/plugin-contract.md (ctx.chrome/verifyCsrf, upstream pattern) updated. typecheck + 296 units + the Ory-free visual E2E green (plugin discovered at boot, routes/nav gated, dashboard unchanged); live full-stack boot-verified (stack up with plugin + mock upstream serving the seeded shifts, bootstrap grants in real Keto all allowed:true) then torn down. apiVersion stays 1.0.0 (contract still assembled in §7). Authenticated browser happy-path deferred to §8 full E2E (line 114). 2026-06-19 14:48:27 +02:00
d1fbf8fa1f Comment/README cleanup (todo §4); tighten the kratos/keto client module-headers (drop forward-refs + caller-listings, keep rationale), retarget the stale safeUrl() ref in plugin-contract.md to §5/§7 2026-06-18 11:52:49 +02:00
caadaf5da3 Reviewer-run fixes (todo §4); re-mint try/catch degrades an Ory outage to anonymous (not 500), RESERVED_PLUGIN_IDS refuses a plugin folder that would shadow a host route 2026-06-18 11:45:04 +02:00
228a206469 Auth guards (todo §4); guards.ts: requireSession/can/check + GuardError, app.ts maps GuardError → 303 /login or 403 (never 500) 2026-06-18 10:10:15 +02:00
a8ebf81588 Address whole-project review (todo §2); wire plugin hooks (onBoot/onRequest/onResponse), document template trust boundary, tidy discovery 2026-06-16 16:23:08 +02:00
ff7b55be4c Wire branding into the app shell (todo §2); render config logo + default theme, fall back to the brand mark 2026-06-16 16:07:24 +02:00
952dd03cc2 Add config/menu.ts central override + branding (todo §2); loadMenuConfig validates+merges, override applied to nav, branding into shell 2026-06-16 15:52:03 +02:00
3cdefff233 Serve per-plugin static assets (todo §2); /public/<id>/ → plugins/<id>/public/ via routePublic, core public/ unaffected 2026-06-16 15:18:20 +02:00
fe89dd1c06 Add per-plugin view resolver (todo §2); render plugins/<id>/views/<view>.ejs with nested names + traversal guard, core partials reachable via include() 2026-06-16 13:41:02 +02:00
9b6684c653 Mount plugin routes via the router (todo §2); match method+path under /<id>, resolve :params, permission gate, RouteResult→response 2026-06-16 12:22:15 +02:00
0ebe8144c2 Document how plugins get into the container (README); bind-mount to /app/plugins/<id> or bake in, front-and-center under Building a plugin 2026-06-16 11:58:28 +02:00
09d616ddd3 Loosen plugin id rule (todo §2); allow digits and dashes anywhere (^[a-z0-9-]+$) 2026-06-16 11:53:14 +02:00
1623a81ddc Refine plugin contract (todo §2); derive id/mount from folder (isValidPluginId), apiVersion literal not HOST_API_VERSION, nav icon = Lucide, drop redundant basePath 2026-06-16 10:58:29 +02:00
a0d39ef624 Make checkApiVersion semver-based (todo §2); strict parseSemver via official semver regex (no dep), major/minor compatibility rules 2026-06-16 10:46:02 +02:00
3be67ff8e4 Specify the plugin contract (todo §2); typed manifest + version/conflict rules in src/plugin.ts, authoritative docs/plugin-contract.md 2026-06-15 17:07:55 +02:00