create-substrat 0.1.1 → 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,9 +20,11 @@ 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
30
  const DOCS = 'https://substrat.net';
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -103,10 +103,12 @@ Imported directly; their in-scope functions run in **your** transaction. Read ea
103
103
  **No import.** You emit; they consume. This is the star topology.
104
104
 
105
105
  - **`engine-invoicing`** — invoice basis and lines, immutable after export. Consumes
106
- `workorder.completed` and `commerce.order-placed`, so a vertical that imports zero engines
107
- still gets invoicing by emitting an event. Its consumer find-or-creates the customer's
108
- *open* basis and appends. It has **no tax/VAT concept** — say so before an EU user
109
- 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.
110
112
 
111
113
  ### Tier 2b — connectors, for anything off-box
112
114
 
@@ -178,7 +180,11 @@ plain language, so nothing there is a surprise:
178
180
  than claimed.
179
181
  7. **The data we'll store** — the vertical's own tables and fields in plain terms. This
180
182
  *previews the migration diff*; migrations are **append-only forever after first ship**,
181
- so this is the cheap moment to get the shape right.
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.
182
188
  8. **The scenario the test will replay** — the happy path plus the denials that prove
183
189
  isolation (wrong role denied, customer A sees theirs and customer B sees nothing, a
184
190
  cross-tenant attacker gets nothing).
@@ -355,10 +361,15 @@ and who can see other tenants' data?* A permission diff nobody understands is th
355
361
 
356
362
  Only if the user asks. Local-first is a legitimate stopping point.
357
363
 
358
- Substrat runs on Cloudflare via `@substrat-run/adapter-cloudflare` (Durable Objects). A
359
- vertical declares what it needs at runtime with a `substrat.runtimeNeeds` block in
360
- `package.json` (stores, node-compat, build). The deploy path is the authenticated CLI, and
361
- 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:
362
373
 
363
374
  - `substrat login` / `substrat whoami` — authenticate against the control plane.
364
375
  - `substrat push` — push the vertical; the version auto-bumps. A **private** (tenant-owned)
@@ -42,11 +42,22 @@ code** (the rules below bind them); `seed`/`server` are **harness** (exempt).
42
42
  src/manifest.ts moduleManifest.parse({…}) + PERM consts ← module code
43
43
  src/migrations.ts the SqlMigration[] ← module code
44
44
  src/module.ts imports both; operations + registration ← module code
45
- 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
46
47
  src/server.ts thin wrapper, one route per operation ← harness
48
+ src/worker.ts the deployable Cloudflare worker ← harness
47
49
  test/scenario.test.ts the scenario — including the denials
48
50
  ```
49
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
+
50
61
  ## The rules (non-negotiable)
51
62
 
52
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
+ }