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 +28 -3
- package/package.json +1 -1
- package/template/.substrat/playbook.md +21 -9
- package/template/AGENTS.md +15 -1
- package/template/src/provision.ts +76 -0
- package/template/src/seed.ts +8 -50
- package/template/src/worker.ts +182 -0
- package/template/tsconfig.worker.json +13 -0
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
|
-
|
|
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.
|
|
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
|
@@ -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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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).
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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)
|
package/template/AGENTS.md
CHANGED
|
@@ -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/
|
|
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
|
+
});
|
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,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
|
+
}
|