create-substrat 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dev-servers.js CHANGED
@@ -23,7 +23,15 @@
23
23
  * @property {Record<string,string>} [env] Env the `dev` script sets for this process.
24
24
  */
25
25
 
26
- /** The template ships one process: the Hono API. There is no web app to scaffold yet. */
26
+ /**
27
+ * Two processes: the local OIDC issuer, and the Hono API that relies on it. There is no
28
+ * web app to scaffold yet.
29
+ *
30
+ * The issuer is a separate PROCESS rather than a branch inside the API on purpose — that
31
+ * is what keeps the vertical free of any dev-only auth path, and lets it be swapped for a
32
+ * real issuer by changing `OIDC_ISSUER` alone.
33
+ */
27
34
  export const DEV_SERVERS = [
35
+ { name: 'issuer', run: 'issuer', portEnv: 'ISSUER_PORT', portFrom: 'src/server.ts' },
28
36
  { name: 'api', run: 'server', portEnv: 'PORT', portFrom: 'src/server.ts' },
29
37
  ];
package/index.js CHANGED
@@ -17,6 +17,7 @@ import { basename, dirname, join, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
 
19
19
  import { DEV_SERVERS } from './dev-servers.js';
20
+ import { TSCONFIG, VITEST_CONFIG } from './project-files.js';
20
21
 
21
22
  const HERE = dirname(fileURLToPath(import.meta.url));
22
23
  const TEMPLATE = join(HERE, 'template');
@@ -35,10 +36,11 @@ const TEMPLATE = join(HERE, 'template');
35
36
  // The runtime packages release together off one version line (the changesets `fixed`
36
37
  // group), so one constant is right for all of them. Engines do NOT share a line —
37
38
  // each versions on its own, so one pin per engine, deliberately.
38
- const SUBSTRAT = '^0.85.0';
39
- const ENGINE_WORKORDER = '^0.8.1';
40
- const ENGINE_INVOICING = '^0.9.1';
41
- const BOUNDARY_LINT = '^0.1.1';
39
+ const SUBSTRAT = '^0.89.0';
40
+ const ENGINE_WORKORDER = '^0.9.0';
41
+ const ENGINE_INVOICING = '^0.9.5';
42
+ const BOUNDARY_LINT = '^0.2.0';
43
+ const DEV_ISSUER = '^0.1.3';
42
44
 
43
45
  const DOCS = 'https://substrat.net';
44
46
 
@@ -117,7 +119,9 @@ function packageJson(name) {
117
119
  devServers: DEV_SERVERS,
118
120
  },
119
121
  scripts: {
120
- dev: 'tsx watch src/server.ts',
122
+ // Two processes: the local OIDC issuer you sign in at, and the API.
123
+ dev: 'concurrently -n issuer,api -c magenta,blue "pnpm run issuer" "tsx watch src/server.ts"',
124
+ issuer: 'substrat-dev-issuer --personas src/personas.ts',
121
125
  server: 'tsx src/server.ts',
122
126
  test: 'vitest run',
123
127
  typecheck: 'tsc --noEmit && tsc -p tsconfig.worker.json --noEmit',
@@ -137,6 +141,9 @@ function packageJson(name) {
137
141
  },
138
142
  devDependencies: {
139
143
  '@substrat-run/boundary-lint': BOUNDARY_LINT,
144
+ // The local OIDC issuer `pnpm dev` signs you in at. A devDependency on
145
+ // purpose: it is never imported by anything the worker bundles.
146
+ '@substrat-run/dev-issuer': DEV_ISSUER,
140
147
  '@cloudflare/workers-types': '^4.20250109.0',
141
148
  '@types/better-sqlite3': '^9.6.0',
142
149
  '@types/node': '^22.0.0',
@@ -154,40 +161,6 @@ function packageJson(name) {
154
161
  )}\n`;
155
162
  }
156
163
 
157
- const TSCONFIG = `${JSON.stringify(
158
- {
159
- compilerOptions: {
160
- target: 'ES2022',
161
- module: 'NodeNext',
162
- moduleResolution: 'NodeNext',
163
- lib: ['ES2022'],
164
- strict: true,
165
- esModuleInterop: true,
166
- skipLibCheck: true,
167
- forceConsistentCasingInFileNames: true,
168
- noEmit: true,
169
- types: ['node'],
170
- },
171
- include: ['src', 'test'],
172
- // The worker and its Cloudflare-only stores compile against workers-types
173
- // under their own config (tsconfig.worker.json) — the node config must not
174
- // see them. `src/routes.ts` is deliberately NOT excluded: the shared route
175
- // table must typecheck under both, which is what keeps it host-agnostic.
176
- exclude: ['src/worker.ts', 'src/config-do.ts'],
177
- },
178
- null,
179
- 2,
180
- )}\n`;
181
-
182
- const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
183
-
184
- export default defineConfig({
185
- test: {
186
- include: ['test/**/*.test.ts'],
187
- },
188
- });
189
- `;
190
-
191
164
  const GITIGNORE = `node_modules/
192
165
  dist/
193
166
  *.sqlite
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "index.js",
27
27
  "dev-servers.js",
28
+ "project-files.js",
28
29
  "template"
29
30
  ],
30
31
  "engines": {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The generated project files — the configs a scaffold gets that are NOT in
3
+ * `template/`, because they are computed rather than copied.
4
+ *
5
+ * Split out of `index.js` for one reason: `tools/template-sync.mjs` materializes
6
+ * the template against the WORKSPACE (issue #878) and has to compile it under the
7
+ * same configs a real scaffold gets. Two hand-kept copies of a tsconfig is the
8
+ * drift class this repo has been bitten by repeatedly, so there is one copy and
9
+ * both readers import it.
10
+ *
11
+ * `packageJson()` deliberately stays in `index.js`: it interpolates the emitted
12
+ * pin block, and `tools/pins-emit.mts` writes that block into `index.js` by path.
13
+ * The workspace check does not want those pins anyway — resolving `@substrat-run/*`
14
+ * from npm is exactly what it is NOT doing.
15
+ *
16
+ * Dependency-free, like everything else in this package.
17
+ */
18
+
19
+ export const TSCONFIG = `${JSON.stringify(
20
+ {
21
+ compilerOptions: {
22
+ target: 'ES2022',
23
+ module: 'NodeNext',
24
+ moduleResolution: 'NodeNext',
25
+ lib: ['ES2022'],
26
+ strict: true,
27
+ esModuleInterop: true,
28
+ skipLibCheck: true,
29
+ forceConsistentCasingInFileNames: true,
30
+ noEmit: true,
31
+ types: ['node'],
32
+ },
33
+ include: ['src', 'test'],
34
+ // The worker and its Cloudflare-only stores compile against workers-types
35
+ // under their own config (tsconfig.worker.json) — the node config must not
36
+ // see them. `src/routes.ts` is deliberately NOT excluded: the shared route
37
+ // table must typecheck under both, which is what keeps it host-agnostic.
38
+ exclude: ['src/worker.ts', 'src/config-do.ts'],
39
+ },
40
+ null,
41
+ 2,
42
+ )}\n`;
43
+
44
+ export const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
45
+
46
+ export default defineConfig({
47
+ test: {
48
+ include: ['test/**/*.test.ts'],
49
+ },
50
+ });
51
+ `;
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "version": "0.0.1",
3
3
  "configurations": [
4
+ {
5
+ "name": "issuer",
6
+ "runtimeExecutable": "pnpm",
7
+ "runtimeArgs": [
8
+ "run",
9
+ "issuer"
10
+ ],
11
+ "port": 8879,
12
+ "autoPort": false
13
+ },
4
14
  {
5
15
  "name": "api",
6
16
  "runtimeExecutable": "pnpm",
@@ -191,9 +191,11 @@ plain language, so nothing there is a surprise:
191
191
  cross-tenant attacker gets nothing).
192
192
  9. **Open decisions** — each with a **recommended default**, so the user chooses rather
193
193
  than specifies:
194
- - **Auth.** Local dev uses an `x-principal` header — a dev seam, not a login. Real auth
195
- gates *exposing* the app, not *building* it — but if the app will be deployed for real
196
- users, wire the OIDC seam from the start: the standard is a **separate OIDC issuer**
194
+ - **Auth.** Local dev signs in at `@substrat-run/dev-issuer`, a real OIDC provider you
195
+ authenticate with by picking a name — so the local login is already the production
196
+ flow, and there is no dev-only auth path to unpick later. Real auth still gates
197
+ *exposing* the app, not *building* it, and the hosted worker resolves nobody until you
198
+ wire its seam; if the app will be deployed for real users, do that from the start: the standard is a **separate OIDC issuer**
197
199
  (an Auth Server app in the same team, or an external issuer — Supabase/Auth0/AuthHero/
198
200
  Keycloak), never per-app credential storage. The vertical is a pure OIDC **relying
199
201
  party**: depend on `@substrat-run/vertical-auth`, bind its `IdentityDO` (the
@@ -361,6 +363,21 @@ operations + the `ModuleRegistration`. Keep the split — the linter and tests e
361
363
  `completeWorkOrder`. One transaction, invariants intact.
362
364
  - Portal listing: iterate and `ctx.check(perm, entityRef)` **per entity** — a proof walk,
363
365
  not UI filtering.
366
+ - **An entity's history is `readTimeline(ctx, entity, input)` from `@substrat-run/kernel`** —
367
+ not a `SELECT` against `_substrat_outbox`. Reading the spine is allowed (writes to
368
+ `_substrat_*` are not); hand-writing the query is what goes wrong. It returns
369
+ `{ entries, nextCursor }` with `{ id, type, occurredAt, actor }` per entry, and it exists
370
+ to close two traps: `actor` is stored as JSON over a union — a principal is `"01J…"`
371
+ *with quotes*, so a raw `SELECT actor` is a string that resolves against no one — and
372
+ the cursor must be the event `id`, never `occurred_at`, which is identical across every
373
+ event a single operation emits. Your permission check stays your line, above the call.
374
+ `readHistory` is the same walk plus the payload, the permissions that authorized the
375
+ write — each with the grant it resolved through, `null` for a row written before that
376
+ was recorded, which is not the same fact as `[]` — and the PII classification,
377
+ `piiClass` with the `subjectId` it is keyed by, so a renderer can decide whether an
378
+ entry is safe to show before it shows it. `subjectId` is null when `piiClass` is
379
+ `none`, and the payload is `null` after that subject's erasure, which is a supported
380
+ answer to render rather than an error.
364
381
 
365
382
  ### `src/seed.ts`
366
383
 
@@ -407,13 +424,54 @@ pnpm dev # API on :8871 (PORT=… WEB_PORT=… to move i
407
424
  ```
408
425
 
409
426
  Then **actually exercise it** — don't just report that the server started. A green scenario
410
- test never touches `server.ts`, its routes, or the principal picker, so it can be green
411
- while the app is broken. Drive the real flow with curl (create → assign → start → report →
412
- complete) as two personas, switching `x-principal` to show a denial landing as a denial.
413
- The moment the attack fails is the demo; make sure the user sees it.
427
+ test never touches `server.ts`, its routes, or the login, so it can be green while the app
428
+ is broken. Drive the real flow with curl (create → assign → start → report → complete) as
429
+ two personas, so a denial lands as a denial. Get a session without a browser by minting a
430
+ token at the issuer — `curl -XPOST localhost:8879/dev/token -d '{"sub":"dev|greta"}'` — and
431
+ sending it as `Authorization: Bearer …`. The moment the attack fails is the demo; make sure
432
+ the user sees it.
433
+
434
+ If they want a UI, scaffold a minimal Vite + React app under `app/` with typed wrappers over
435
+ the routes and a sign-in button that redirects to `/api/auth/login`. Ask first — it roughly
436
+ doubles the work.
437
+
438
+ **The same change that creates `app/` declares it** — before a single component is written.
439
+ A UI ships as NATIVE assets: `substrat push` runs the declared build, hashes the output and
440
+ uploads it to the runtime's own asset store, served from the edge without invoking the
441
+ worker. Undeclared, the directory is never built, never uploaded, and the deployed vertical
442
+ answers `/api/*` and 404s on `/` — a deploy that looks entirely successful, and a failure no
443
+ gate before deploy can see (`pnpm test` never touches `server.ts`, boundary-lint has no
444
+ opinion about static files, and a vertical with no UI must legitimately declare no assets).
414
445
 
415
- If they want a UI, scaffold a minimal Vite + React app under `app/` with a principal picker
416
- and typed wrappers over the routes. Ask first — it roughly doubles the work.
446
+ ```jsonc
447
+ "substrat": {
448
+ "runtimeNeeds": {
449
+ "entry": "src/worker.ts",
450
+ "build": "npm --prefix app install && npm --prefix app run build",
451
+ "assets": {
452
+ "directory": "app/dist",
453
+ "notFoundHandling": "single-page-application", // deep client routes → index.html
454
+ "runWorkerFirst": ["/api/*", "/internal/*"] // only these reach the worker
455
+ }
456
+ }
457
+ }
458
+ ```
459
+
460
+ `build` runs before assets are collected, so the directory may be pure build output. Never
461
+ base64-inline a built `app/dist` into a generated worker module: it costs ~+33 % script size
462
+ and a worker invocation per image.
463
+
464
+ Two more that each fail silently:
465
+
466
+ - **`runWorkerFirst` must list every worker-owned prefix.** With
467
+ `notFoundHandling: "single-page-application"`, a missing `/api/*` entry answers every API
468
+ call with `index.html` — the app then reports parse errors instead of denials.
469
+ - **The app calls its own origin** (`fetch('/api' + path)`), never a baked base URL. The Vite
470
+ `proxy` block is a dev-only convenience; `VITE_API_URL` or `localhost:8871` works on the
471
+ author's machine and reaches nothing from a phone.
472
+
473
+ `substrat push` refuses an `app/` that nothing would serve, so this cannot reach a hostname
474
+ undeclared — but the refusal is a backstop, not the instruction. Declare it here.
417
475
 
418
476
  ---
419
477
 
@@ -464,27 +522,26 @@ authenticated CLI, and the author never holds a Cloudflare token:
464
522
  - `substrat hostnames bind <slug> --surface <s> [--domain <d>]` — mint a live hostname, or
465
523
  record a custom domain pending DNS validation (`substrat hostnames verify`).
466
524
 
467
- **A SPA ships as NATIVE assets — never inline it into the worker.** Declare it in
468
- `runtimeNeeds` and `substrat push` builds, hashes, and uploads the directory to the
469
- runtime's own asset store, served from the edge without invoking the worker:
525
+ **If this vertical has a UI, its `runtimeNeeds.assets` block was written back in Step 7**,
526
+ when `app/` was created — that is the one description of it, and it is not repeated here so
527
+ the two cannot drift. If you are deploying a vertical scaffolded before that rule existed,
528
+ go read it now: an undeclared `app/` deploys clean and 404s at its own hostname. `substrat
529
+ push` refuses that push, with the recipe.
470
530
 
471
- ```jsonc
472
- "substrat": {
473
- "runtimeNeeds": {
474
- "entry": "src/worker.ts",
475
- "build": "npm --prefix app install && npm --prefix app run build",
476
- "assets": {
477
- "directory": "app/dist",
478
- "notFoundHandling": "single-page-application", // deep client routes → index.html
479
- "runWorkerFirst": ["/api/*", "/internal/*"] // only these reach the worker
480
- }
481
- }
482
- }
531
+ **A deploy is not done until the URL serves the app.** Two requests, and show the user both:
532
+
533
+ ```sh
534
+ curl -si https://<hostname>/ | head -3 # expect 200 + content-type: text/html
535
+ curl -si https://<hostname>/api/me | head -3 # expect the worker, not index.html
483
536
  ```
484
537
 
485
- `build` runs before assets are collected, so the directory may be pure build output. Never
486
- base64-inline a built `app/dist` into a generated worker module: it costs ~+33 % script
487
- size and a worker invocation per image.
538
+ Triage a 404 in one request instead of an hour — the two layers fail differently:
539
+
540
+ | What you see | Where it broke |
541
+ |---|---|
542
+ | `404` with a Cloudflare body, `/internal/*` answers `403` | the worker ran; assets are undeclared or unbuilt |
543
+ | `404` before any worker header | the hostname is not bound to this surface |
544
+ | `/api/*` returns HTML | `runWorkerFirst` is missing that prefix |
488
545
 
489
546
  **Let changesets own the version, and pass it to push explicitly** — the default bump walks
490
547
  the registry forward on its own, so `package.json` and the registry drift apart within a
@@ -511,8 +568,9 @@ the version you just replaced. Changesets needs a git repo with a commit on the
511
568
  Updates deploy **in place** from one stable script — data carries forward, migrations run
512
569
  against prod data, backout is a time-boxed PITR rewind.
513
570
 
514
- Before deploying: the `x-principal` dev header **must** be gone. Shipping it is a
515
- cross-tenant hole with a UI.
571
+ Before deploying: `worker.ts`'s auth seam **must** be wired — it resolves nobody as it
572
+ ships, so every `/api/*` call is 401 until you do. Never substitute a header that names the
573
+ caller: that is a cross-tenant hole with a UI, which is why the starter no longer has one.
516
574
 
517
575
  ---
518
576
 
@@ -66,9 +66,16 @@ modules defined anywhere else will run locally and silently not deploy.
66
66
  `worker.ts` **mounts** the platform's `/internal/*` management contract via
67
67
  `mountPlatformSurface` from `@substrat-run/vertical-host` (one call — the routes
68
68
  and the `{ error }` envelope are authored there, not here, so they can't drift or
69
- ship half-done). What `worker.ts` still owns is **the auth seam** — the dev
70
- `x-principal` header is the only caller resolution until you wire real auth there;
71
- deploying with `ALLOW_DEV_HEADER` set is a cross-tenant hole with a UI.
69
+ ship half-done). What `worker.ts` still owns is **the auth seam** — it resolves
70
+ nobody until you wire real auth there, and every `/api/*` call is 401 until you do.
71
+ That is deliberate: there is no dev header to forget to turn off. `src/server.ts`
72
+ shows the shape, signing in against the local OIDC issuer `pnpm dev` starts.
73
+
74
+ **A UI ships as declared assets.** If `app/` exists, `substrat.runtimeNeeds.assets` must
75
+ point at its build output and `runtimeNeeds.build` must produce it — otherwise the deployed
76
+ vertical serves the API and 404s on `/`, with every local gate green. Declare it in the same
77
+ change that creates `app/`, not at deploy time. (`substrat push` refuses an undeclared UI,
78
+ but by then you are already deploying.)
72
79
 
73
80
  Among the hooks it passes, **`onConfigure` is the one you must not drop.** It is
74
81
  how per-instance settings reach the running app: the dashboard's Settings → Env
@@ -0,0 +1,35 @@
1
+ import type { DevPersona } from '@substrat-run/dev-issuer';
2
+
3
+ /**
4
+ * The local dev cast — the ONE place the issuer and this vertical agree on who exists.
5
+ *
6
+ * `pnpm dev` starts `@substrat-run/dev-issuer` pointed at this file: a real OpenID Connect
7
+ * provider whose only shortcut is that `/authorize` lists names instead of asking for a
8
+ * password. `linkDevPersonas` reads the same array and binds each `sub` to a principal in
9
+ * the identity directory. Neither side holds a copy of the other's list.
10
+ *
11
+ * Nothing here is a bypass, and that is the point. The issuer asserts a subject and the
12
+ * directory maps it to a principal — the same two steps a deployed instance performs against
13
+ * a real issuer, so the login you exercise all day is the login your users will run. What
14
+ * stood here before was an `x-principal` header: a name the server was simply told and
15
+ * believed, which is an impersonation bypass, and which meant the dev login was one no
16
+ * deployment ever ran.
17
+ *
18
+ * Add or rename people here freely — this is your world. Keep the `sub` values stable and
19
+ * readable: they end up in an identity directory that outlives a restart.
20
+ */
21
+ export const PERSONAS: DevPersona[] = [
22
+ { sub: 'dev|greta', name: 'Greta', email: 'greta@kedja.test', note: 'workshop-admin' },
23
+ { sub: 'dev|mans', name: 'Måns', email: 'mans@kedja.test', note: 'mechanic' },
24
+ { sub: 'dev|lisbeth', name: 'Lisbeth', email: 'lisbeth@example.test', note: 'portal customer' },
25
+ { sub: 'dev|otto', name: 'Otto', email: 'otto@example.test', note: 'portal customer' },
26
+ // The other shop, so the cross-tenant beat has someone to be turned away as.
27
+ { sub: 'dev|rutger', name: 'Rutger', email: 'rutger@trampolin.test', note: 'admin at a DIFFERENT shop' },
28
+ ];
29
+
30
+ /**
31
+ * The identity pool these logins belong to (K-23). `central`: one issuer serves both demo
32
+ * tenants, so the same subject is the same person in each. Named for the pool rather than
33
+ * its URL, so moving the issuer's port does not orphan every link in `.data`.
34
+ */
35
+ export const DEV_PROVIDER = 'oidc:dev-issuer';
@@ -1,12 +1,12 @@
1
1
  import type { Context, Hono } from 'hono';
2
- import { classifyError } from '@substrat-run/vertical-host';
2
+ import { problemResponse } from '@substrat-run/vertical-host';
3
3
  import type { ScopeStub } from '@substrat-run/kernel';
4
4
 
5
5
  /**
6
6
  * The bike shop's HTTP API — ONE route table, adapter- and auth-agnostic.
7
7
  *
8
- * Both entrypoints mount this: `server.ts` (node, pure-SQLite adapter, `x-principal`
9
- * dev auth) and `worker.ts` (Cloudflare, Durable-Object adapter, the auth seam). Each
8
+ * Both entrypoints mount this: `server.ts` (node, pure-SQLite adapter, OIDC against the
9
+ * local dev issuer) and `worker.ts` (Cloudflare, Durable-Object adapter, the auth seam). Each
10
10
  * supplies a `resolveStub` that authenticates the caller its own way and returns a
11
11
  * capability `ScopeStub`; every route here is a thin wrapper over ONE operation, with
12
12
  * no business logic — the rules live in an operation or an engine.
@@ -28,21 +28,24 @@ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): vo
28
28
  const body = (c: Context) => c.req.json<Record<string, unknown>>();
29
29
 
30
30
  /**
31
- * One error vocabulary, shared with the platform surface: `classifyError`
32
- * (@substrat-run/vertical-host) is the same function `mountPlatformSurface` uses, so a
33
- * permission denial is 403, a missing thing 404, a broken invariant 409, a runtime
34
- * fault 502 — identically on both hosts. "No opinion" becomes the caller's 400.
31
+ * One error vocabulary, shared with the platform surface: `problemResponse`
32
+ * (@substrat-run/vertical-host) is built on the same `classifyError` that
33
+ * `mountPlatformSurface` uses, so a permission denial is 403, a missing thing 404, a
34
+ * broken invariant 409, a runtime fault 502 — identically on both hosts. "No opinion"
35
+ * becomes the caller's 400.
36
+ *
37
+ * The body is RFC 9457 `application/problem+json`: a `code` from the closed taxonomy
38
+ * when your throw declared one (`substratError('conflict', …)`), `about:blank` when it
39
+ * did not. `{ error }` rides along for one deprecation window, so a client reading it
40
+ * keeps working while you move to `code`.
35
41
  *
36
42
  * In `worker.ts` this handler is REPLACED: Hono keeps only the last-registered
37
43
  * `onError`, and `mountPlatformSurface` installs its own. That is harmless precisely
38
- * because both are built on `classifyError` — same input, same answer. Registering it
39
- * here is what gives `server.ts`, which mounts no platform surface, the same behaviour.
44
+ * because both are built on the same vocabulary — same input, same answer. Registering
45
+ * it here is what gives `server.ts`, which mounts no platform surface, the same
46
+ * behaviour.
40
47
  */
41
- app.onError((err, c) => {
42
- const seen = classifyError(err);
43
- if (seen) return c.json({ error: seen.message }, seen.status);
44
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
45
- });
48
+ app.onError((err, c) => problemResponse(c, err));
46
49
 
47
50
  // -- generic invoke ---------------------------------------------------------
48
51
  // The kernel checks a permission inside EVERY operation, so a generic route is
@@ -17,6 +17,7 @@ import { ENTITLEMENT_KEYS, MODULES, OWNER_ROLE_KEY, portalPerms, ROLES } from '.
17
17
  // provision.ts — node-free so the worker bundles it and `substrat push` reads
18
18
  // it. Re-exported here for callers that treat seed.ts as the world's front door.
19
19
  export { ENTITY_GRANTS, MODULES, permissions, ROLES } from './provision.js';
20
+ import { DEV_PROVIDER, PERSONAS } from './personas.js';
20
21
 
21
22
  // ============================================================================
22
23
  // The seeded world. TWO tenants on purpose: the first is the shop the scenario
@@ -221,3 +222,45 @@ export async function seedBikeShop(host: SqliteScopeHost, dir: string): Promise<
221
222
  writeFileSync(castPath, JSON.stringify(world, null, 2));
222
223
  return world;
223
224
  }
225
+
226
+ /**
227
+ * Bind each dev persona's OIDC `sub` to its principal in the identity directory.
228
+ *
229
+ * This is the ordinary production seam, not a dev one: a deployed instance builds the same
230
+ * rows when someone claims the owner seat or accepts an invite. All that differs locally is
231
+ * that the subjects are known up front, so nobody has to click through a first-run claim
232
+ * after every data wipe.
233
+ *
234
+ * Run on EVERY boot rather than only on a fresh seed: `seedBikeShop` returns early once
235
+ * `cast.json` exists, and `linkIdentity` is idempotent for an unchanged binding, so
236
+ * re-running costs nothing and a wiped `.data` heals itself.
237
+ */
238
+ export async function linkDevPersonas(host: SqliteScopeHost, world: BikeShopWorld): Promise<void> {
239
+ const staff = platformActorId.parse(ulid());
240
+ await host.admin.registerIdentityPool(staff, {
241
+ provider: DEV_PROVIDER,
242
+ topology: 'central',
243
+ tenantId: null,
244
+ });
245
+ // `scopeId` on the link is what carries a persona to their own node — Rutger into the
246
+ // other shop entirely, which is why the cross-tenant beat still runs with no persona
247
+ // table anywhere in the server.
248
+ const homes: Record<string, { principal: PrincipalId; tenantId: TenantId; scopeId: ScopeId }> = {
249
+ 'dev|greta': { principal: world.greta, tenantId: world.t1, scopeId: world.s1 },
250
+ 'dev|mans': { principal: world.mans, tenantId: world.t1, scopeId: world.s1 },
251
+ 'dev|lisbeth': { principal: world.lisbeth, tenantId: world.t1, scopeId: world.s1 },
252
+ 'dev|otto': { principal: world.otto, tenantId: world.t1, scopeId: world.s1 },
253
+ 'dev|rutger': { principal: world.rutger, tenantId: world.t2, scopeId: world.s2 },
254
+ };
255
+ for (const persona of PERSONAS) {
256
+ const home = homes[persona.sub];
257
+ if (!home) continue;
258
+ await host.admin.linkIdentity(staff, {
259
+ provider: DEV_PROVIDER,
260
+ externalId: persona.sub,
261
+ principal: home.principal,
262
+ tenantId: home.tenantId,
263
+ scopeId: home.scopeId,
264
+ });
265
+ }
266
+ }
@@ -4,17 +4,38 @@ import { fileURLToPath } from 'node:url';
4
4
  import { serve } from '@hono/node-server';
5
5
  import { Hono } from 'hono';
6
6
  import type { Context } from 'hono';
7
- import { PermissionDenied, type ScopeStub } from '@substrat-run/kernel';
8
- import type { PrincipalId } from '@substrat-run/contracts';
9
- import { buildBikeShopHost, seedBikeShop, type BikeShopWorld } from './seed.js';
7
+ import { HTTPException } from 'hono/http-exception';
8
+ import { platformActorId } from '@substrat-run/contracts';
9
+ import { ulid, type ScopeStub } from '@substrat-run/kernel';
10
+ import { devLogin } from '@substrat-run/dev-issuer';
11
+ import { buildBikeShopHost, linkDevPersonas, seedBikeShop, type BikeShopWorld } from './seed.js';
12
+ import { DEV_PROVIDER } from './personas.js';
10
13
  import { mountApi } from './routes.js';
11
14
 
12
15
  // ============================================================================
13
16
  // The DEV entrypoint. It owns exactly three things — a SQLite host on disk, the
14
- // `x-principal` persona picker, and the port — and then mounts `routes.ts`, the
15
- // same route table `worker.ts` mounts. There is no business logic here and no
16
- // route here either: a route added to this file would exist in dev and 404 in
17
+ // relying-party login, and the port — and then mounts `routes.ts`, the same
18
+ // route table `worker.ts` mounts. There is no business logic here and no route
19
+ // here either: a route added to this file would exist in dev and 404 in
17
20
  // production, which is the one failure this split exists to prevent.
21
+ //
22
+ // ── AUTH ────────────────────────────────────────────────────────────────────
23
+ // An ordinary OpenID Connect round-trip, against whatever `OIDC_ISSUER` names.
24
+ // `pnpm dev` starts `@substrat-run/dev-issuer` on :8879 — a real provider whose
25
+ // only shortcut is that you pick a name instead of typing a password — so the
26
+ // login you exercise locally is the one your users will run, and pointing this
27
+ // at Auth0, Keycloak or your own issuer is a change of configuration, not code.
28
+ //
29
+ // There is deliberately NO dev header here. A header naming the caller is an
30
+ // impersonation bypass; kept for convenience it becomes a second auth path that
31
+ // no deployment runs, and one environment variable away from being live.
32
+ // Impersonation for scripts lives at the issuer instead:
33
+ // curl -XPOST localhost:8879/dev/token -d '{"sub":"dev|greta"}'
34
+ //
35
+ // ── THE VITE PROXY, IF YOU ADD ONE ──────────────────────────────────────────
36
+ // Do not set `changeOrigin` on a proxy in front of this server: the OIDC
37
+ // `redirect_uri` is derived from the forwarded Host header, and rewriting it
38
+ // sends the login callback to the API port instead of back to your app.
18
39
  // ============================================================================
19
40
 
20
41
  const dataDir = join(dirname(fileURLToPath(import.meta.url)), '..', '.data');
@@ -22,34 +43,46 @@ mkdirSync(dataDir, { recursive: true });
22
43
 
23
44
  const host = buildBikeShopHost(dataDir);
24
45
  const world: BikeShopWorld = await seedBikeShop(host, dataDir);
46
+ await linkDevPersonas(host, world);
25
47
 
26
- // The dev cast, keyed by the `x-principal` header value. Every entry is a real
27
- // principal with real tuples — nothing here is a bypass.
28
- const CAST: Record<string, { name: string; principal: PrincipalId }> = {
29
- greta: { name: 'Greta (workshop-admin)', principal: world.greta },
30
- mans: { name: 'Måns (mechanic)', principal: world.mans },
31
- lisbeth: { name: 'Lisbeth (portal customer)', principal: world.lisbeth },
32
- otto: { name: 'Otto (portal customer)', principal: world.otto },
33
- rutger: { name: 'Rutger (other shop — attacker)', principal: world.rutger },
34
- };
35
-
36
- function principalOf(c: Context): PrincipalId {
37
- const who = c.req.header('x-principal') ?? 'greta';
38
- const entry = CAST[who];
39
- if (!entry) throw new PermissionDenied(`unknown principal: ${who}`);
40
- return entry.principal;
41
- }
48
+ // The local platform actor (a stub locally): directory reads are stamped with it.
49
+ const staffActor = platformActorId.parse(ulid());
42
50
 
43
- function stub(c: Context): Promise<ScopeStub> {
44
- return host.getScope(principalOf(c), world.t1, world.s1);
45
- }
51
+ /**
52
+ * The relying party — login, callback, logout, plus `sub` → principal through the
53
+ * identity directory. The persona's tenant and scope come out of the link the seed
54
+ * wrote, which is why signing in as Rutger lands in the OTHER shop and every one of
55
+ * this shop's rows stays out of reach.
56
+ */
57
+ // Both ports are bound in THIS file so they move together: `substrat.devServers` names
58
+ // it for the API and for the issuer alike, and `ISSUER_PORT=… PORT=… pnpm dev` shifts the
59
+ // pair without either end losing track of the other.
60
+ const ISSUER_PORT = Number(process.env.ISSUER_PORT ?? 8879);
61
+ const login = devLogin({
62
+ directory: host.admin,
63
+ actor: staffActor,
64
+ provider: DEV_PROVIDER,
65
+ issuer: process.env.OIDC_ISSUER ?? `http://localhost:${ISSUER_PORT}`,
66
+ });
46
67
 
47
68
  const app = new Hono();
48
69
 
49
- // The persona picker — genuinely dev-only, so it stays out of the shared table.
50
- // Its ABSENCE in the worker is how a client can tell it is talking to a real
51
- // deployment; the worker answers `/api/me` instead.
52
- app.get('/api/cast', (c) => c.json(CAST));
70
+ // Login, callback, logout. Accounts and passwords live at the issuer; this vertical
71
+ // runs no credential store of its own.
72
+ app.on(['GET', 'POST'], '/api/auth/*', (c) => login.handle(c.req.raw));
73
+
74
+ /** Who is signed in, or 401 — the same question `worker.ts` answers. */
75
+ app.get('/api/me', async (c) => {
76
+ const caller = await login.caller(c.req.raw.headers);
77
+ if (!caller) return c.json({ error: 'unauthorized' }, 401);
78
+ return c.json({ principal: caller.principal, display: caller.display });
79
+ });
80
+
81
+ async function stub(c: Context): Promise<ScopeStub> {
82
+ const caller = await login.caller(c.req.raw.headers);
83
+ if (!caller) throw new HTTPException(401, { message: 'unauthorized' });
84
+ return host.getScope(caller.principal, caller.tenantId, caller.scopeId);
85
+ }
53
86
 
54
87
  // Everything else — including `/api/invoke` and the shared error envelope.
55
88
  mountApi(app, stub);
@@ -57,4 +90,4 @@ mountApi(app, stub);
57
90
  const PORT = Number(process.env.PORT ?? 8873);
58
91
  serve({ fetch: app.fetch, port: PORT });
59
92
  console.log(`Bike-shop API on http://localhost:${PORT} — data in ${dataDir}`);
60
- console.log(`Pick a principal with the "x-principal" header: ${Object.keys(CAST).join(', ')}`);
93
+ console.log(`Sign in at http://localhost:${PORT}/api/auth/login — issuer: ${login.issuer}`);
@@ -12,20 +12,26 @@
12
12
  * you never author wrangler config.
13
13
  *
14
14
  * ── THE AUTH SEAM ────────────────────────────────────────────────────────────
15
- * This starter resolves a caller ONLY through the `x-principal` dev header,
16
- * gated on ALLOW_DEV_HEADER — an impersonation bypass by design, for local
17
- * `wrangler dev` and smoke tests. In production the gate is off and every
18
- * /api/* call is 401 until you wire real auth into `authenticatedPrincipal`
19
- * below (a session/bearer verifier that maps a login → PrincipalId — see
20
- * @substrat-run/vertical-auth, the platform's pluggable AuthProvider +
21
- * per-tenant identity DO, for the intended shape). Deploying with the dev
22
- * header enabled is a cross-tenant hole with a UI. ──────────────────────────
15
+ * This starter ships NO auth in the worker: every /api/* call is 401 until you
16
+ * wire `authenticatedPrincipal` below. That is deliberate and it is honest —
17
+ * there is nothing here that resolves a caller, in any environment, so there is
18
+ * nothing to accidentally deploy.
19
+ *
20
+ * It used to resolve a caller through an `x-principal` header gated on
21
+ * ALLOW_DEV_HEADER. That is an impersonation bypass — a cross-tenant hole with a
22
+ * UI, one environment variable from being live — and it is gone. `src/server.ts`
23
+ * shows the shape you want instead: @substrat-run/vertical-auth's
24
+ * `oidcRpAuthProvider` verifies the request against your issuer and hands you a
25
+ * subject; an identity directory maps that subject to a PrincipalId. The dev
26
+ * server can use `host.admin` as that directory. A hosted worker needs a durable
27
+ * one — @substrat-run/vertical-auth's per-tenant `IdentityDO` is the platform's,
28
+ * and it also gives you the owner-claim and invite flows a new install needs.
29
+ * ───────────────────────────────────────────────────────────────────────────
23
30
  */
24
31
  import { Hono } from 'hono';
25
32
  import type { Context } from 'hono';
26
33
  import { HTTPException } from 'hono/http-exception';
27
34
  import {
28
- principalId,
29
35
  resolveScopedEnvSpec,
30
36
  scopeId,
31
37
  tenantId,
@@ -84,8 +90,12 @@ interface Node {
84
90
  }
85
91
 
86
92
  // A fixed dev node (valid ULIDs) — ONLY the fallback for local `wrangler dev`,
87
- // where there is no router to assert one; gated on ALLOW_DEV_HEADER (never set
88
- // in prod).
93
+ // where there is no router to assert one; gated on ALLOW_DEV_NODE (never set in
94
+ // prod).
95
+ //
96
+ // This is an ADDRESS, not an identity: it says which instance an un-routed local
97
+ // request belongs to, and grants nobody anything. Keeping the two separate is why
98
+ // it survived the removal of the dev header, which named the CALLER.
89
99
  const DEV_NODE: Node = {
90
100
  tenantId: tenantId.parse('01JZ00000000000000000DEV01'),
91
101
  scopeId: scopeId.parse('01JZ00000000000000000DEV02'),
@@ -98,8 +108,9 @@ interface Env {
98
108
  SWEEPER: DurableObjectNamespace;
99
109
  /** Per-instance config delivered by the platform (`/internal/configure`). */
100
110
  CONFIG: DurableObjectNamespace;
101
- /** Local dev only: when 'true', trust the `x-principal` header. NEVER set in prod. */
102
- ALLOW_DEV_HEADER?: string;
111
+ /** Local `wrangler dev` only: when 'true', fall back to DEV_NODE if no router
112
+ * asserted a node. Addresses an instance; authenticates nobody. */
113
+ ALLOW_DEV_NODE?: string;
103
114
  /** Shared secret the router presents (how this worker knows the asserted node is real). */
104
115
  ROUTER_SECRET?: string;
105
116
  /** Shared secret the platform presents on /internal/* calls. */
@@ -116,7 +127,7 @@ function nodeFor(req: Request, env: Env): Node {
116
127
  throw e;
117
128
  }
118
129
  if (routed) return { tenantId: routed.tenantId, scopeId: routed.scopeId };
119
- if (env.ALLOW_DEV_HEADER === 'true') return DEV_NODE;
130
+ if (env.ALLOW_DEV_NODE === 'true') return DEV_NODE;
120
131
  throw new HTTPException(503, { message: 'no scope was asserted for this request (missing router assertion)' });
121
132
  }
122
133
 
@@ -170,16 +181,23 @@ async function instanceConfig(env: Env, node: Node) {
170
181
  }
171
182
 
172
183
  /**
173
- * THE AUTH SEAM (see the header comment): resolve the caller to a PrincipalId,
174
- * or null for nobody. Replace the body with a real session/bearer verifier
175
- * before exposing this worker to users — the dev header is dev-only.
184
+ * THE AUTH SEAM (see the header comment): resolve the caller to a PrincipalId, or
185
+ * null for nobody. Two steps, and this starter ships neither:
186
+ *
187
+ * 1. Verify the request → a subject. `instanceConfig` already reads the issuer
188
+ * the dashboard delivered as `substrat:auth`, so this is
189
+ * `oidcRpAuthProvider({ issuer, clientId, clientSecret, sessionSecret }).resolve(...)`
190
+ * — and you mount the same provider's `handle` on `/api/auth/*` for the login
191
+ * round-trip. `src/server.ts` does exactly this against the local dev issuer.
192
+ * 2. Map that subject → a PrincipalId, per scope. This needs a durable store the
193
+ * worker owns; @substrat-run/vertical-auth's `IdentityDO` is one, and carries
194
+ * the owner-claim (first sign-in takes the seat) and invite flows with it.
195
+ *
196
+ * Returning null unconditionally is the safe default, not an oversight: a starter
197
+ * that guessed here would be a starter that let the wrong person in.
176
198
  */
177
- async function authenticatedPrincipal(req: Request, env: Env): Promise<PrincipalId | null> {
178
- if (env.ALLOW_DEV_HEADER === 'true') {
179
- const parsed = principalId.safeParse(req.headers.get('x-principal') ?? '');
180
- if (parsed.success) return parsed.data;
181
- }
182
- return null; // ← wire real auth here
199
+ async function authenticatedPrincipal(_req: Request, _env: Env): Promise<PrincipalId | null> {
200
+ return null; // ← wire real auth here (see the two steps above)
183
201
  }
184
202
 
185
203
  /** Resolve caller + routed node → a scope stub. 401 if nobody. */
@@ -214,8 +232,8 @@ async function unauthorizedReason(env: Env, node: Node): Promise<string> {
214
232
  const app = new Hono<{ Bindings: Env }>();
215
233
 
216
234
  // Who am I, and what instance am I on — resolves the caller without invoking
217
- // anything. Auth-shaped and host-specific, so it stays OUT of the shared table:
218
- // the dev server answers `/api/cast` instead, and a client can tell the two apart.
235
+ // anything. Auth-shaped and host-specific, so it stays OUT of the shared table;
236
+ // `server.ts` answers the same question from its own login.
219
237
  app.get('/api/me', async (c) => {
220
238
  const node = nodeFor(c.req.raw, c.env);
221
239
  const principal = await authenticatedPrincipal(c.req.raw, c.env);