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 +33 -6
- package/package.json +2 -2
- package/template/.substrat/playbook.md +124 -37
- package/template/AGENTS.md +15 -3
- package/template/src/provision.ts +76 -0
- package/template/src/seed.ts +8 -50
- package/template/src/worker.ts +345 -0
- package/template/tsconfig.worker.json +13 -0
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
|
-
|
|
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
|
+
const ENGINES = '^0.3.37';
|
|
26
28
|
const BOUNDARY_LINT = '^0.0.5';
|
|
27
29
|
|
|
28
|
-
const DOCS = 'https://substrat.
|
|
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 (
|
|
193
|
+
if (target === '-h' || target === '--help') {
|
|
194
|
+
usage();
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
if (!target) {
|
|
171
198
|
usage();
|
|
172
|
-
|
|
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.
|
|
4
|
-
"description": "Scaffold a Substrat vertical — `npm create substrat
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.**
|
|
38
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 —
|
|
147
|
+
## Step 3 — Write the design document
|
|
132
148
|
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
225
|
+
## Step 5 — Reshape the reference
|
|
146
226
|
|
|
147
|
-
The scaffold already contains a working vertical in `src/` + `test/` —
|
|
148
|
-
**Read it first** (it's your Callout: the real, green implementation of
|
|
149
|
-
step describes), then reshape it into the user's domain from the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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
|
package/template/AGENTS.md
CHANGED
|
@@ -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,
|
|
9
|
-
|
|
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/
|
|
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
|
+
});
|
package/template/src/seed.ts
CHANGED
|
@@ -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 {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|