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 `