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 +9 -1
- package/index.js +12 -39
- package/package.json +2 -1
- package/project-files.js +51 -0
- package/template/.claude/launch.json +10 -0
- package/template/.substrat/playbook.md +87 -29
- package/template/AGENTS.md +10 -3
- package/template/src/personas.ts +35 -0
- package/template/src/routes.ts +17 -14
- package/template/src/seed.ts +43 -0
- package/template/src/server.ts +63 -30
- package/template/src/worker.ts +43 -25
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
|
-
/**
|
|
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.
|
|
39
|
-
const ENGINE_WORKORDER = '^0.
|
|
40
|
-
const ENGINE_INVOICING = '^0.9.
|
|
41
|
-
const BOUNDARY_LINT = '^0.
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/project-files.js
ADDED
|
@@ -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
|
+
`;
|
|
@@ -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
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
**
|
|
468
|
-
`
|
|
469
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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:
|
|
515
|
-
|
|
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
|
|
package/template/AGENTS.md
CHANGED
|
@@ -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** —
|
|
70
|
-
|
|
71
|
-
|
|
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';
|
package/template/src/routes.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Context, Hono } from 'hono';
|
|
2
|
-
import {
|
|
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,
|
|
9
|
-
* dev
|
|
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: `
|
|
32
|
-
* (@substrat-run/vertical-host) is the same
|
|
33
|
-
* permission denial is 403, a missing thing 404, a
|
|
34
|
-
* fault 502 — identically on both hosts. "No opinion"
|
|
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
|
|
39
|
-
* here is what gives `server.ts`, which mounts no platform surface, the same
|
|
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
|
package/template/src/seed.ts
CHANGED
|
@@ -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
|
+
}
|
package/template/src/server.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
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(`
|
|
93
|
+
console.log(`Sign in at http://localhost:${PORT}/api/auth/login — issuer: ${login.issuer}`);
|
package/template/src/worker.ts
CHANGED
|
@@ -12,20 +12,26 @@
|
|
|
12
12
|
* you never author wrangler config.
|
|
13
13
|
*
|
|
14
14
|
* ── THE AUTH SEAM ────────────────────────────────────────────────────────────
|
|
15
|
-
* This starter
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
88
|
-
//
|
|
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',
|
|
102
|
-
|
|
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.
|
|
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
|
-
*
|
|
175
|
-
*
|
|
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(
|
|
178
|
-
|
|
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
|
-
//
|
|
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);
|