create-substrat 0.7.1 → 0.7.3
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 +72 -29
- package/template/AGENTS.md +10 -3
- package/template/src/module.ts +21 -8
- package/template/src/personas.ts +35 -0
- package/template/src/routes.ts +2 -2
- package/template/src/seed.ts +43 -0
- package/template/src/server.ts +63 -30
- package/template/src/worker.ts +43 -25
- package/template/test/scenario.test.ts +18 -11
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.8.
|
|
40
|
-
const ENGINE_INVOICING = '^0.9.
|
|
41
|
-
const BOUNDARY_LINT = '^0.1.
|
|
39
|
+
const SUBSTRAT = '^0.86.0';
|
|
40
|
+
const ENGINE_WORKORDER = '^0.8.2';
|
|
41
|
+
const ENGINE_INVOICING = '^0.9.2';
|
|
42
|
+
const BOUNDARY_LINT = '^0.1.2';
|
|
43
|
+
const DEV_ISSUER = '^0.1.0';
|
|
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.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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
|
|
@@ -407,13 +409,54 @@ pnpm dev # API on :8871 (PORT=… WEB_PORT=… to move i
|
|
|
407
409
|
```
|
|
408
410
|
|
|
409
411
|
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
|
-
|
|
412
|
+
test never touches `server.ts`, its routes, or the login, so it can be green while the app
|
|
413
|
+
is broken. Drive the real flow with curl (create → assign → start → report → complete) as
|
|
414
|
+
two personas, so a denial lands as a denial. Get a session without a browser by minting a
|
|
415
|
+
token at the issuer — `curl -XPOST localhost:8879/dev/token -d '{"sub":"dev|greta"}'` — and
|
|
416
|
+
sending it as `Authorization: Bearer …`. The moment the attack fails is the demo; make sure
|
|
417
|
+
the user sees it.
|
|
418
|
+
|
|
419
|
+
If they want a UI, scaffold a minimal Vite + React app under `app/` with typed wrappers over
|
|
420
|
+
the routes and a sign-in button that redirects to `/api/auth/login`. Ask first — it roughly
|
|
421
|
+
doubles the work.
|
|
422
|
+
|
|
423
|
+
**The same change that creates `app/` declares it** — before a single component is written.
|
|
424
|
+
A UI ships as NATIVE assets: `substrat push` runs the declared build, hashes the output and
|
|
425
|
+
uploads it to the runtime's own asset store, served from the edge without invoking the
|
|
426
|
+
worker. Undeclared, the directory is never built, never uploaded, and the deployed vertical
|
|
427
|
+
answers `/api/*` and 404s on `/` — a deploy that looks entirely successful, and a failure no
|
|
428
|
+
gate before deploy can see (`pnpm test` never touches `server.ts`, boundary-lint has no
|
|
429
|
+
opinion about static files, and a vertical with no UI must legitimately declare no assets).
|
|
414
430
|
|
|
415
|
-
|
|
416
|
-
|
|
431
|
+
```jsonc
|
|
432
|
+
"substrat": {
|
|
433
|
+
"runtimeNeeds": {
|
|
434
|
+
"entry": "src/worker.ts",
|
|
435
|
+
"build": "npm --prefix app install && npm --prefix app run build",
|
|
436
|
+
"assets": {
|
|
437
|
+
"directory": "app/dist",
|
|
438
|
+
"notFoundHandling": "single-page-application", // deep client routes → index.html
|
|
439
|
+
"runWorkerFirst": ["/api/*", "/internal/*"] // only these reach the worker
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`build` runs before assets are collected, so the directory may be pure build output. Never
|
|
446
|
+
base64-inline a built `app/dist` into a generated worker module: it costs ~+33 % script size
|
|
447
|
+
and a worker invocation per image.
|
|
448
|
+
|
|
449
|
+
Two more that each fail silently:
|
|
450
|
+
|
|
451
|
+
- **`runWorkerFirst` must list every worker-owned prefix.** With
|
|
452
|
+
`notFoundHandling: "single-page-application"`, a missing `/api/*` entry answers every API
|
|
453
|
+
call with `index.html` — the app then reports parse errors instead of denials.
|
|
454
|
+
- **The app calls its own origin** (`fetch('/api' + path)`), never a baked base URL. The Vite
|
|
455
|
+
`proxy` block is a dev-only convenience; `VITE_API_URL` or `localhost:8871` works on the
|
|
456
|
+
author's machine and reaches nothing from a phone.
|
|
457
|
+
|
|
458
|
+
`substrat push` refuses an `app/` that nothing would serve, so this cannot reach a hostname
|
|
459
|
+
undeclared — but the refusal is a backstop, not the instruction. Declare it here.
|
|
417
460
|
|
|
418
461
|
---
|
|
419
462
|
|
|
@@ -464,27 +507,26 @@ authenticated CLI, and the author never holds a Cloudflare token:
|
|
|
464
507
|
- `substrat hostnames bind <slug> --surface <s> [--domain <d>]` — mint a live hostname, or
|
|
465
508
|
record a custom domain pending DNS validation (`substrat hostnames verify`).
|
|
466
509
|
|
|
467
|
-
**
|
|
468
|
-
`
|
|
469
|
-
|
|
510
|
+
**If this vertical has a UI, its `runtimeNeeds.assets` block was written back in Step 7**,
|
|
511
|
+
when `app/` was created — that is the one description of it, and it is not repeated here so
|
|
512
|
+
the two cannot drift. If you are deploying a vertical scaffolded before that rule existed,
|
|
513
|
+
go read it now: an undeclared `app/` deploys clean and 404s at its own hostname. `substrat
|
|
514
|
+
push` refuses that push, with the recipe.
|
|
470
515
|
|
|
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
|
-
}
|
|
516
|
+
**A deploy is not done until the URL serves the app.** Two requests, and show the user both:
|
|
517
|
+
|
|
518
|
+
```sh
|
|
519
|
+
curl -si https://<hostname>/ | head -3 # expect 200 + content-type: text/html
|
|
520
|
+
curl -si https://<hostname>/api/me | head -3 # expect the worker, not index.html
|
|
483
521
|
```
|
|
484
522
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
523
|
+
Triage a 404 in one request instead of an hour — the two layers fail differently:
|
|
524
|
+
|
|
525
|
+
| What you see | Where it broke |
|
|
526
|
+
|---|---|
|
|
527
|
+
| `404` with a Cloudflare body, `/internal/*` answers `403` | the worker ran; assets are undeclared or unbuilt |
|
|
528
|
+
| `404` before any worker header | the hostname is not bound to this surface |
|
|
529
|
+
| `/api/*` returns HTML | `runWorkerFirst` is missing that prefix |
|
|
488
530
|
|
|
489
531
|
**Let changesets own the version, and pass it to push explicitly** — the default bump walks
|
|
490
532
|
the registry forward on its own, so `package.json` and the registry drift apart within a
|
|
@@ -511,8 +553,9 @@ the version you just replaced. Changesets needs a git repo with a commit on the
|
|
|
511
553
|
Updates deploy **in place** from one stable script — data carries forward, migrations run
|
|
512
554
|
against prod data, backout is a time-boxed PITR rewind.
|
|
513
555
|
|
|
514
|
-
Before deploying:
|
|
515
|
-
|
|
556
|
+
Before deploying: `worker.ts`'s auth seam **must** be wired — it resolves nobody as it
|
|
557
|
+
ships, so every `/api/*` call is 401 until you do. Never substitute a header that names the
|
|
558
|
+
caller: that is a cross-tenant hole with a UI, which is why the starter no longer has one.
|
|
516
559
|
|
|
517
560
|
---
|
|
518
561
|
|
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
|
package/template/src/module.ts
CHANGED
|
@@ -3,15 +3,18 @@ import {
|
|
|
3
3
|
compareDecimal,
|
|
4
4
|
moneyOf,
|
|
5
5
|
mulMoney,
|
|
6
|
+
pageVisible,
|
|
6
7
|
z,
|
|
7
8
|
type EntityRef,
|
|
8
9
|
type Money,
|
|
10
|
+
type Page,
|
|
9
11
|
} from '@substrat-run/contracts';
|
|
10
12
|
import {
|
|
11
13
|
assertAllowed,
|
|
12
14
|
ulid,
|
|
13
15
|
type ModuleRegistration,
|
|
14
16
|
type OperationHandler,
|
|
17
|
+
type PageParams,
|
|
15
18
|
} from '@substrat-run/kernel';
|
|
16
19
|
import {
|
|
17
20
|
closeWorkOrder,
|
|
@@ -244,15 +247,25 @@ const closeRepairOp: OperationHandler<{ orderId: string }, WorkOrder> = async (c
|
|
|
244
247
|
* PER-ENTITY check per repair. A portal customer holds an entity-narrowed grant
|
|
245
248
|
* on their own customer record, so the walk workorder → bike → customer lets
|
|
246
249
|
* them through for their own repairs and no one else's.
|
|
250
|
+
*
|
|
251
|
+
* Paged by OVER-FETCHING, which is what a permission-filtered walk needs: a page
|
|
252
|
+
* of 20 rows read from the table can leave 3 standing after the proof walk, so the
|
|
253
|
+
* fetch size and the page size are not the same number and cannot be made the same
|
|
254
|
+
* number. `pageVisible` does the over-fetch and advances the cursor by the last row
|
|
255
|
+
* EXAMINED — advancing by the last row RETURNED would re-examine every rejected row
|
|
256
|
+
* on the next request, and a page the walk rejects entirely would never advance at
|
|
257
|
+
* all. So a SHORT page does not end this walk; only a null `nextCursor` does.
|
|
247
258
|
*/
|
|
248
|
-
const portalRepairsOp: OperationHandler<undefined, WorkOrder
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
259
|
+
const portalRepairsOp: OperationHandler<PageParams | undefined, Page<WorkOrder>> = async (
|
|
260
|
+
ctx,
|
|
261
|
+
input,
|
|
262
|
+
) =>
|
|
263
|
+
pageVisible(
|
|
264
|
+
(p) => listOrders(ctx, { ...input, ...p }),
|
|
265
|
+
input,
|
|
266
|
+
async (order) =>
|
|
267
|
+
(await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id })).allowed,
|
|
268
|
+
);
|
|
256
269
|
|
|
257
270
|
const timelineInput = z.object({
|
|
258
271
|
entityType: z.string().min(1),
|
|
@@ -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
|
@@ -5,8 +5,8 @@ import type { ScopeStub } from '@substrat-run/kernel';
|
|
|
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.
|
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);
|
|
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
6
|
-
import { addMoney, moneyOf, mulMoney } from '@substrat-run/contracts';
|
|
6
|
+
import { addMoney, moneyOf, mulMoney, type Page } from '@substrat-run/contracts';
|
|
7
7
|
import type { ScopeStub } from '@substrat-run/kernel';
|
|
8
8
|
import type { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
|
|
9
9
|
import type { WorkOrder, BillableLine } from '@substrat-run/engine-workorder';
|
|
@@ -105,7 +105,8 @@ describe('bike-shop scenario', () => {
|
|
|
105
105
|
lisbeth.invoke('workorder/report-time', { orderId: repairId, hours: '1' }),
|
|
106
106
|
).rejects.toThrow(/permission denied/);
|
|
107
107
|
// …but she CAN see her own repair through the portal walk.
|
|
108
|
-
await
|
|
108
|
+
const lisbethSees = await lisbeth.invoke<Page<WorkOrder>>('shop/portal-repairs');
|
|
109
|
+
expect(lisbethSees.entries).toHaveLength(1);
|
|
109
110
|
|
|
110
111
|
// The cross-tenant attacker: claiming t1's scope under his OWN tenant fails
|
|
111
112
|
// the (tenant, scope) pair check…
|
|
@@ -120,7 +121,8 @@ describe('bike-shop scenario', () => {
|
|
|
120
121
|
await expect(rutger.invoke('invoicing/list')).rejects.toThrow(/permission denied/);
|
|
121
122
|
// …the control: the per-entity portal walk resolves for him too, and returns
|
|
122
123
|
// exactly nothing — an open door onto an empty room, not a denial.
|
|
123
|
-
await
|
|
124
|
+
const rutgerSees = await rutger.invoke<Page<WorkOrder>>('shop/portal-repairs');
|
|
125
|
+
expect(rutgerSees.entries).toEqual([]);
|
|
124
126
|
});
|
|
125
127
|
|
|
126
128
|
it('5. priced completion: the half-hour minimum bills, internal dropped, math exact', async () => {
|
|
@@ -153,9 +155,9 @@ describe('bike-shop scenario', () => {
|
|
|
153
155
|
});
|
|
154
156
|
|
|
155
157
|
it('6. star topology: the invoicing engine consumed workorder.completed', async () => {
|
|
156
|
-
const underlag = await greta.invoke<
|
|
157
|
-
|
|
158
|
-
);
|
|
158
|
+
const { entries: underlag } = await greta.invoke<
|
|
159
|
+
Page<{ id: string; status: string; total: string }>
|
|
160
|
+
>('invoicing/list');
|
|
159
161
|
expect(underlag).toHaveLength(1);
|
|
160
162
|
expect(underlag[0]!.status).toBe('open');
|
|
161
163
|
expect(underlag[0]!.total).toBe('336.5');
|
|
@@ -178,9 +180,10 @@ describe('bike-shop scenario', () => {
|
|
|
178
180
|
const lisbeth = await host.getScope(w.lisbeth, w.t1, w.s1);
|
|
179
181
|
const otto = await host.getScope(w.otto, w.t1, w.s1);
|
|
180
182
|
|
|
181
|
-
const hers = await lisbeth.invoke<WorkOrder
|
|
182
|
-
expect(hers.map((o) => o.id)).toEqual([repairId]);
|
|
183
|
-
await
|
|
183
|
+
const hers = await lisbeth.invoke<Page<WorkOrder>>('shop/portal-repairs');
|
|
184
|
+
expect(hers.entries.map((o) => o.id)).toEqual([repairId]);
|
|
185
|
+
const ottos = await otto.invoke<Page<WorkOrder>>('shop/portal-repairs');
|
|
186
|
+
expect(ottos.entries).toEqual([]);
|
|
184
187
|
|
|
185
188
|
// Lisbeth reads her repair's timeline via the same entity walk…
|
|
186
189
|
await expect(
|
|
@@ -194,7 +197,9 @@ describe('bike-shop scenario', () => {
|
|
|
194
197
|
});
|
|
195
198
|
|
|
196
199
|
it('8. export makes the underlag immutable; the next completion opens a new one', async () => {
|
|
197
|
-
const
|
|
200
|
+
const {
|
|
201
|
+
entries: [underlag],
|
|
202
|
+
} = await greta.invoke<Page<{ id: string }>>('invoicing/list');
|
|
198
203
|
await greta.invoke('invoicing/export', { underlagId: underlag!.id });
|
|
199
204
|
await expect(greta.invoke('invoicing/export', { underlagId: underlag!.id })).rejects.toThrow(
|
|
200
205
|
/immutable/,
|
|
@@ -214,7 +219,9 @@ describe('bike-shop scenario', () => {
|
|
|
214
219
|
});
|
|
215
220
|
await greta.invoke('shop/complete-repair', { orderId: repair2.id });
|
|
216
221
|
|
|
217
|
-
const all = await greta.invoke<{ status: string; total: string }
|
|
222
|
+
const { entries: all } = await greta.invoke<Page<{ status: string; total: string }>>(
|
|
223
|
+
'invoicing/list',
|
|
224
|
+
);
|
|
218
225
|
expect(all).toHaveLength(2);
|
|
219
226
|
expect(all.filter((u) => u.status === 'open')).toHaveLength(1);
|
|
220
227
|
expect(all.filter((u) => u.status === 'exported')).toHaveLength(1);
|