create-substrat 0.1.0 → 0.2.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/index.js CHANGED
@@ -20,12 +20,14 @@ const HERE = dirname(fileURLToPath(import.meta.url));
20
20
  const TEMPLATE = join(HERE, 'template');
21
21
 
22
22
  // Published today; Substrat is 0.x, so these are caret ranges on the current minor.
23
- const SUBSTRAT = '^0.29.0';
23
+ // 0.40.0 is the release that ships `defineScopeSweeperDO` (#461), which the
24
+ // template's worker imports — this pin and that adapter release move together.
25
+ const SUBSTRAT = '^0.40.0';
24
26
  // Engines version on their own line (0.3.x), independent of the kernel/contracts line.
25
- const ENGINES = '^0.3.27';
27
+ const ENGINES = '^0.3.37';
26
28
  const BOUNDARY_LINT = '^0.0.5';
27
29
 
28
- const DOCS = 'https://substrat.ahlstrand.es';
30
+ const DOCS = 'https://substrat.net';
29
31
 
30
32
  function fail(message) {
31
33
  process.stderr.write(`\n create-substrat: ${message}\n\n`);
@@ -64,17 +66,33 @@ function packageJson(name) {
64
66
  version: '0.0.0',
65
67
  private: true,
66
68
  type: 'module',
69
+ // What `substrat push` reads: the permission surface (the registry the
70
+ // promotion checkpoint diffs) and the runtime needs the deploy config is
71
+ // derived from — you never author wrangler config (src/worker.ts is the
72
+ // entry; ScopeDO is the store it exports).
73
+ substrat: {
74
+ permissions: 'src/provision.ts',
75
+ runtimeNeeds: {
76
+ entry: 'src/worker.ts',
77
+ stores: [
78
+ { binding: 'SCOPE', class: 'ScopeDO' },
79
+ // The scope-local sweep singleton — the deployment's own timer (#461).
80
+ { binding: 'SWEEPER', class: 'SweeperDO' },
81
+ ],
82
+ },
83
+ },
67
84
  scripts: {
68
85
  dev: 'tsx watch src/server.ts',
69
86
  server: 'tsx src/server.ts',
70
87
  test: 'vitest run',
71
- typecheck: 'tsc --noEmit',
88
+ typecheck: 'tsc --noEmit && tsc -p tsconfig.worker.json --noEmit',
72
89
  'lint:boundaries': 'substrat-boundary-lint',
73
90
  },
74
91
  dependencies: {
75
92
  '@substrat-run/kernel': SUBSTRAT,
76
93
  '@substrat-run/contracts': SUBSTRAT,
77
94
  '@substrat-run/adapter-sqlite': SUBSTRAT,
95
+ '@substrat-run/adapter-cloudflare': SUBSTRAT,
78
96
  '@substrat-run/engine-workorder': ENGINES,
79
97
  '@substrat-run/engine-invoicing': ENGINES,
80
98
  hono: '^4.6.0',
@@ -83,7 +101,9 @@ function packageJson(name) {
83
101
  },
84
102
  devDependencies: {
85
103
  '@substrat-run/boundary-lint': BOUNDARY_LINT,
104
+ '@cloudflare/workers-types': '^4.20250109.0',
86
105
  '@types/better-sqlite3': '^7.6.0',
106
+ '@types/node': '^22.0.0',
87
107
  concurrently: '^9.0.0',
88
108
  tsx: '^4.19.0',
89
109
  typescript: '^5.6.0',
@@ -112,6 +132,9 @@ const TSCONFIG = `${JSON.stringify(
112
132
  types: ['node'],
113
133
  },
114
134
  include: ['src', 'test'],
135
+ // The worker compiles against workers-types under its own config
136
+ // (tsconfig.worker.json) — the node config must not see it.
137
+ exclude: ['src/worker.ts'],
115
138
  },
116
139
  null,
117
140
  2,
@@ -167,9 +190,13 @@ pnpm typecheck
167
190
 
168
191
  function main() {
169
192
  const target = process.argv[2];
170
- if (!target || target === '-h' || target === '--help') {
193
+ if (target === '-h' || target === '--help') {
194
+ usage();
195
+ process.exit(0);
196
+ }
197
+ if (!target) {
171
198
  usage();
172
- process.exit(target ? 0 : 1);
199
+ fail('a target directory is required — e.g. `npm create substrat my-app` (or `.` for here).');
173
200
  }
174
201
 
175
202
  const dest = resolve(target);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.1.0",
4
- "description": "Scaffold a Substrat vertical — `npm create substrat`. Placeholder reserving the entry point; the initializer is not released yet.",
3
+ "version": "0.2.0",
4
+ "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -1,21 +1,33 @@
1
1
  # Playbook — build a vertical on Substrat
2
2
 
3
3
  The always-on rules live in [`AGENTS.md`](../AGENTS.md); read them first. This playbook is
4
- the **flow**: interview the user, tell them honestly how much of their app already exists,
5
- then build and run the part that doesn't. Read the whole thing before starting — the
6
- checkpoint in Step 6 is a hard stop.
4
+ the **flow**: interview the user, tell them honestly how much of their app already exists, and
5
+ **land a design document they can review and approve before a line of code is written** — then
6
+ reshape the reference into it. Read the whole thing before starting — both the design gate
7
+ (Step 4) and the checkpoints (Step 7) are hard stops.
8
+
9
+ **The target is a reviewed design, not running code.** Steps 1–2 learn the domain and map it
10
+ onto what already exists; Step 3 writes a checked-in `DESIGN.md` in the user's own vocabulary;
11
+ Step 4 is a **hard stop** where the user reads and approves it. Only then does Step 5 reshape
12
+ the reference into their domain. The design gate (Step 4) is *upstream* of the two code
13
+ checkpoints (Step 7) — a user with zero Substrat knowledge gets to say "yes, that's the app I
14
+ want" before implementation, not after.
7
15
 
8
16
  This project ships with a small **working reference vertical** — a bike-repair shop on
9
- `engine-workorder` + `engine-invoicing`, green out of the box (`npm test`). It is your
10
- worked example and your starting point: you **reshape** it into the user's domain rather
11
- than building from an empty directory. Work in the project root.
17
+ `engine-workorder` + `engine-invoicing`, green out of the box (`npm test`). It is your worked
18
+ example and your build starting point: once the design is approved you **reshape** it into the
19
+ user's domain rather than building from an empty directory. Work in the project root.
12
20
 
13
21
  ---
14
22
 
15
23
  ## Step 1 — Interview
16
24
 
17
- Ask, don't assume. **Three to five questions, conversational, one message.** You are
18
- learning the *shape* of the domain, not writing a spec.
25
+ Ask, don't assume. **Three to five questions, conversational, one message.** You are learning
26
+ the *shape* of the domain — the answers become the design document (Step 3), so listen for
27
+ vocabulary, the cast, and who must be denied what, not just features. **Adapt depth to the
28
+ user**: someone who already knows their domain cold needs fewer, sharper questions; someone
29
+ thinking out loud needs you to draw the shape out. One flow, not branching tracks — read the
30
+ room and dial the teaching up or down.
19
31
 
20
32
  1. **What are you building, and who uses it?** (the firm, the cast)
21
33
  2. **What's the thing that moves through the system?** A job, a repair, an inspection, an
@@ -34,8 +46,10 @@ detail, skip to Step 2 and confirm your reading of it instead of re-asking.
34
46
  ## Step 2 — The coverage map
35
47
 
36
48
  **This is the most valuable thing you do, and the easiest to get wrong by being
37
- flattering.** Tell the user what already exists and what they are actually signing up to
38
- build. Be specific and honest.
49
+ flattering.** Work out what already exists and what the user is actually signing up to build.
50
+ Be specific and honest. This analysis is the analytical core of the design document (Step 3) —
51
+ sections 3 ("what already exists vs. what's yours") and 4 ("who is denied what") are this map,
52
+ written down.
39
53
 
40
54
  **First, list the engines that actually exist. Do not trust any hard-coded list:**
41
55
 
@@ -89,10 +103,12 @@ Imported directly; their in-scope functions run in **your** transaction. Read ea
89
103
  **No import.** You emit; they consume. This is the star topology.
90
104
 
91
105
  - **`engine-invoicing`** — invoice basis and lines, immutable after export. Consumes
92
- `workorder.completed` and `commerce.order-placed`, so a vertical that imports zero engines
93
- still gets invoicing by emitting an event. Its consumer find-or-creates the customer's
94
- *open* basis and appends. It has **no tax/VAT concept** — say so before an EU user
95
- discovers it.
106
+ `workorder.completed`, `commerce.order-placed` **and** `timesheet.period-closed` (a
107
+ closed/approved period of reported time — `closeId`/`customer`/`period`/`billable`/
108
+ `total`, deduped on `closeId`), so an e-commerce or time-reporting vertical that imports
109
+ zero engines still gets invoicing by emitting an event. Its consumer find-or-creates the
110
+ customer's *open* basis and appends. It has **no tax/VAT concept** — say so before an EU
111
+ user discovers it.
96
112
 
97
113
  ### Tier 2b — connectors, for anything off-box
98
114
 
@@ -128,25 +144,90 @@ stop. Do not scaffold.
128
144
 
129
145
  ---
130
146
 
131
- ## Step 3 — Decisions
147
+ ## Step 3 — Write the design document
132
148
 
133
- Short. Recommend a default and move.
149
+ **This is the deliverable.** Everything before now was learning; this is where it lands
150
+ somewhere the user can hold. Write a **checked-in `DESIGN.md`** in the project root, in the
151
+ user's own vocabulary — no Substrat internals, no decision refs, no cross-references to
152
+ platform docs. Someone who has never heard of Substrat must be able to read it and recognise
153
+ their own business.
134
154
 
135
- - **Auth.** Local dev uses an `x-principal` header — a dev seam, not a login. Offer to wire
136
- a real login now if they want it; otherwise default to the dev header and say it **must**
137
- be replaced before anything real. Real auth gates *exposing* the app, not *building* it.
138
- - **The cast.** Confirm the personas and their roles (e.g. `office-admin`, `technician`,
139
- `portal-customer`). Roles are the user's vocabulary — name them for the persona.
140
- - **Two tenants, always.** Seed a second tenant that exists to be attacked. This is how the
141
- isolation gets proven rather than claimed.
155
+ **Top line, verbatim** — the house marker for a pre-code design:
156
+
157
+ ```
158
+ Status: draft v0.1 · Last updated: <date> · For review before any code
159
+ ```
160
+
161
+ **The template** — the coverage map (Step 2) is sections 3–4, already done; the rest is the
162
+ interview written down. Two sections deliberately *preview* the code checkpoints of Step 7 in
163
+ plain language, so nothing there is a surprise:
164
+
165
+ 1. **What we're building & who uses it** — the firm and the cast, one paragraph.
166
+ 2. **The thing that moves through the system** — the core noun and its lifecycle
167
+ (the states it passes through, and which transitions must not be skippable).
168
+ 3. **What already exists vs. what's yours** — the coverage map as tiers: the kernel
169
+ (free), the engines you compose, the connectors, and the Tier-3 vocabulary/pricing/
170
+ screens that are yours. If it's a **bad fit, this is where the honest no lands** — say
171
+ so and stop; do not write the rest.
172
+ 4. **Who is denied what** — the load-bearing section, and a plain-language *preview of the
173
+ permission diff*: each role and what it can and cannot see. Make two answers impossible
174
+ to miss — **who can see the money, and who can see other customers' data.**
175
+ 5. **Money & sign-off** — invoice / quote / receipt / none; anything gated on a signature
176
+ or a check before a step can happen.
177
+ 6. **The cast, roles, and tenancy** — the named roles per persona (roles are the user's
178
+ vocabulary — name them for the persona: `workshop-admin`, not `role_1`). **Two tenants,
179
+ always** — the second exists to be attacked, which is how isolation gets proven rather
180
+ than claimed.
181
+ 7. **The data we'll store** — the vertical's own tables and fields in plain terms. This
182
+ *previews the migration diff*; migrations are **append-only forever after first ship**,
183
+ so this is the cheap moment to get the shape right. **Every human-readable string the
184
+ design promises on an output artifact needs a named source here** — if §8 shows an
185
+ invoice line saying "Konsulttid Anna", some table in §7 must own that name, because
186
+ principals are ULIDs. A promised name with no source table is a missing table,
187
+ discovered at build time instead of in this review.
188
+ 8. **The scenario the test will replay** — the happy path plus the denials that prove
189
+ isolation (wrong role denied, customer A sees theirs and customer B sees nothing, a
190
+ cross-tenant attacker gets nothing).
191
+ 9. **Open decisions** — each with a **recommended default**, so the user chooses rather
192
+ than specifies:
193
+ - **Auth.** Local dev uses an `x-principal` header — a dev seam, not a login. Default to
194
+ it and note it must be replaced before anything real; offer to wire a real login (the
195
+ bike-shop reference shows the Better Auth pattern) now if they want it. Real auth gates
196
+ *exposing* the app, not *building* it.
197
+ - **Deploy or stay local.** Local-first is a legitimate endpoint; default to it.
198
+ 10. **Out of scope / deferred** — what you are deliberately not building, so the review is
199
+ about a bounded thing.
200
+
201
+ End with a short **"Review questions for the human"** block (2–3 questions) — the things the
202
+ user must actively confirm, not rubber-stamp.
203
+
204
+ ---
205
+
206
+ ## Step 4 — The design gate. STOP HERE.
207
+
208
+ **Present the design document and wait for approval. Do not reshape the reference, do not
209
+ write code.**
210
+
211
+ This is a *human* gate, and it is the whole point: it happens **before** any code, upstream of
212
+ the two implementation checkpoints in Step 7. Walk the user through section 4 ("who is denied
213
+ what") in **their own vocabulary** until they can answer, without your help: *who can see the
214
+ money, and who can see other customers' data?*
215
+
216
+ **A gate assumes a competent reviewer.** If the user cannot evaluate the permission preview,
217
+ say so rather than letting them wave it through — a design nobody understands is theater, and
218
+ reproduces exactly the failure Substrat exists to prevent. Iterate the document until they
219
+ can, and only then take explicit approval.
220
+
221
+ Approval of the design is what unlocks Step 5. Until you have it, you are still in design.
142
222
 
143
223
  ---
144
224
 
145
- ## Step 4 — Reshape the reference
225
+ ## Step 5 — Reshape the reference
146
226
 
147
- The scaffold already contains a working vertical in `src/` + `test/` — the bike-repair shop.
148
- **Read it first** (it's your Callout: the real, green implementation of every pattern this
149
- step describes), then reshape it into the user's domain from the interview:
227
+ The design is approved. The scaffold already contains a working vertical in `src/` + `test/` —
228
+ the bike-repair shop. **Read it first** (it's your Callout: the real, green implementation of
229
+ every pattern this step describes), then reshape it into the user's domain from the approved
230
+ `DESIGN.md`:
150
231
 
151
232
  - **Rename the vocabulary** — `shop_customers`/`shop_bikes` → the user's nouns, the `shop/*`
152
233
  operation names, the roles, the price-list shape. If the user's core noun maps onto a work
@@ -159,7 +240,7 @@ step describes), then reshape it into the user's domain from the interview:
159
240
  - **Drop what the domain doesn't need, add its own tables** for anything the engines don't
160
241
  own. If the user's core noun *isn't* work-order-shaped, you may replace more of `src/` —
161
242
  but the seed/server/test scaffolding and the layout still hold.
162
- - **Re-run the gates as you go** (Step 5) — the reference is green, so any red is something
243
+ - **Re-run the gates as you go** (Step 6) — the reference is green, so any red is something
163
244
  you just changed.
164
245
 
165
246
  The dependencies are already wired in `package.json` (the `@substrat-run/*` packages, `hono`,
@@ -233,7 +314,7 @@ closed-door assertion with a control proving a neighbouring door is still open.
233
314
 
234
315
  ---
235
316
 
236
- ## Step 5 — Run it
317
+ ## Step 6 — Run it
237
318
 
238
319
  Build confidence in this order, and **show the user the output of each**:
239
320
 
@@ -255,9 +336,10 @@ and typed wrappers over the routes. Ask first — it roughly doubles the work.
255
336
 
256
337
  ---
257
338
 
258
- ## Step 6 — The two checkpoints. STOP HERE.
339
+ ## Step 7 — The two checkpoints. STOP HERE.
259
340
 
260
- **You may never self-approve these. Present them and wait.**
341
+ **You may never self-approve these. Present them and wait.** The design gate (Step 4) already
342
+ took the user's approval of *what* to build; these confirm that the code matches it.
261
343
 
262
344
  1. **Migration diff** — every new `SqlMigration`, verbatim. Append-only forever once
263
345
  shipped, so this is the last cheap moment to change your mind.
@@ -275,14 +357,19 @@ and who can see other tenants' data?* A permission diff nobody understands is th
275
357
 
276
358
  ---
277
359
 
278
- ## Step 7 — Deploy (optional)
360
+ ## Step 8 — Deploy (optional)
279
361
 
280
362
  Only if the user asks. Local-first is a legitimate stopping point.
281
363
 
282
- Substrat runs on Cloudflare via `@substrat-run/adapter-cloudflare` (Durable Objects). A
283
- vertical declares what it needs at runtime with a `substrat.runtimeNeeds` block in
284
- `package.json` (stores, node-compat, build). The deploy path is the authenticated CLI, and
285
- the author never holds a Cloudflare token:
364
+ Substrat runs on Cloudflare via `@substrat-run/adapter-cloudflare` (Durable Objects).
365
+ **This starter is pushable as scaffolded**: `src/worker.ts` is the deploy entry (the
366
+ sandbox-clean shape, with the platform's `/internal/*` management contract already
367
+ mounted), and package.json already carries the `substrat.runtimeNeeds` block the CLI
368
+ derives the deploy config from (stores, node-compat, build) — you never author wrangler
369
+ config. When you reshape the vertical, keep `src/provision.ts` the single source of
370
+ MODULES/ROLES: both the dev server and the worker register from it, so a module added
371
+ only in seed.ts would run locally and silently not deploy. The deploy path is the
372
+ authenticated CLI, and the author never holds a Cloudflare token:
286
373
 
287
374
  - `substrat login` / `substrat whoami` — authenticate against the control plane.
288
375
  - `substrat push` — push the vertical; the version auto-bumps. A **private** (tenant-owned)
@@ -301,7 +388,7 @@ cross-tenant hole with a UI.
301
388
 
302
389
  ---
303
390
 
304
- ## Step 8 — Leave the project competent
391
+ ## Step 9 — Leave the project competent
305
392
 
306
393
  The next session — in any tool — starts cold. The scaffold already ships `AGENTS.md`,
307
394
  `CLAUDE.md`, and the Cursor/opencode command stubs, so the rules and this flow survive. Your
@@ -5,8 +5,9 @@ Substrat kernel and its engines. This file is the always-on constitution — the
5
5
  that hold no matter what you touch. It is read by every AI tool (Claude Code, Cursor,
6
6
  opencode); do not duplicate it into tool-specific config.
7
7
 
8
- The full build flow — interview, coverage map, scaffold, run, checkpoints — is a
9
- **playbook**, not always-on context. Invoke it when you start or extend a vertical:
8
+ The full build flow — interview, coverage map, a reviewed design document you approve
9
+ before any code, then reshape, run, and the checkpoints — is a **playbook**, not always-on
10
+ context. Invoke it when you start or extend a vertical:
10
11
 
11
12
  - **Claude Code**: `/substrat`
12
13
  - **Cursor / opencode**: the `new-vertical` command, or read [`.substrat/playbook.md`](.substrat/playbook.md)
@@ -41,11 +42,22 @@ code** (the rules below bind them); `seed`/`server` are **harness** (exempt).
41
42
  src/manifest.ts moduleManifest.parse({…}) + PERM consts ← module code
42
43
  src/migrations.ts the SqlMigration[] ← module code
43
44
  src/module.ts imports both; operations + registration ← module code
44
- src/seed.ts host, tenants, roles, grants, seed world ← harness
45
+ src/provision.ts MODULES, ROLES, grant shapes — node-free ← module code
46
+ src/seed.ts host, tenants, demo cast, seed world ← harness
45
47
  src/server.ts thin wrapper, one route per operation ← harness
48
+ src/worker.ts the deployable Cloudflare worker ← harness
46
49
  test/scenario.test.ts the scenario — including the denials
47
50
  ```
48
51
 
52
+ `provision.ts` is deliberately node-free: both hosts register from it (the dev
53
+ server's SQLite host and the worker's `ScopeDO`), and `substrat push` reads the
54
+ permission registry from it (package.json `substrat.permissions`). Roles or
55
+ modules defined anywhere else will run locally and silently not deploy.
56
+ `worker.ts` carries the platform's `/internal/*` management contract and **the
57
+ auth seam** — the dev `x-principal` header is the only caller resolution until
58
+ you wire real auth there; deploying with `ALLOW_DEV_HEADER` set is a
59
+ cross-tenant hole with a UI.
60
+
49
61
  ## The rules (non-negotiable)
50
62
 
51
63
  **Module code** = everything reachable from a `ModuleRegistration` (operations,
@@ -0,0 +1,76 @@
1
+ import {
2
+ definePermissions,
3
+ type PermissionKey,
4
+ type RoleDefinition,
5
+ } from '@substrat-run/contracts';
6
+ import { workorderModule, PERM as WO } from '@substrat-run/engine-workorder';
7
+ import { invoicingModule, INVOICING_PERM as INV } from '@substrat-run/engine-invoicing';
8
+ import { bikeShopModule } from './module.js';
9
+ import { SHOP_PERM } from './manifest.js';
10
+
11
+ // ============================================================================
12
+ // The vertical's PROVISIONING surface — everything a deployment needs to know
13
+ // about modules, roles and grant shapes, with NO node imports. Split from
14
+ // seed.ts (which pulls in node:fs + the SQLite adapter) so the Cloudflare
15
+ // worker (src/worker.ts) can bundle it, and so `substrat push` can read the
16
+ // permission registry from it (package.json `substrat.permissions`).
17
+ // ============================================================================
18
+
19
+ /**
20
+ * The modules this vertical composes, in registration order. Registered by
21
+ * BOTH hosts (the dev server's SqliteScopeHost and the worker's ScopeDO), and
22
+ * read by the permission checkpoint — the artifact can never drift from what
23
+ * actually runs.
24
+ */
25
+ export const MODULES = [workorderModule, invoicingModule, bikeShopModule];
26
+
27
+ /** Entitlements are default-deny: one SKU key per module this vertical runs. */
28
+ export const ENTITLEMENT_KEYS = ['workorder', 'invoicing', 'bikeshop'];
29
+
30
+ const adminPerms: PermissionKey[] = [
31
+ SHOP_PERM.customerManage,
32
+ SHOP_PERM.bikeManage,
33
+ WO.create,
34
+ WO.read,
35
+ WO.assign,
36
+ WO.report,
37
+ WO.complete,
38
+ WO.close,
39
+ INV.read,
40
+ INV.export,
41
+ ];
42
+
43
+ /**
44
+ * This vertical's role table — identical in every tenant, so it is a plain
45
+ * constant the permission snapshot can render without naming a tenant.
46
+ */
47
+ export const ROLES: RoleDefinition[] = [
48
+ { key: 'workshop-admin', permissions: adminPerms, source: 'vertical' },
49
+ { key: 'mechanic', permissions: [WO.read, WO.report], source: 'vertical' },
50
+ ];
51
+
52
+ /** Which role the installing owner holds — what /internal/provision assigns. */
53
+ export const OWNER_ROLE_KEY = 'workshop-admin';
54
+
55
+ /** What a portal customer receives, narrowed to their own customer record. */
56
+ export const portalPerms: PermissionKey[] = [WO.read];
57
+
58
+ /**
59
+ * Entity-narrowed grant SHAPES. The grants themselves are per-principal and
60
+ * minted at runtime, so they can never be a build artifact; their shape is what
61
+ * tells a reviewer which keys are reachable outside the role table.
62
+ */
63
+ export const ENTITY_GRANTS: { entityType: string; permissions: PermissionKey[] }[] = [
64
+ { entityType: 'customer', permissions: portalPerms },
65
+ ];
66
+
67
+ /**
68
+ * The single typed source for this vertical's permission surface — what the
69
+ * permission checkpoint and `substrat push` read (via package.json
70
+ * `substrat.permissions`).
71
+ */
72
+ export const permissions = definePermissions({
73
+ modules: MODULES,
74
+ roles: ROLES,
75
+ entityGrants: ENTITY_GRANTS,
76
+ });
@@ -5,18 +5,18 @@ import {
5
5
  principalId,
6
6
  scopeId,
7
7
  tenantId,
8
- type PermissionKey,
9
8
  type PrincipalId,
10
- type RoleDefinition,
11
9
  type ScopeId,
12
10
  type TenantId,
13
11
  } from '@substrat-run/contracts';
14
12
  import { ulid } from '@substrat-run/kernel';
15
13
  import { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
16
- import { workorderModule, PERM as WO } from '@substrat-run/engine-workorder';
17
- import { invoicingModule, INVOICING_PERM as INV } from '@substrat-run/engine-invoicing';
18
- import { bikeShopModule } from './module.js';
19
- import { SHOP_PERM } from './manifest.js';
14
+ import { ENTITLEMENT_KEYS, MODULES, OWNER_ROLE_KEY, portalPerms, ROLES } from './provision.js';
15
+
16
+ // The provisioning surface (modules, roles, grant shapes) lives in
17
+ // provision.ts — node-free so the worker bundles it and `substrat push` reads
18
+ // it. Re-exported here for callers that treat seed.ts as the world's front door.
19
+ export { ENTITY_GRANTS, MODULES, permissions, ROLES } from './provision.js';
20
20
 
21
21
  // ============================================================================
22
22
  // The seeded world. TWO tenants on purpose: the first is the shop the scenario
@@ -41,48 +41,6 @@ export interface BikeShopWorld {
41
41
  bianchiId: string; // Otto's bike
42
42
  }
43
43
 
44
- /**
45
- * The modules this vertical composes, in registration order. Exported so the
46
- * permission checkpoint (`pnpm lint:permissions`) renders from the same array
47
- * the running host registers — the artifact can never drift from reality.
48
- */
49
- export const MODULES = [workorderModule, invoicingModule, bikeShopModule];
50
-
51
- const adminPerms: PermissionKey[] = [
52
- SHOP_PERM.customerManage,
53
- SHOP_PERM.bikeManage,
54
- WO.create,
55
- WO.read,
56
- WO.assign,
57
- WO.report,
58
- WO.complete,
59
- WO.close,
60
- INV.read,
61
- INV.export,
62
- ];
63
-
64
- /**
65
- * This vertical's role table — identical in every tenant, so it is a plain
66
- * constant the permission snapshot can render without naming a tenant. Exported
67
- * for the same reason as MODULES.
68
- */
69
- export const ROLES: RoleDefinition[] = [
70
- { key: 'workshop-admin', permissions: adminPerms, source: 'vertical' },
71
- { key: 'mechanic', permissions: [WO.read, WO.report], source: 'vertical' },
72
- ];
73
-
74
- /** What a portal customer receives, narrowed to their own customer record. */
75
- const portalPerms: PermissionKey[] = [WO.read];
76
-
77
- /**
78
- * Entity-narrowed grant SHAPES. The grants themselves are per-principal and
79
- * minted at runtime, so they can never be a build artifact; their shape is what
80
- * tells a reviewer which keys are reachable outside the role table.
81
- */
82
- export const ENTITY_GRANTS: { entityType: string; permissions: PermissionKey[] }[] = [
83
- { entityType: 'customer', permissions: portalPerms },
84
- ];
85
-
86
44
  export function buildBikeShopHost(dir: string): SqliteScopeHost {
87
45
  const host = new SqliteScopeHost({ dir });
88
46
  for (const m of MODULES) host.registerModule(m);
@@ -102,7 +60,7 @@ async function provisionShop(
102
60
  await host.admin.createTenant(staff, { id: input.tenantId, slug: input.slug, name: input.name });
103
61
  // Entitlements are default-deny: the SKU flag for each module this vertical
104
62
  // runs must be granted before any of its operations resolve.
105
- for (const key of ['workorder', 'invoicing', 'bikeshop']) {
63
+ for (const key of ENTITLEMENT_KEYS) {
106
64
  await host.admin.grantEntitlement(staff, input.tenantId, key);
107
65
  }
108
66
  await host.provisionScope(staff, {
@@ -117,7 +75,7 @@ async function provisionShop(
117
75
  for (const role of ROLES) await host.admin.defineRole(staff, input.tenantId, role);
118
76
  await host.admin.assignRole(staff, {
119
77
  principalId: input.owner,
120
- roleKey: 'workshop-admin',
78
+ roleKey: OWNER_ROLE_KEY,
121
79
  node: { tenantId: input.tenantId, scopeId: null },
122
80
  });
123
81
  }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * This vertical as a deployable Cloudflare Worker — SANDBOX-CLEAN and
3
+ * control-plane-less: the shape `substrat push` deploys into the platform's
4
+ * dispatch namespace. Its only durable stores are its OWN DO classes — `SCOPE`
5
+ * (kernel + engines + this vertical, bundled) and `SWEEPER` (the deployment's
6
+ * own timer, #461); no CONTROL_PLANE binding, no service bindings, no ASSETS
7
+ * binding — the platform refuses those.
8
+ *
9
+ * `substrat push` derives the deploy config from `substrat.runtimeNeeds` in
10
+ * package.json (entry = this file, stores = the DO classes exported here) —
11
+ * you never author wrangler config.
12
+ *
13
+ * ── THE AUTH SEAM ────────────────────────────────────────────────────────────
14
+ * This starter resolves a caller ONLY through the `x-principal` dev header,
15
+ * gated on ALLOW_DEV_HEADER — an impersonation bypass by design, for local
16
+ * `wrangler dev` and smoke tests. In production the gate is off and every
17
+ * /api/* call is 401 until you wire real auth into `authenticatedPrincipal`
18
+ * below (a session/bearer verifier that maps a login → PrincipalId — see
19
+ * @substrat-run/vertical-auth, the platform's pluggable AuthProvider +
20
+ * per-tenant identity DO, for the intended shape). Deploying with the dev
21
+ * header enabled is a cross-tenant hole with a UI. ──────────────────────────
22
+ */
23
+ import { Hono } from 'hono';
24
+ import type { Context } from 'hono';
25
+ import { HTTPException } from 'hono/http-exception';
26
+ import {
27
+ entitlementGrant,
28
+ principalId,
29
+ projectedIdentityLink,
30
+ queryScopeInput,
31
+ readScopeTableInput,
32
+ scopeId,
33
+ tenantId,
34
+ z,
35
+ type PrincipalId,
36
+ type ScopeId,
37
+ type TenantId,
38
+ } from '@substrat-run/contracts';
39
+ import {
40
+ CloudflareScopeHost,
41
+ defineScopeDO,
42
+ defineScopeSweeperDO,
43
+ SCOPE_SWEEPER_NAME,
44
+ type ScopeSweeperDo,
45
+ } from '@substrat-run/adapter-cloudflare';
46
+ import {
47
+ assertPlatformCall,
48
+ PlatformCallError,
49
+ readRoutedNode,
50
+ RouterAssertionError,
51
+ type ScopeStub,
52
+ } from '@substrat-run/kernel';
53
+ import { MODULES, OWNER_ROLE_KEY, ROLES } from './provision.js';
54
+
55
+ /** The scope-DO class = the app binary: kernel + engines + this vertical, bundled. */
56
+ export const ScopeDO = defineScopeDO(MODULES, {});
57
+
58
+ /**
59
+ * The deployment's own timer (#461): a roster-keeping singleton whose alarm runs
60
+ * each provisioned scope's due recurring work — executor retries and any
61
+ * `manifest.schedules` your modules declare — with no control plane anywhere.
62
+ * `/internal/provision` and `/internal/reconcile` add scopes to the roster;
63
+ * `/internal/delete-scope` removes them. Costs nothing while the roster is empty.
64
+ */
65
+ export const SweeperDO = defineScopeSweeperDO<Env>({
66
+ intervalMs: 120_000,
67
+ host: hostFor,
68
+ });
69
+
70
+ /** The sweeper singleton's stub — one roster and one alarm per deployment. */
71
+ function sweeper(env: Env): DurableObjectStub & ScopeSweeperDo {
72
+ return env.SWEEPER.get(
73
+ env.SWEEPER.idFromName(SCOPE_SWEEPER_NAME),
74
+ ) as DurableObjectStub & ScopeSweeperDo;
75
+ }
76
+
77
+ interface Node {
78
+ tenantId: TenantId;
79
+ scopeId: ScopeId;
80
+ }
81
+
82
+ // A fixed dev node (valid ULIDs) — ONLY the fallback for local `wrangler dev`,
83
+ // where there is no router to assert one; gated on ALLOW_DEV_HEADER (never set
84
+ // in prod).
85
+ const DEV_NODE: Node = {
86
+ tenantId: tenantId.parse('01JZ00000000000000000DEV01'),
87
+ scopeId: scopeId.parse('01JZ00000000000000000DEV02'),
88
+ };
89
+
90
+ interface Env {
91
+ /** One DO per scope — the vertical's only durable store (sandbox-clean). */
92
+ SCOPE: DurableObjectNamespace;
93
+ /** The roster-keeping sweep singleton — the deployment's own timer (#461). */
94
+ SWEEPER: DurableObjectNamespace;
95
+ /** Local dev only: when 'true', trust the `x-principal` header. NEVER set in prod. */
96
+ ALLOW_DEV_HEADER?: string;
97
+ /** Shared secret the router presents (how this worker knows the asserted node is real). */
98
+ ROUTER_SECRET?: string;
99
+ /** Shared secret the platform presents on /internal/* calls. */
100
+ PLATFORM_SECRET?: string;
101
+ }
102
+
103
+ /** The routed (tenant, scope) — from the router assertion, or the dev node. */
104
+ function nodeFor(req: Request, env: Env): Node {
105
+ let routed;
106
+ try {
107
+ routed = readRoutedNode(req.headers, { expectedSecret: env.ROUTER_SECRET });
108
+ } catch (e) {
109
+ if (e instanceof RouterAssertionError) throw new HTTPException(400, { message: e.message });
110
+ throw e;
111
+ }
112
+ if (routed) return { tenantId: routed.tenantId, scopeId: routed.scopeId };
113
+ if (env.ALLOW_DEV_HEADER === 'true') return DEV_NODE;
114
+ throw new HTTPException(503, { message: 'no scope was asserted for this request (missing router assertion)' });
115
+ }
116
+
117
+ function hostFor(env: Env): CloudflareScopeHost {
118
+ const host = new CloudflareScopeHost({ scope: env.SCOPE });
119
+ for (const m of MODULES) host.registerModule(m);
120
+ return host;
121
+ }
122
+
123
+ /**
124
+ * THE AUTH SEAM (see the header comment): resolve the caller to a PrincipalId,
125
+ * or null for nobody. Replace the body with a real session/bearer verifier
126
+ * before exposing this worker to users — the dev header is dev-only.
127
+ */
128
+ async function authenticatedPrincipal(req: Request, env: Env): Promise<PrincipalId | null> {
129
+ if (env.ALLOW_DEV_HEADER === 'true') {
130
+ const parsed = principalId.safeParse(req.headers.get('x-principal') ?? '');
131
+ if (parsed.success) return parsed.data;
132
+ }
133
+ return null; // ← wire real auth here
134
+ }
135
+
136
+ /** Resolve caller + routed node → a scope stub. 401 if nobody. */
137
+ async function stub(c: Context<{ Bindings: Env }>): Promise<ScopeStub> {
138
+ const node = nodeFor(c.req.raw, c.env);
139
+ const principal = await authenticatedPrincipal(c.req.raw, c.env);
140
+ if (!principal) throw new HTTPException(401, { message: 'unauthorized' });
141
+ return hostFor(c.env).getScope(principal, node.tenantId, node.scopeId);
142
+ }
143
+
144
+ const app = new Hono<{ Bindings: Env }>();
145
+
146
+ app.onError((err, c) => {
147
+ if (err instanceof HTTPException) return err.getResponse();
148
+ const m = err instanceof Error ? err.message : String(err);
149
+ if (/permission denied/i.test(m)) return c.json({ error: m }, 403);
150
+ if (/not found|unknown scope/i.test(m)) return c.json({ error: m }, 404);
151
+ if (/invalid transition|immutable/i.test(m)) return c.json({ error: m }, 409);
152
+ return c.json({ error: m }, 400);
153
+ });
154
+
155
+ // Who am I — resolves the caller without invoking anything.
156
+ app.get('/api/me', async (c) => {
157
+ const principal = await authenticatedPrincipal(c.req.raw, c.env);
158
+ if (!principal) return c.json({ error: 'unauthorized' }, 401);
159
+ return c.json({ principal });
160
+ });
161
+
162
+ // Generic invoke: the kernel checks a permission inside EVERY operation, so a
163
+ // generic route is exactly as safe as one route per operation.
164
+ app.post('/api/invoke', async (c) => {
165
+ const { op, input } = await c.req.json<{ op: string; input?: unknown }>();
166
+ return c.json((await (await stub(c)).invoke(op, input)) ?? null);
167
+ });
168
+
169
+ // ── /internal/* — the platform-gated management contract ────────────────────
170
+ // The control plane provisions, heals, inspects and restores installs through
171
+ // these routes. A vertical without them cannot be installed or repaired, so
172
+ // keep the FULL set even though your app code never calls them.
173
+
174
+ function gatePlatform(c: { env: Env; req: { raw: Request } }): void {
175
+ try {
176
+ assertPlatformCall(c.req.raw.headers, { expectedSecret: c.env.PLATFORM_SECRET });
177
+ } catch (e) {
178
+ if (e instanceof PlatformCallError) throw new HTTPException(403, { message: e.message });
179
+ throw e;
180
+ }
181
+ }
182
+
183
+ const provisionBody = z.object({
184
+ tenantId,
185
+ scopeId,
186
+ owner: principalId,
187
+ slug: z.string().min(1),
188
+ name: z.string().min(1),
189
+ entitlements: z.array(entitlementGrant).optional(),
190
+ identityLinks: z.array(projectedIdentityLink).optional(),
191
+ });
192
+
193
+ // Provision ONE scope on the platform's instruction, CP-lessly: migrate the
194
+ // modules, project this vertical's roles + the tenant's entitlements locally,
195
+ // grant the owner their role at scope level. Platform-secret gated; idempotent.
196
+ app.post('/internal/provision', async (c) => {
197
+ gatePlatform(c);
198
+ const body = provisionBody.parse(await c.req.json());
199
+ await hostFor(c.env).provisionScopeLocal({
200
+ tenantId: body.tenantId,
201
+ scopeId: body.scopeId,
202
+ owner: body.owner,
203
+ roles: ROLES,
204
+ ownerRoleKey: OWNER_ROLE_KEY,
205
+ entitlements: body.entitlements,
206
+ identityLinks: body.identityLinks,
207
+ });
208
+ // Provision is where the deployment learns a scope exists — put it on the
209
+ // sweep roster so its declared schedules run. (Never note a snapshot fork:
210
+ // recurring side effects must not fire off a preview copy, and fork-ness is
211
+ // only knowable platform-side — which is why this rides provision/reconcile,
212
+ // not request traffic.)
213
+ await sweeper(c.env).noteScope(body.tenantId, body.scopeId);
214
+ return c.json({ tenantId: body.tenantId, scopeId: body.scopeId, owner: body.owner }, 201);
215
+ });
216
+
217
+ // Repair (reconcile): re-deliver roles/entitlements/identity links to a scope.
218
+ // This starter has no durable owner-of-record store (that lives with real auth
219
+ // — the auth seam), so the owner must be re-supplied; without one the refusal
220
+ // names the remedy instead of healing wrongly.
221
+ const reconcileBody = provisionBody.partial({ owner: true, slug: true, name: true });
222
+ app.post('/internal/reconcile', async (c) => {
223
+ gatePlatform(c);
224
+ const body = reconcileBody.parse(await c.req.json());
225
+ if (!body.owner) {
226
+ throw new HTTPException(409, {
227
+ message:
228
+ 'no owner of record: this starter keeps none (an identity store — the auth seam — owns it). ' +
229
+ 'Re-run the full install, or wire auth and record the owner durably.',
230
+ });
231
+ }
232
+ await hostFor(c.env).provisionScopeLocal({
233
+ tenantId: body.tenantId,
234
+ scopeId: body.scopeId,
235
+ owner: body.owner,
236
+ roles: ROLES,
237
+ ownerRoleKey: OWNER_ROLE_KEY,
238
+ entitlements: body.entitlements,
239
+ identityLinks: body.identityLinks,
240
+ });
241
+ // Reconcile is the roster's backfill: scopes provisioned before the sweeper
242
+ // shipped join it on their next platform repair.
243
+ await sweeper(c.env).noteScope(body.tenantId, body.scopeId);
244
+ return c.json({ tenantId: body.tenantId, scopeId: body.scopeId, owner: body.owner });
245
+ });
246
+
247
+ // Read-only scope-table introspection (console/dashboard Data view).
248
+ app.get('/internal/tables', async (c) => {
249
+ gatePlatform(c);
250
+ return c.json(await hostFor(c.env).introspectScopeTables(scopeId.parse(c.req.query('scopeId'))));
251
+ });
252
+ app.get('/internal/tables/:table', async (c) => {
253
+ gatePlatform(c);
254
+ const scope = scopeId.parse(c.req.query('scopeId'));
255
+ const input = readScopeTableInput.parse({
256
+ table: c.req.param('table'),
257
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
258
+ offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined,
259
+ });
260
+ return c.json(await hostFor(c.env).introspectScopeTable(scope, input));
261
+ });
262
+ // The SQL console: one read-only statement, enforced in the DO.
263
+ app.post('/internal/query', async (c) => {
264
+ gatePlatform(c);
265
+ const body = queryScopeInput.extend({ scopeId }).parse(await c.req.json());
266
+ try {
267
+ return c.json(await hostFor(c.env).introspectScopeQuery(body.scopeId, { sql: body.sql }));
268
+ } catch (e) {
269
+ if (e instanceof Error && e.message.includes('read-only console')) {
270
+ throw new HTTPException(400, { message: e.message });
271
+ }
272
+ throw e;
273
+ }
274
+ });
275
+
276
+ // Platform-intent drain surface: the control plane PULLS pending intents from
277
+ // this deployment's scope DOs and journals outcomes back.
278
+ app.get('/internal/platform-requests', async (c) => {
279
+ gatePlatform(c);
280
+ const t = tenantId.parse(c.req.query('tenantId'));
281
+ const s = scopeId.parse(c.req.query('scopeId'));
282
+ return c.json(await hostFor(c.env).listPlatformRequests(t, s));
283
+ });
284
+
285
+ // Scope-storage lifecycle: snapshot/delete/export/restore/bookmarks/rewind —
286
+ // what `substrat scope pull`/`restore` and the in-place update backout use.
287
+ app.post('/internal/snapshot', async (c) => {
288
+ gatePlatform(c);
289
+ const body = z.object({ sourceScopeId: scopeId, newScopeId: scopeId }).parse(await c.req.json());
290
+ return c.json(await hostFor(c.env).snapshotScopeLocal(body.sourceScopeId, body.newScopeId), 201);
291
+ });
292
+ app.post('/internal/delete-scope', async (c) => {
293
+ gatePlatform(c);
294
+ const body = z.object({ scopeId }).parse(await c.req.json());
295
+ await hostFor(c.env).deleteScopeLocal(body.scopeId);
296
+ // Off the sweep roster too — a deleted scope must not be woken by the alarm.
297
+ await sweeper(c.env).forgetScope(body.scopeId);
298
+ return c.json({ deleted: body.scopeId });
299
+ });
300
+ app.get('/internal/export', async (c) => {
301
+ gatePlatform(c);
302
+ return c.json(await hostFor(c.env).exportScopeLocal(scopeId.parse(c.req.query('scopeId'))));
303
+ });
304
+ app.post('/internal/restore', async (c) => {
305
+ gatePlatform(c);
306
+ const body = z
307
+ .object({
308
+ tenantId: tenantId.optional(),
309
+ scopeId,
310
+ tables: z.array(
311
+ z.object({ name: z.string(), ddl: z.string(), columns: z.array(z.string()), rows: z.array(z.array(z.unknown())) }),
312
+ ),
313
+ })
314
+ .parse(await c.req.json());
315
+ const host = hostFor(c.env);
316
+ const result = await host.restoreScopeLocal(body.scopeId, body.tables);
317
+ // Re-project role definitions after an import — a dump may carry tuples but
318
+ // no role definitions; roles are code-defined, so re-projecting is always safe.
319
+ if (body.tenantId) await host.projectRolesLocal(body.tenantId, body.scopeId, ROLES);
320
+ return c.json(result);
321
+ });
322
+ app.get('/internal/bookmarks', async (c) => {
323
+ gatePlatform(c);
324
+ return c.json(await hostFor(c.env).migrationBookmarksLocal(scopeId.parse(c.req.query('scopeId'))));
325
+ });
326
+ app.post('/internal/rewind', async (c) => {
327
+ gatePlatform(c);
328
+ const body = z
329
+ .object({ scopeId, bookmark: z.string().min(1), force: z.boolean().optional() })
330
+ .parse(await c.req.json());
331
+ return c.json(await hostFor(c.env).rewindScopeLocal(body.scopeId, body.bookmark, { force: body.force }));
332
+ });
333
+
334
+ // Unmatched /api/* fails as JSON; everything else gets a pointer, not a UI —
335
+ // this starter ships no SPA (add one and inline it at build time when you do).
336
+ app.all('/api/*', (c) => c.json({ error: `unknown route: ${new URL(c.req.raw.url).pathname}` }, 404));
337
+ app.all('*', (c) =>
338
+ c.json({
339
+ service: 'substrat vertical',
340
+ api: 'POST /api/invoke { op, input }',
341
+ docs: 'https://substrat.net',
342
+ }),
343
+ );
344
+
345
+ export default app;
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["@cloudflare/workers-types"],
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true
11
+ },
12
+ "include": ["src/worker.ts", "src/provision.ts", "src/manifest.ts", "src/migrations.ts", "src/module.ts"]
13
+ }