create-substrat 0.1.1 → 0.3.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,12 @@ 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.45.0 is the release that ships `@substrat-run/vertical-host` (#510) — the
24
+ // mountPlatformSurface the template's worker mounts — so this pin and that release
25
+ // move together (it also covers `defineScopeSweeperDO`, #461, from 0.40.0).
26
+ const SUBSTRAT = '^0.45.0';
24
27
  // Engines version on their own line (0.3.x), independent of the kernel/contracts line.
25
- const ENGINES = '^0.3.27';
28
+ const ENGINES = '^0.3.37';
26
29
  const BOUNDARY_LINT = '^0.0.5';
27
30
 
28
31
  const DOCS = 'https://substrat.net';
@@ -64,17 +67,34 @@ function packageJson(name) {
64
67
  version: '0.0.0',
65
68
  private: true,
66
69
  type: 'module',
70
+ // What `substrat push` reads: the permission surface (the registry the
71
+ // promotion checkpoint diffs) and the runtime needs the deploy config is
72
+ // derived from — you never author wrangler config (src/worker.ts is the
73
+ // entry; ScopeDO is the store it exports).
74
+ substrat: {
75
+ permissions: 'src/provision.ts',
76
+ runtimeNeeds: {
77
+ entry: 'src/worker.ts',
78
+ stores: [
79
+ { binding: 'SCOPE', class: 'ScopeDO' },
80
+ // The scope-local sweep singleton — the deployment's own timer (#461).
81
+ { binding: 'SWEEPER', class: 'SweeperDO' },
82
+ ],
83
+ },
84
+ },
67
85
  scripts: {
68
86
  dev: 'tsx watch src/server.ts',
69
87
  server: 'tsx src/server.ts',
70
88
  test: 'vitest run',
71
- typecheck: 'tsc --noEmit',
89
+ typecheck: 'tsc --noEmit && tsc -p tsconfig.worker.json --noEmit',
72
90
  'lint:boundaries': 'substrat-boundary-lint',
73
91
  },
74
92
  dependencies: {
75
93
  '@substrat-run/kernel': SUBSTRAT,
76
94
  '@substrat-run/contracts': SUBSTRAT,
77
95
  '@substrat-run/adapter-sqlite': SUBSTRAT,
96
+ '@substrat-run/adapter-cloudflare': SUBSTRAT,
97
+ '@substrat-run/vertical-host': SUBSTRAT,
78
98
  '@substrat-run/engine-workorder': ENGINES,
79
99
  '@substrat-run/engine-invoicing': ENGINES,
80
100
  hono: '^4.6.0',
@@ -83,7 +103,9 @@ function packageJson(name) {
83
103
  },
84
104
  devDependencies: {
85
105
  '@substrat-run/boundary-lint': BOUNDARY_LINT,
106
+ '@cloudflare/workers-types': '^4.20250109.0',
86
107
  '@types/better-sqlite3': '^7.6.0',
108
+ '@types/node': '^22.0.0',
87
109
  concurrently: '^9.0.0',
88
110
  tsx: '^4.19.0',
89
111
  typescript: '^5.6.0',
@@ -112,6 +134,9 @@ const TSCONFIG = `${JSON.stringify(
112
134
  types: ['node'],
113
135
  },
114
136
  include: ['src', 'test'],
137
+ // The worker compiles against workers-types under its own config
138
+ // (tsconfig.worker.json) — the node config must not see it.
139
+ exclude: ['src/worker.ts'],
115
140
  },
116
141
  null,
117
142
  2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.1.1",
3
+ "version": "0.3.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,16 @@ 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 mounted
367
+ via `mountPlatformSurface` from `@substrat-run/vertical-host` — you never re-author those
368
+ routes), and package.json already carries the `substrat.runtimeNeeds` block the CLI
369
+ derives the deploy config from (stores, node-compat, build) — you never author wrangler
370
+ config. When you reshape the vertical, keep `src/provision.ts` the single source of
371
+ MODULES/ROLES: both the dev server and the worker register from it, so a module added
372
+ only in seed.ts would run locally and silently not deploy. The deploy path is the
373
+ authenticated CLI, and the author never holds a Cloudflare token:
362
374
 
363
375
  - `substrat login` / `substrat whoami` — authenticate against the control plane.
364
376
  - `substrat push` — push the vertical; the version auto-bumps. A **private** (tenant-owned)
@@ -42,11 +42,25 @@ 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` **mounts** the platform's `/internal/*` management contract via
57
+ `mountPlatformSurface` from `@substrat-run/vertical-host` (one call — the routes
58
+ and the `{ error }` envelope are authored there, not here, so they can't drift or
59
+ ship half-done). What `worker.ts` still owns is your app routes and **the auth
60
+ seam** — the dev `x-principal` header is the only caller resolution until you wire
61
+ real auth there; deploying with `ALLOW_DEV_HEADER` set is a cross-tenant hole with
62
+ a UI.
63
+
50
64
  ## The rules (non-negotiable)
51
65
 
52
66
  **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,182 @@
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
+ principalId,
28
+ scopeId,
29
+ tenantId,
30
+ type PrincipalId,
31
+ type ScopeId,
32
+ type TenantId,
33
+ } from '@substrat-run/contracts';
34
+ import {
35
+ CloudflareScopeHost,
36
+ defineScopeDO,
37
+ defineScopeSweeperDO,
38
+ SCOPE_SWEEPER_NAME,
39
+ type ScopeSweeperDo,
40
+ } from '@substrat-run/adapter-cloudflare';
41
+ import { readRoutedNode, RouterAssertionError, type ScopeStub } from '@substrat-run/kernel';
42
+ import { mountPlatformSurface } from '@substrat-run/vertical-host';
43
+ import { MODULES, OWNER_ROLE_KEY, ROLES } from './provision.js';
44
+
45
+ /** The scope-DO class = the app binary: kernel + engines + this vertical, bundled. */
46
+ export const ScopeDO = defineScopeDO(MODULES, {});
47
+
48
+ /**
49
+ * The deployment's own timer (#461): a roster-keeping singleton whose alarm runs
50
+ * each provisioned scope's due recurring work — executor retries and any
51
+ * `manifest.schedules` your modules declare — with no control plane anywhere.
52
+ * `/internal/provision` and `/internal/reconcile` add scopes to the roster;
53
+ * `/internal/delete-scope` removes them. Costs nothing while the roster is empty.
54
+ */
55
+ export const SweeperDO = defineScopeSweeperDO<Env>({
56
+ intervalMs: 120_000,
57
+ host: hostFor,
58
+ });
59
+
60
+ /** The sweeper singleton's stub — one roster and one alarm per deployment. */
61
+ function sweeper(env: Env): DurableObjectStub & ScopeSweeperDo {
62
+ return env.SWEEPER.get(
63
+ env.SWEEPER.idFromName(SCOPE_SWEEPER_NAME),
64
+ ) as DurableObjectStub & ScopeSweeperDo;
65
+ }
66
+
67
+ interface Node {
68
+ tenantId: TenantId;
69
+ scopeId: ScopeId;
70
+ }
71
+
72
+ // A fixed dev node (valid ULIDs) — ONLY the fallback for local `wrangler dev`,
73
+ // where there is no router to assert one; gated on ALLOW_DEV_HEADER (never set
74
+ // in prod).
75
+ const DEV_NODE: Node = {
76
+ tenantId: tenantId.parse('01JZ00000000000000000DEV01'),
77
+ scopeId: scopeId.parse('01JZ00000000000000000DEV02'),
78
+ };
79
+
80
+ interface Env {
81
+ /** One DO per scope — the vertical's only durable store (sandbox-clean). */
82
+ SCOPE: DurableObjectNamespace;
83
+ /** The roster-keeping sweep singleton — the deployment's own timer (#461). */
84
+ SWEEPER: DurableObjectNamespace;
85
+ /** Local dev only: when 'true', trust the `x-principal` header. NEVER set in prod. */
86
+ ALLOW_DEV_HEADER?: string;
87
+ /** Shared secret the router presents (how this worker knows the asserted node is real). */
88
+ ROUTER_SECRET?: string;
89
+ /** Shared secret the platform presents on /internal/* calls. */
90
+ PLATFORM_SECRET?: string;
91
+ }
92
+
93
+ /** The routed (tenant, scope) — from the router assertion, or the dev node. */
94
+ function nodeFor(req: Request, env: Env): Node {
95
+ let routed;
96
+ try {
97
+ routed = readRoutedNode(req.headers, { expectedSecret: env.ROUTER_SECRET });
98
+ } catch (e) {
99
+ if (e instanceof RouterAssertionError) throw new HTTPException(400, { message: e.message });
100
+ throw e;
101
+ }
102
+ if (routed) return { tenantId: routed.tenantId, scopeId: routed.scopeId };
103
+ if (env.ALLOW_DEV_HEADER === 'true') return DEV_NODE;
104
+ throw new HTTPException(503, { message: 'no scope was asserted for this request (missing router assertion)' });
105
+ }
106
+
107
+ function hostFor(env: Env): CloudflareScopeHost {
108
+ const host = new CloudflareScopeHost({ scope: env.SCOPE });
109
+ for (const m of MODULES) host.registerModule(m);
110
+ return host;
111
+ }
112
+
113
+ /**
114
+ * THE AUTH SEAM (see the header comment): resolve the caller to a PrincipalId,
115
+ * or null for nobody. Replace the body with a real session/bearer verifier
116
+ * before exposing this worker to users — the dev header is dev-only.
117
+ */
118
+ async function authenticatedPrincipal(req: Request, env: Env): Promise<PrincipalId | null> {
119
+ if (env.ALLOW_DEV_HEADER === 'true') {
120
+ const parsed = principalId.safeParse(req.headers.get('x-principal') ?? '');
121
+ if (parsed.success) return parsed.data;
122
+ }
123
+ return null; // ← wire real auth here
124
+ }
125
+
126
+ /** Resolve caller + routed node → a scope stub. 401 if nobody. */
127
+ async function stub(c: Context<{ Bindings: Env }>): Promise<ScopeStub> {
128
+ const node = nodeFor(c.req.raw, c.env);
129
+ const principal = await authenticatedPrincipal(c.req.raw, c.env);
130
+ if (!principal) throw new HTTPException(401, { message: 'unauthorized' });
131
+ return hostFor(c.env).getScope(principal, node.tenantId, node.scopeId);
132
+ }
133
+
134
+ const app = new Hono<{ Bindings: Env }>();
135
+
136
+ // Who am I — resolves the caller without invoking anything.
137
+ app.get('/api/me', async (c) => {
138
+ const principal = await authenticatedPrincipal(c.req.raw, c.env);
139
+ if (!principal) return c.json({ error: 'unauthorized' }, 401);
140
+ return c.json({ principal });
141
+ });
142
+
143
+ // Generic invoke: the kernel checks a permission inside EVERY operation, so a
144
+ // generic route is exactly as safe as one route per operation.
145
+ app.post('/api/invoke', async (c) => {
146
+ const { op, input } = await c.req.json<{ op: string; input?: unknown }>();
147
+ return c.json((await (await stub(c)).invoke(op, input)) ?? null);
148
+ });
149
+
150
+ // ── /internal/* — the platform-gated management contract ────────────────────
151
+ // The control plane provisions, heals, inspects and restores installs through
152
+ // these routes. The whole contract — provision, reconcile, introspection, the
153
+ // read-only SQL console, platform-request drain, snapshot/delete/export/restore,
154
+ // and bookmarks/rewind — plus the guaranteed { error } envelope is authored ONCE
155
+ // in @substrat-run/vertical-host (issue #510); mount it and it cannot drift.
156
+ //
157
+ // This starter's hooks keep the deployment's sweep roster (#461) in step: a newly
158
+ // provisioned scope joins it (so its schedules run), and a deleted one leaves it.
159
+ // Reconcile needs a durable owner-of-record to heal from — this starter keeps none
160
+ // (that lives with real auth, the auth seam), so `resolveOwner` is omitted and
161
+ // /internal/reconcile answers 501 until you wire auth and supply one.
162
+ mountPlatformSurface<Env>(app, {
163
+ platformSecret: (env) => env.PLATFORM_SECRET,
164
+ hostFor,
165
+ roles: ROLES,
166
+ ownerRoleKey: OWNER_ROLE_KEY,
167
+ onProvision: (env, b) => sweeper(env).noteScope(b.tenantId, b.scopeId),
168
+ onDeleteScope: (env, s) => sweeper(env).forgetScope(s),
169
+ });
170
+
171
+ // Unmatched /api/* fails as JSON; everything else gets a pointer, not a UI —
172
+ // this starter ships no SPA (add one and inline it at build time when you do).
173
+ app.all('/api/*', (c) => c.json({ error: `unknown route: ${new URL(c.req.raw.url).pathname}` }, 404));
174
+ app.all('*', (c) =>
175
+ c.json({
176
+ service: 'substrat vertical',
177
+ api: 'POST /api/invoke { op, input }',
178
+ docs: 'https://substrat.net',
179
+ }),
180
+ );
181
+
182
+ 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
+ }