From de22f51c12f130ec36410cd55258f962b5d54789 Mon Sep 17 00:00:00 2001 From: lilleman Date: Wed, 24 Jun 2026 00:23:55 +0200 Subject: [PATCH] Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout --- AGENTS.md | 10 +- README.md | 147 +++++++++++--------- compose.yml | 4 +- config/menu.ts | 4 +- docs/plugin-contract.md | 40 +++--- e2e-tests/visual.spec.ts | 2 +- package.json | 2 +- plugins/scheduling/plugin.ts | 2 +- plugins/scheduling/shifts.test.ts | 2 +- plugins/scheduling/shifts.ts | 2 +- src/{ => admin}/admin-clients.test.ts | 0 src/{ => admin}/admin-clients.ts | 16 +-- src/{ => admin}/admin-groups.test.ts | 2 +- src/{ => admin}/admin-groups.ts | 18 +-- src/{ => admin}/admin-nav.test.ts | 8 +- src/{ => admin}/admin-nav.ts | 14 +- src/{ => admin}/admin-roles.test.ts | 2 +- src/{ => admin}/admin-roles.ts | 18 +-- src/{ => admin}/admin-users.test.ts | 2 +- src/{ => admin}/admin-users.ts | 18 +-- src/{ => auth}/bootstrap.test.ts | 0 src/{ => auth}/bootstrap.ts | 4 +- src/{ => auth}/csrf.test.ts | 0 src/{ => auth}/csrf.ts | 2 +- src/{ => auth}/denylist.test.ts | 0 src/{ => auth}/denylist.ts | 0 src/{ => auth}/fetch-timeout.test.ts | 0 src/{ => auth}/fetch-timeout.ts | 0 src/{ => auth}/flow-view.test.ts | 0 src/{ => auth}/flow-view.ts | 2 +- src/{ => auth}/gen-jwks.test.ts | 4 +- src/{ => auth}/gen-jwks.ts | 2 +- src/{ => auth}/guards.test.ts | 2 +- src/{ => auth}/guards.ts | 4 +- src/{ => auth}/hydra-admin.test.ts | 0 src/{ => auth}/hydra-admin.ts | 0 src/{ => auth}/jwks.test.ts | 2 +- src/{ => auth}/jwks.ts | 2 +- src/{ => auth}/jwt-middleware.test.ts | 0 src/{ => auth}/jwt-middleware.ts | 6 +- src/{ => auth}/jwt.test.ts | 0 src/{ => auth}/jwt.ts | 0 src/{ => auth}/keto-client.test.ts | 0 src/{ => auth}/keto-client.ts | 0 src/{ => auth}/kratos-admin.test.ts | 0 src/{ => auth}/kratos-admin.ts | 0 src/{ => auth}/kratos-public.test.ts | 0 src/{ => auth}/kratos-public.ts | 0 src/{ => auth}/login.test.ts | 0 src/{ => auth}/login.ts | 6 +- src/{ => auth}/oauth-consent.test.ts | 0 src/{ => auth}/oauth-consent.ts | 0 src/{ => auth}/oauth-login.test.ts | 0 src/{ => auth}/oauth-login.ts | 0 src/compose.test.ts | 2 +- src/{ => http}/app.test.ts | 24 ++-- src/{ => http}/app.ts | 56 ++++---- src/{ => http}/body.test.ts | 0 src/{ => http}/body.ts | 0 src/{ => http}/context.test.ts | 2 +- src/{ => http}/context.ts | 4 +- src/{ => http}/cookie.test.ts | 0 src/{ => http}/cookie.ts | 0 src/{ => http}/safe-url.test.ts | 0 src/{ => http}/safe-url.ts | 0 src/{ => http}/security-headers.test.ts | 0 src/{ => http}/security-headers.ts | 0 src/{ => http}/static.ts | 0 src/{ => plugin-host}/discovery.test.ts | 0 src/{ => plugin-host}/discovery.ts | 2 +- src/{ => plugin-host}/hooks.test.ts | 2 +- src/{ => plugin-host}/hooks.ts | 2 +- src/{ => plugin-host}/plugin-api.test.ts | 0 src/{ => plugin-host}/plugin-api.ts | 18 +-- src/{ => plugin-host}/plugin.test.ts | 4 +- src/{ => plugin-host}/plugin.ts | 6 +- src/{ => plugin-host}/router.test.ts | 0 src/{ => plugin-host}/router.ts | 0 src/{ => plugin-host}/view-resolver.test.ts | 2 +- src/{ => plugin-host}/view-resolver.ts | 0 src/server.ts | 24 ++-- src/{ => ui}/auth-card.test.ts | 2 +- src/{ => ui}/chrome.test.ts | 2 +- src/{ => ui}/chrome.ts | 6 +- src/{ => ui}/dashboard.test.ts | 0 src/{ => ui}/dashboard.ts | 2 +- src/{ => ui}/data-table.test.ts | 2 +- src/{ => ui}/field.test.ts | 2 +- src/{ => ui}/filter-bar.test.ts | 2 +- src/{ => ui}/icons.test.ts | 2 +- src/{ => ui}/icons.ts | 2 +- src/{ => ui}/list-query.test.ts | 0 src/{ => ui}/list-query.ts | 0 src/{ => ui}/menu-config.test.ts | 0 src/{ => ui}/menu-config.ts | 2 +- src/{ => ui}/menu.test.ts | 2 +- src/{ => ui}/nav-tree.test.ts | 2 +- src/{ => ui}/nav.test.ts | 0 src/{ => ui}/nav.ts | 0 src/{ => ui}/paginate.test.ts | 0 src/{ => ui}/paginate.ts | 0 src/{ => ui}/pagination.test.ts | 2 +- src/{ => ui}/shell-context.test.ts | 0 src/{ => ui}/shell-context.ts | 2 +- src/{ => ui}/shell.test.ts | 2 +- src/{ => ui}/theme-switch.test.ts | 2 +- views/admin/clients.ejs | 2 +- views/admin/groups.ejs | 2 +- views/admin/roles.ejs | 2 +- views/admin/users.ejs | 2 +- views/auth.ejs | 2 +- views/partials/flow-body.ejs | 2 +- views/partials/icons.ejs | 2 +- 113 files changed, 282 insertions(+), 265 deletions(-) rename src/{ => admin}/admin-clients.test.ts (100%) rename src/{ => admin}/admin-clients.ts (97%) rename src/{ => admin}/admin-groups.test.ts (99%) rename src/{ => admin}/admin-groups.ts (97%) rename src/{ => admin}/admin-nav.test.ts (94%) rename src/{ => admin}/admin-nav.ts (90%) rename src/{ => admin}/admin-roles.test.ts (98%) rename src/{ => admin}/admin-roles.ts (96%) rename src/{ => admin}/admin-users.test.ts (99%) rename src/{ => admin}/admin-users.ts (96%) rename src/{ => auth}/bootstrap.test.ts (100%) rename src/{ => auth}/bootstrap.ts (98%) rename src/{ => auth}/csrf.test.ts (100%) rename src/{ => auth}/csrf.ts (97%) rename src/{ => auth}/denylist.test.ts (100%) rename src/{ => auth}/denylist.ts (100%) rename src/{ => auth}/fetch-timeout.test.ts (100%) rename src/{ => auth}/fetch-timeout.ts (100%) rename src/{ => auth}/flow-view.test.ts (100%) rename src/{ => auth}/flow-view.ts (98%) rename src/{ => auth}/gen-jwks.test.ts (93%) rename src/{ => auth}/gen-jwks.ts (96%) rename src/{ => auth}/guards.test.ts (96%) rename src/{ => auth}/guards.ts (95%) rename src/{ => auth}/hydra-admin.test.ts (100%) rename src/{ => auth}/hydra-admin.ts (100%) rename src/{ => auth}/jwks.test.ts (99%) rename src/{ => auth}/jwks.ts (99%) rename src/{ => auth}/jwt-middleware.test.ts (100%) rename src/{ => auth}/jwt-middleware.ts (96%) rename src/{ => auth}/jwt.test.ts (100%) rename src/{ => auth}/jwt.ts (100%) rename src/{ => auth}/keto-client.test.ts (100%) rename src/{ => auth}/keto-client.ts (100%) rename src/{ => auth}/kratos-admin.test.ts (100%) rename src/{ => auth}/kratos-admin.ts (100%) rename src/{ => auth}/kratos-public.test.ts (100%) rename src/{ => auth}/kratos-public.ts (100%) rename src/{ => auth}/login.test.ts (100%) rename src/{ => auth}/login.ts (97%) rename src/{ => auth}/oauth-consent.test.ts (100%) rename src/{ => auth}/oauth-consent.ts (100%) rename src/{ => auth}/oauth-login.test.ts (100%) rename src/{ => auth}/oauth-login.ts (100%) rename src/{ => http}/app.test.ts (99%) rename src/{ => http}/app.ts (95%) rename src/{ => http}/body.test.ts (100%) rename src/{ => http}/body.ts (100%) rename src/{ => http}/context.test.ts (98%) rename src/{ => http}/context.ts (95%) rename src/{ => http}/cookie.test.ts (100%) rename src/{ => http}/cookie.ts (100%) rename src/{ => http}/safe-url.test.ts (100%) rename src/{ => http}/safe-url.ts (100%) rename src/{ => http}/security-headers.test.ts (100%) rename src/{ => http}/security-headers.ts (100%) rename src/{ => http}/static.ts (100%) rename src/{ => plugin-host}/discovery.test.ts (100%) rename src/{ => plugin-host}/discovery.ts (99%) rename src/{ => plugin-host}/hooks.test.ts (97%) rename src/{ => plugin-host}/hooks.ts (96%) rename src/{ => plugin-host}/plugin-api.test.ts (100%) rename src/{ => plugin-host}/plugin-api.ts (70%) rename src/{ => plugin-host}/plugin.test.ts (98%) rename src/{ => plugin-host}/plugin.ts (98%) rename src/{ => plugin-host}/router.test.ts (100%) rename src/{ => plugin-host}/router.ts (100%) rename src/{ => plugin-host}/view-resolver.test.ts (98%) rename src/{ => plugin-host}/view-resolver.ts (100%) rename src/{ => ui}/auth-card.test.ts (98%) rename src/{ => ui}/chrome.test.ts (98%) rename src/{ => ui}/chrome.ts (95%) rename src/{ => ui}/dashboard.test.ts (100%) rename src/{ => ui}/dashboard.ts (95%) rename src/{ => ui}/data-table.test.ts (99%) rename src/{ => ui}/field.test.ts (98%) rename src/{ => ui}/filter-bar.test.ts (98%) rename src/{ => ui}/icons.test.ts (99%) rename src/{ => ui}/icons.ts (94%) rename src/{ => ui}/list-query.test.ts (100%) rename src/{ => ui}/list-query.ts (100%) rename src/{ => ui}/menu-config.test.ts (100%) rename src/{ => ui}/menu-config.ts (99%) rename src/{ => ui}/menu.test.ts (98%) rename src/{ => ui}/nav-tree.test.ts (98%) rename src/{ => ui}/nav.test.ts (100%) rename src/{ => ui}/nav.ts (100%) rename src/{ => ui}/paginate.test.ts (100%) rename src/{ => ui}/paginate.ts (100%) rename src/{ => ui}/pagination.test.ts (98%) rename src/{ => ui}/shell-context.test.ts (100%) rename src/{ => ui}/shell-context.ts (97%) rename src/{ => ui}/shell.test.ts (99%) rename src/{ => ui}/theme-switch.test.ts (96%) diff --git a/AGENTS.md b/AGENTS.md index 79935fc..d20ee1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,9 +37,13 @@ commands and layout. Intentional, reasoned choices — an architecture review should honor them, not re-raise them. Revisit only if the stated reason stops holding. -- **`src/` is flat on purpose.** The functional-core / imperative-shell split is - conceptual, not a directory layout; splitting into `core/`/`shell/` dirs was judged - premature for a scaffold this size. Revisit only if the flat tree stops being readable. +- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/` + (session-JWT hot path, guards, and the Ory REST clients), `admin/` (built-in screens), + `plugin-host/` (discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel), + and `ui/` (design-system view-models + menu/chrome); `server.ts`/`config.ts`/`logger.ts` + and the topology-guard `*.test.ts` stay at the root. Tests are co-located (`foo.test.ts` + beside `foo.ts`). Add a new module to the folder that owns its concern rather than to the + root; don't reintroduce a flat tree. - **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request context. It protects the I/O-free hot path on the public, bot-hit landing (`/`). (Declined twice.) diff --git a/README.md b/README.md index 4c765d8..bbf316d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ landing; the gated app home is `/dashboard`. folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: ```ts -import { definePlugin } from "../../src/plugin-api.ts"; +import { definePlugin } from "../../src/plugin-host/plugin-api.ts"; export default definePlugin({ apiVersion: "1.0.0", @@ -196,7 +196,7 @@ via a central override (see [The menu system](#the-menu-system)). The full, authoritative API surface — manifest shape, handler/`RequestContext` contract, versioning, conflict rules, hooks, and the dev/test story — is -**[docs/plugin-contract.md](docs/plugin-contract.md)** (`src/plugin.ts` holds the types). A +**[docs/plugin-contract.md](docs/plugin-contract.md)** (`src/plugin-host/plugin.ts` holds the types). A complete, runnable reference ships in **[`plugins/scheduling/`](plugins/scheduling/)** — a public overview page, a permission-gated list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, and a mix of public + role-gated nav. Copy it and adapt. @@ -216,7 +216,7 @@ The manifest is **TypeScript** — typed, commented, no separate schema to keep `id` and mount path are **derived from the folder name**, not declared: ```ts -import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs) +import { definePlugin } from "../../src/plugin-host/plugin-api.ts"; // the stable author barrel (see docs) import { listShifts, overview } from "./shifts.ts"; export default definePlugin({ @@ -224,7 +224,7 @@ export default definePlugin({ // Nav fragment, composed into the global menu. Permission-gated: items the current user can't // access are hidden. `public: true` shows an item to everyone (signed in or not). Arbitrary - // depth. `icon` is a Lucide icon by its sprite id (src/icons.ts). + // depth. `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). nav: [ { label: "Scheduling", icon: "i-cal", @@ -301,13 +301,13 @@ the plugin in the build context and it's `COPY`'d in at build time — pinned an reproducible; mount a volume only to add plugins to an already-built image. > Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and -> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/discovery.ts`); a bad -> plugin stops startup with a precise message. The router (`src/router.ts`) then mounts +> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad +> plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts > each route at `/`, resolves `:name` params, runs the permission gate, and turns the > handler's `RouteResult` into the response; a `view` result renders -> `plugins//views/.ejs` (`src/view-resolver.ts`), which may `include()` the core +> `plugins//views/.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core > building-block partials. A plugin's `public/` assets are served at `/public//` -> (`src/static.ts`). The mount mechanics above are how the files get into the container +> (`src/http/static.ts`). The mount mechanics above are how the files get into the container > either way. ## The menu system @@ -315,7 +315,7 @@ reproducible; mount a volume only to add plugins to an already-built image. The menu is **driven entirely by config** and assembled from two sources: 1. **Plugin fragments** — each plugin contributes its own `nav` (above). -2. **A central override** — `config/menu.ts` (loaded by `src/menu-config.ts`, validated at +2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and sets branding (app name, logo, default theme). The override always wins, applied before the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply. @@ -332,7 +332,7 @@ counts, arbitrary depth). Branding (name, logo, default theme) renders in the ap the sidebar brand shows the configured logo (else a default mark), and the theme sets the theme-switch default. -**One menu, one shell, everywhere.** There is a single menu (`src/chrome.ts` +**One menu, one shell, everywhere.** There is a single menu (`src/ui/chrome.ts` `buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, the admin screens, plugin pages, and the login / registration / recovery / front (`/`) pages. So it looks identical signed in or out; it just shows fewer items to an anonymous visitor @@ -350,7 +350,7 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS: pagination, form fields, badges, menus, auth cards. - **Helpers:** `composeNav` (menu from config), `parseListQuery` (`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the - auth guards a handler calls to authorize (`src/guards.ts`): `requireSession` (assert a + auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a session — a `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for relationship rules). @@ -541,7 +541,7 @@ users** on modest hardware. In return: ### Instant revoke: the optional denylist -Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/denylist.ts`). For +Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For security-critical revoke (offboarding, a compromised account) the ~10m role/session lag above is too long. When enabled, an admin **deactivating** or **deleting** a user, or **granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path @@ -587,10 +587,10 @@ Hydra's login & consent steps — authenticating the user via their Kratos sessi issues the access / refresh / id tokens those apps use. Nothing in the menu or first-party pages needs Hydra. -The **login challenge** is wired (`src/oauth-login.ts` at `/oauth2/login`): Hydra hands the +The **login challenge** is wired (`src/auth/oauth-login.ts` at `/oauth2/login`): Hydra hands the browser here, the app resolves it against the Kratos session and accepts (or bounces an unauthenticated user to the themed login, returning here once signed in). The **consent -challenge** is wired too (`src/oauth-consent.ts` at `/oauth2/consent`): a first-party client +challenge** is wired too (`src/auth/oauth-consent.ts` at `/oauth2/consent`): a first-party client (its Hydra `metadata.first_party: true`) — or one Hydra already skipped — is auto-granted the requested scopes; any other client gets a themed consent screen (naming the signed-in account, with a sign-out escape) whose CSRF-guarded Allow/Deny accepts or rejects. id_token @@ -600,7 +600,7 @@ resumes to Hydra's post-logout redirect — the first-party `POST /logout` still the Kratos session + our JWT cookie. Those clients are registered from the admin **OAuth2 clients** screen (`/admin/clients`, -`src/admin-clients.ts`): register (Hydra shows the generated `client_secret` **once**, on +`src/admin/admin-clients.ts`): register (Hydra shows the generated `client_secret` **once**, on the confirmation page — confidential clients), list, and delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra. @@ -744,7 +744,7 @@ Before going live, supply the production secrets and any SSO credentials — the manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)); the rest is auto-generated. -Every response carries security headers (`src/security-headers.ts`, set once per request): a +Every response carries security headers (`src/http/security-headers.ts`, set once per request): a strict `Content-Security-Policy` (the core is **zero-JS** — `script-src 'self'`, no inline scripts, so an injected `