create-substrat 0.6.4 → 0.7.1

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
@@ -35,10 +35,10 @@ const TEMPLATE = join(HERE, 'template');
35
35
  // The runtime packages release together off one version line (the changesets `fixed`
36
36
  // group), so one constant is right for all of them. Engines do NOT share a line —
37
37
  // each versions on its own, so one pin per engine, deliberately.
38
- const SUBSTRAT = '^0.79.0';
39
- const ENGINE_WORKORDER = '^0.6.6';
40
- const ENGINE_INVOICING = '^0.7.6';
41
- const BOUNDARY_LINT = '^0.0.8';
38
+ const SUBSTRAT = '^0.84.0';
39
+ const ENGINE_WORKORDER = '^0.8.0';
40
+ const ENGINE_INVOICING = '^0.9.0';
41
+ const BOUNDARY_LINT = '^0.1.0';
42
42
 
43
43
  const DOCS = 'https://substrat.net';
44
44
 
@@ -93,8 +93,27 @@ function packageJson(name) {
93
93
  { binding: 'SCOPE', class: 'ScopeDO' },
94
94
  // The scope-local sweep singleton — the deployment's own timer (#461).
95
95
  { binding: 'SWEEPER', class: 'SweeperDO' },
96
+ // Per-instance config delivered by the platform (/internal/configure) —
97
+ // one DO per tenant, rows per scope. Without it the app cannot receive
98
+ // the settings the dashboard offers its users.
99
+ { binding: 'CONFIG', class: 'ConfigDO' },
96
100
  ],
97
101
  },
102
+ // The declared per-instance settings — MIRRORED from src/manifest.ts
103
+ // (`SHOP_ENV`), because `substrat push` reads JSON, not TS. The dashboard
104
+ // renders its Settings form from this; keep the two in sync.
105
+ envSpec: [
106
+ {
107
+ key: 'SHOP_NAME',
108
+ label: 'Workshop name',
109
+ description: 'The workshop name shown to customers. Set per install.',
110
+ placeholder: 'Söder Cykel & Service',
111
+ default: 'Substrat Bike Shop',
112
+ required: false,
113
+ secret: false,
114
+ group: 'General',
115
+ },
116
+ ],
98
117
  devServers: DEV_SERVERS,
99
118
  },
100
119
  scripts: {
@@ -150,9 +169,11 @@ const TSCONFIG = `${JSON.stringify(
150
169
  types: ['node'],
151
170
  },
152
171
  include: ['src', 'test'],
153
- // The worker compiles against workers-types under its own config
154
- // (tsconfig.worker.json) — the node config must not see it.
155
- exclude: ['src/worker.ts'],
172
+ // The worker and its Cloudflare-only stores compile against workers-types
173
+ // under their own config (tsconfig.worker.json) — the node config must not
174
+ // see them. `src/routes.ts` is deliberately NOT excluded: the shared route
175
+ // table must typecheck under both, which is what keeps it host-agnostic.
176
+ exclude: ['src/worker.ts', 'src/config-do.ts'],
156
177
  },
157
178
  null,
158
179
  2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -7,8 +7,8 @@ reshape the reference into it. Read the whole thing before starting — both the
7
7
  (Step 4) and the checkpoints (Step 7) are hard stops.
8
8
 
9
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
10
+ onto what already exists; Step 3 writes a checked-in `spec/concept.md` in the user's own
11
+ vocabulary; Step 4 is a **hard stop** where the user reads and approves it. Only then does Step 5 reshape
12
12
  the reference into their domain. The design gate (Step 4) is *upstream* of the two code
13
13
  checkpoints (Step 7) — a user with zero Substrat knowledge gets to say "yes, that's the app I
14
14
  want" before implementation, not after.
@@ -147,8 +147,9 @@ stop. Do not scaffold.
147
147
  ## Step 3 — Write the design document
148
148
 
149
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
150
+ somewhere the user can hold. Write a **checked-in `spec/concept.md`** — beside
151
+ `spec/model.ts`, which is this same design one rung more concrete — in the user's own
152
+ vocabulary — no Substrat internals, no decision refs, no cross-references to
152
153
  platform docs. Someone who has never heard of Substrat must be able to read it and recognise
153
154
  their own business.
154
155
 
@@ -291,8 +292,8 @@ and event subjects all need one id, so naming such an entity in `parents`,
291
292
  compile error. It is still a full model member with migrations and a row type. A
292
293
  single-column key that is not called `id` stays fully pointable.
293
294
 
294
- Behaviour stays prose in `DESIGN.md`. Inventing a way to declare a state *transition* means
295
- the boundary slipped.
295
+ Behaviour stays prose in `spec/concept.md`. Inventing a way to declare a state *transition*
296
+ means the boundary slipped.
296
297
 
297
298
  Full reference: https://substrat.net/concepts/model
298
299
 
@@ -307,7 +308,7 @@ to make the build pass.
307
308
  The design is approved. The scaffold already contains a working vertical in `src/` + `test/` —
308
309
  the bike-repair shop. **Read it first** (it's your Callout: the real, green implementation of
309
310
  every pattern this step describes), then reshape it into the user's domain from the approved
310
- `DESIGN.md`:
311
+ `spec/concept.md`:
311
312
 
312
313
  - **Rename the vocabulary** — `shop_customers`/`shop_bikes` → the user's nouns, the `shop/*`
313
314
  operation names, the roles, the price-list shape. If the user's core noun maps onto a work
@@ -44,11 +44,21 @@ src/migrations.ts the SqlMigration[] ← module cod
44
44
  src/module.ts imports both; operations + registration ← module code
45
45
  src/provision.ts MODULES, ROLES, grant shapes — node-free ← module code
46
46
  src/seed.ts host, tenants, demo cast, seed world ← harness
47
- src/server.ts thin wrapper, one route per operation ← harness
48
- src/worker.ts the deployable Cloudflare worker ← harness
47
+ src/routes.ts the HTTP route table — BOTH hosts mount it ← harness
48
+ src/server.ts the dev entrypoint (node + persona picker) ← harness
49
+ src/worker.ts the deployable Cloudflare worker ← harness
50
+ src/config-do.ts per-instance config store (Cloudflare only) ← harness
49
51
  test/scenario.test.ts the scenario — including the denials
50
52
  ```
51
53
 
54
+ **A new route goes in `src/routes.ts`, never in an entrypoint.** Both `server.ts`
55
+ and `worker.ts` mount that one table, so a route added there is live on both — and
56
+ a route added to only one is a surface that works in dev and 404s in production
57
+ (or the reverse), which nothing catches until you deploy: the scenario tests call
58
+ operations directly and never boot either host. What an entrypoint may still own
59
+ is only what is genuinely its own — building a host, resolving a caller, and its
60
+ own auth-shaped route (`/api/cast` in dev, `/api/me` in the worker).
61
+
52
62
  `provision.ts` is deliberately node-free: both hosts register from it (the dev
53
63
  server's SQLite host and the worker's `ScopeDO`), and `substrat push` reads the
54
64
  permission registry from it (package.json `substrat.permissions`). Roles or
@@ -56,18 +66,36 @@ modules defined anywhere else will run locally and silently not deploy.
56
66
  `worker.ts` **mounts** the platform's `/internal/*` management contract via
57
67
  `mountPlatformSurface` from `@substrat-run/vertical-host` (one call — the routes
58
68
  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.
69
+ ship half-done). What `worker.ts` still owns is **the auth seam** — the dev
70
+ `x-principal` header is the only caller resolution until you wire real auth there;
71
+ deploying with `ALLOW_DEV_HEADER` set is a cross-tenant hole with a UI.
72
+
73
+ Among the hooks it passes, **`onConfigure` is the one you must not drop.** It is
74
+ how per-instance settings reach the running app: the dashboard's Settings → Env
75
+ and Identity tabs POST to `/internal/configure`, and a vertical that supplies no
76
+ hook answers **501** to that call for its whole life — the setting is saved, the
77
+ dashboard reports `delivered: false`, and the app never sees it. That includes the
78
+ `substrat:auth` issuer choice, i.e. the difference between a working login and
79
+ 401-on-everything. The starter stores deliveries in `config-do.ts` and reads them
80
+ back through `resolveScopedEnvSpec` (`instanceConfig`). Read settings that way and
81
+ never off `env` directly: an `envSpec` default rides as a worker binding shared by
82
+ every install of one serving script, so `env.FOO` is the same string for every
83
+ tenant no matter what any of them saved. Declare a setting in **both**
84
+ `src/manifest.ts` (`SHOP_ENV`) and package.json `substrat.envSpec` — `substrat
85
+ push` reads the JSON, not the TypeScript.
63
86
 
64
87
  ## The rules (non-negotiable)
65
88
 
66
89
  **Module code** = everything reachable from a `ModuleRegistration` (operations,
67
- consumers). Rules 1–4 are enforced mechanically by `boundary-lint`.
68
-
69
- 1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter, or
70
- `node:*` in module code.
90
+ consumers). Rules 1–5 are enforced mechanically by `boundary-lint`.
91
+
92
+ 1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter,
93
+ `node:*`, or `cloudflare:workers` in module code. That last one is not a style rule:
94
+ it exports an ambient `env`, so a single import hands module code every binding and
95
+ secret your worker declares — including its own `SCOPE` Durable Object namespace,
96
+ which reaches *another scope's* data. `ctx.sql` is closed over one scope and cannot.
97
+ Capabilities arrive on `ctx`; `DurableObject` is imported in harness code
98
+ (`worker.ts`, `*-do.ts`), never here.
71
99
  2. **No `fetch` / network in module code.** It would hold the scope's transaction open on
72
100
  a third party. The sanctioned path is a **connector**: emit a fat event, register a
73
101
  handler that runs outside the transaction. An integration is never impossible because
@@ -79,16 +107,22 @@ consumers). Rules 1–4 are enforced mechanically by `boundary-lint`.
79
107
  the shortcut *works* and silently welds you to an engine's private schema forever. Need
80
108
  extra data on an engine entity? Add **your own side table keyed by the engine's id** —
81
109
  never a column upstream.
82
- 5. **Every operation checks a permission first.** `assertAllowed(await ctx.check(PERM))`
110
+ 5. **Time comes from `ctx.now()`.** Module code has no other clock — `new Date()` and
111
+ `Date.now()` are banned exactly like `node:*`. It is the same instant for the whole
112
+ operation, so your rows and the events announcing them agree about when. Store it as
113
+ ISO text, never an epoch integer. Because the host injects the clock, a scenario can
114
+ test elapsed time (`manualClock` from `@substrat-run/kernel`) instead of sleeping or
115
+ shrinking the window to zero — the workaround that proves nothing.
116
+ 6. **Every operation checks a permission first.** `assertAllowed(await ctx.check(PERM))`
83
117
  is the first line.
84
- 6. **Every mutation emits a fat event** — a consumer must never need a cross-module read.
85
- 7. **Never fork an engine.** Extend by composition. If you must fork, the engine drew its
118
+ 7. **Every mutation emits a fat event** — a consumer must never need a cross-module read.
119
+ 8. **Never fork an engine.** Extend by composition. If you must fork, the engine drew its
86
120
  line wrong — that's design feedback, not a coding problem.
87
- 8. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers
121
+ 9. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers
88
122
  (`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
89
- 9. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
90
- hand-roll a hash to dodge an import ban.
91
- 10. **Parse, don't trust.** Zod at every boundary — but import `z` from
123
+ 10. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
124
+ hand-roll a hash to dodge an import ban.
125
+ 11. **Parse, don't trust.** Zod at every boundary — but import `z` from
92
126
  `@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across
93
127
  copies or majors; composing a contracts schema into one built from a separate `zod`
94
128
  fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the
@@ -105,7 +139,7 @@ This is also what lets a portal permission-walk reach the owner.
105
139
 
106
140
  ```sh
107
141
  npm test # the scenario, including the denials
108
- npx @substrat-run/boundary-lint # the layer rules (1–4)
142
+ npx @substrat-run/boundary-lint # the layer rules (1–5)
109
143
  npm run typecheck
110
144
  ```
111
145
 
@@ -0,0 +1,86 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+
3
+ /**
4
+ * The key the platform delivers a scope's identity-provider choice under.
5
+ *
6
+ * It lives HERE rather than in `worker.ts` for a runtime reason worth knowing: workerd
7
+ * requires every named export of the entry module to be a handler or a Durable Object
8
+ * class, so exporting a plain constant from `worker.ts` makes the whole worker fail to
9
+ * boot — with a `tsc`-clean tree and a green test suite. Config vocabulary belongs with
10
+ * the config store anyway.
11
+ */
12
+ export const AUTH_CONFIG_KEY = 'substrat:auth';
13
+
14
+ /**
15
+ * The vertical's own per-instance CONFIG store — the durable half of
16
+ * `/internal/configure`.
17
+ *
18
+ * The platform delivers per-install settings (the dashboard's Settings → Env and
19
+ * Identity tabs) by POSTing them to `/internal/configure` on this worker. A vertical
20
+ * that supplies no `onConfigure` hook answers 501 to that call for its whole life:
21
+ * the dashboard records the setting, reports `delivered: false`, and the running app
22
+ * never sees it. This DO is what makes the hook answerable.
23
+ *
24
+ * It is a HARNESS store, not module code — the config a scope runs on is not domain
25
+ * data, and it must survive a scope-DO storage wipe (a restore, a rebind), so it
26
+ * deliberately lives outside the scope's own DO. One DO per TENANT, rows keyed by
27
+ * scope; the table matches `scope_config` in `@substrat-run/vertical-auth`'s
28
+ * `IdentityDO` exactly, so a project that later adopts vertical-auth for real logins
29
+ * swaps the binding and keeps its rows.
30
+ *
31
+ * Sandbox-clean (D-18): this is one of the deployment's OWN Durable Object classes,
32
+ * declared in package.json `substrat.runtimeNeeds.stores`. No control-plane binding,
33
+ * no service binding — the platform refuses those.
34
+ */
35
+ export class ConfigDO extends DurableObject<Record<string, never>> {
36
+ private ready = false;
37
+
38
+ /** Lazily create the table — cheaper than a blockConcurrencyWhile on every wake. */
39
+ private init(): void {
40
+ if (this.ready) return;
41
+ this.ctx.storage.sql.exec(
42
+ `CREATE TABLE IF NOT EXISTS scope_config (
43
+ scope_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
44
+ PRIMARY KEY (scope_id, key))`,
45
+ );
46
+ this.ready = true;
47
+ }
48
+
49
+ /**
50
+ * Upsert config delivered for one scope. Key-by-key rather than a replace, so a
51
+ * partial delivery composes with what is already there; idempotent, so the
52
+ * platform's reconciliation sweep can re-run it safely.
53
+ */
54
+ async setScopeConfig(scopeId: string, entries: Array<{ key: string; value: string }>): Promise<void> {
55
+ this.init();
56
+ for (const { key, value } of entries) {
57
+ this.ctx.storage.sql.exec(
58
+ 'INSERT OR REPLACE INTO scope_config (scope_id, key, value) VALUES (?, ?, ?)',
59
+ scopeId, key, value,
60
+ );
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The config delivered to one scope, as a plain map — exactly the `delivered`
66
+ * argument of `resolveScopedEnvSpec(spec, env, delivered)`. Reading it back is what
67
+ * keeps the hook from being write-only: an env-spec key set per install must be
68
+ * overlaid here, because env-spec defaults ride as worker bindings SHARED by every
69
+ * install of one serving script, so reading `env` alone always yields the shared
70
+ * default no matter what the tenant saved.
71
+ */
72
+ async getScopeConfig(scopeId: string): Promise<Record<string, string>> {
73
+ this.init();
74
+ const config: Record<string, string> = {};
75
+ for (const row of this.ctx.storage.sql.exec('SELECT key, value FROM scope_config WHERE scope_id = ?', scopeId)) {
76
+ config[row.key as string] = row.value as string;
77
+ }
78
+ return config;
79
+ }
80
+ }
81
+
82
+ /** The typed stub surface — what `worker.ts` calls across the DO boundary. */
83
+ export interface ConfigDo {
84
+ setScopeConfig(scopeId: string, entries: Array<{ key: string; value: string }>): Promise<void>;
85
+ getScopeConfig(scopeId: string): Promise<Record<string, string>>;
86
+ }
@@ -1,4 +1,4 @@
1
- import { moduleManifest, permissionKey } from '@substrat-run/contracts';
1
+ import { moduleManifest, permissionKey, type EnvVarSpec } from '@substrat-run/contracts';
2
2
 
3
3
  // ============================================================================
4
4
  // The vertical's MANIFEST — the reviewable contract the kernel reads at
@@ -19,6 +19,32 @@ export const SHOP_PERM = {
19
19
  bikeManage: permissionKey.parse('bike:manage'),
20
20
  };
21
21
 
22
+ /**
23
+ * The vertical's DECLARED per-instance settings — the allow-list of config keys this
24
+ * app consumes, and what the dashboard renders a form from. A key not declared here is
25
+ * a key the app cannot read, however it is delivered.
26
+ *
27
+ * Read them with `resolveScopedEnvSpec(SHOP_ENV, env, delivered)` (never `env.FOO`
28
+ * directly): precedence is delivered > env > default, and only that overlay sees a
29
+ * value one tenant saved for one install — a spec `default` rides as a worker binding
30
+ * shared by every install of the serving script.
31
+ *
32
+ * MIRRORED in package.json `substrat.envSpec` (what `substrat push` carries — it reads
33
+ * JSON, not TS); keep the two in sync.
34
+ */
35
+ export const SHOP_ENV: EnvVarSpec[] = [
36
+ {
37
+ key: 'SHOP_NAME',
38
+ label: 'Workshop name',
39
+ description: 'The workshop name shown to customers. Set per install.',
40
+ placeholder: 'Söder Cykel & Service',
41
+ default: 'Substrat Bike Shop',
42
+ required: false,
43
+ secret: false,
44
+ group: 'General',
45
+ },
46
+ ];
47
+
22
48
  export const bikeShopManifest = moduleManifest.parse({
23
49
  id: 'bikeshop',
24
50
  version: '0.0.1',
@@ -44,5 +70,6 @@ export const bikeShopManifest = moduleManifest.parse({
44
70
  { entityType: 'bike', parentType: 'customer' },
45
71
  { entityType: 'workorder', parentType: 'bike' },
46
72
  ],
73
+ envSpec: SHOP_ENV,
47
74
  entitlementKey: 'bikeshop',
48
75
  });
@@ -71,7 +71,7 @@ const createCustomerOp: OperationHandler<
71
71
  const id = ulid();
72
72
  ctx.sql.exec(
73
73
  `INSERT INTO shop_customers (id, number, name, phone, created_at) VALUES (?, ?, ?, ?, ?)`,
74
- [id, input.number, input.name, input.phone ?? null, new Date().toISOString()],
74
+ [id, input.number, input.name, input.phone ?? null, ctx.now()],
75
75
  );
76
76
  return ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [id])[0]!;
77
77
  };
@@ -101,7 +101,7 @@ const registerBikeOp: OperationHandler<
101
101
  const id = ulid();
102
102
  ctx.sql.exec(
103
103
  `INSERT INTO shop_bikes (id, customer_id, label, frame_no, created_at) VALUES (?, ?, ?, ?, ?)`,
104
- [id, customer.id, input.label, input.frameNo ?? null, new Date().toISOString()],
104
+ [id, customer.id, input.label, input.frameNo ?? null, ctx.now()],
105
105
  );
106
106
  // Record the bike → customer edge the manifest declared, so the portal walk
107
107
  // (workorder → bike → customer) can resolve an entity-narrowed grant.
@@ -0,0 +1,140 @@
1
+ import type { Context, Hono } from 'hono';
2
+ import { classifyError } from '@substrat-run/vertical-host';
3
+ import type { ScopeStub } from '@substrat-run/kernel';
4
+
5
+ /**
6
+ * The bike shop's HTTP API — ONE route table, adapter- and auth-agnostic.
7
+ *
8
+ * Both entrypoints mount this: `server.ts` (node, pure-SQLite adapter, `x-principal`
9
+ * dev auth) and `worker.ts` (Cloudflare, Durable-Object adapter, the auth seam). Each
10
+ * supplies a `resolveStub` that authenticates the caller its own way and returns a
11
+ * capability `ScopeStub`; every route here is a thin wrapper over ONE operation, with
12
+ * no business logic — the rules live in an operation or an engine.
13
+ *
14
+ * Sharing the table is the point. A route added to only one entrypoint is a surface
15
+ * that exists in dev and 404s in production (or the reverse), and nothing catches it
16
+ * until deploy: the scenario tests call operations directly and never boot either host.
17
+ * Add a route HERE and it is live on both.
18
+ *
19
+ * What each entrypoint still owns is only what is genuinely its own: how it builds a
20
+ * host, how it resolves a caller, and its own auth-shaped routes (`/api/cast` in dev,
21
+ * `/api/me` in the worker) — those answer "who am I on THIS host" and cannot be shared.
22
+ */
23
+ export type ResolveStub = (c: Context) => Promise<ScopeStub>;
24
+
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): void {
27
+ const S = resolveStub;
28
+ const body = (c: Context) => c.req.json<Record<string, unknown>>();
29
+
30
+ /**
31
+ * One error vocabulary, shared with the platform surface: `classifyError`
32
+ * (@substrat-run/vertical-host) is the same function `mountPlatformSurface` uses, so a
33
+ * permission denial is 403, a missing thing 404, a broken invariant 409, a runtime
34
+ * fault 502 — identically on both hosts. "No opinion" becomes the caller's 400.
35
+ *
36
+ * In `worker.ts` this handler is REPLACED: Hono keeps only the last-registered
37
+ * `onError`, and `mountPlatformSurface` installs its own. That is harmless precisely
38
+ * because both are built on `classifyError` — same input, same answer. Registering it
39
+ * here is what gives `server.ts`, which mounts no platform surface, the same behaviour.
40
+ */
41
+ app.onError((err, c) => {
42
+ const seen = classifyError(err);
43
+ if (seen) return c.json({ error: seen.message }, seen.status);
44
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
45
+ });
46
+
47
+ // -- generic invoke ---------------------------------------------------------
48
+ // The kernel checks a permission inside EVERY operation, so a generic route is
49
+ // exactly as safe as one route per operation. It is the escape hatch that keeps a
50
+ // new operation reachable before it has a named route — on BOTH hosts, deliberately.
51
+ app.post('/api/invoke', async (c) => {
52
+ const { op, input } = await c.req.json<{ op: string; input?: unknown }>();
53
+ return c.json((await (await S(c)).invoke(op, input)) ?? null);
54
+ });
55
+
56
+ // -- customers, bikes, price list (the vertical's own tables) ---------------
57
+ app.get('/api/customers', async (c) => c.json(await (await S(c)).invoke('shop/list-customers')));
58
+ app.post('/api/customers', async (c) =>
59
+ c.json(await (await S(c)).invoke('shop/create-customer', await c.req.json())),
60
+ );
61
+ app.post('/api/customers/:id/bikes', async (c) =>
62
+ c.json(
63
+ await (await S(c)).invoke('shop/register-bike', {
64
+ customerId: c.req.param('id'),
65
+ ...(await body(c)),
66
+ }),
67
+ ),
68
+ );
69
+ app.get('/api/prices', async (c) => c.json(await (await S(c)).invoke('shop/price-list')));
70
+ app.post('/api/prices', async (c) =>
71
+ c.json(await (await S(c)).invoke('shop/upsert-price', await c.req.json())),
72
+ );
73
+
74
+ // -- repairs ---------------------------------------------------------------
75
+ // create/complete/close are the VERTICAL's operations (they wrap the engine and own
76
+ // the pricing moment); assign/start/report/get/list are the ENGINE's own, invoked
77
+ // directly. Which is which is the composition boundary, visible right here.
78
+ app.get('/api/repairs', async (c) =>
79
+ c.json(await (await S(c)).invoke('workorder/list', { status: c.req.query('status') })),
80
+ );
81
+ app.post('/api/repairs', async (c) =>
82
+ c.json(await (await S(c)).invoke('shop/create-repair', await c.req.json())),
83
+ );
84
+ app.get('/api/repairs/:id', async (c) =>
85
+ c.json(await (await S(c)).invoke('workorder/get', { orderId: c.req.param('id') })),
86
+ );
87
+ app.get('/api/repairs/:id/timeline', async (c) =>
88
+ c.json(
89
+ await (await S(c)).invoke('shop/timeline', {
90
+ entityType: 'workorder',
91
+ entityId: c.req.param('id'),
92
+ }),
93
+ ),
94
+ );
95
+ app.post('/api/repairs/:id/assign', async (c) =>
96
+ c.json(
97
+ await (await S(c)).invoke('workorder/assign', {
98
+ orderId: c.req.param('id'),
99
+ ...(await body(c)),
100
+ }),
101
+ ),
102
+ );
103
+ app.post('/api/repairs/:id/start', async (c) =>
104
+ c.json(await (await S(c)).invoke('workorder/start', { orderId: c.req.param('id') })),
105
+ );
106
+ app.post('/api/repairs/:id/time', async (c) =>
107
+ c.json(
108
+ await (await S(c)).invoke('workorder/report-time', {
109
+ orderId: c.req.param('id'),
110
+ ...(await body(c)),
111
+ }),
112
+ ),
113
+ );
114
+ app.post('/api/repairs/:id/material', async (c) =>
115
+ c.json(
116
+ await (await S(c)).invoke('workorder/report-material', {
117
+ orderId: c.req.param('id'),
118
+ ...(await body(c)),
119
+ }),
120
+ ),
121
+ );
122
+ app.post('/api/repairs/:id/complete', async (c) =>
123
+ c.json(await (await S(c)).invoke('shop/complete-repair', { orderId: c.req.param('id') })),
124
+ );
125
+ app.post('/api/repairs/:id/close', async (c) =>
126
+ c.json(await (await S(c)).invoke('shop/close-repair', { orderId: c.req.param('id') })),
127
+ );
128
+
129
+ // -- the customer portal (the per-entity proof walk) ------------------------
130
+ app.get('/api/portal/repairs', async (c) => c.json(await (await S(c)).invoke('shop/portal-repairs')));
131
+
132
+ // -- invoicing (the sibling engine, fed by event) ---------------------------
133
+ app.get('/api/invoicing', async (c) => c.json(await (await S(c)).invoke('invoicing/list')));
134
+ app.get('/api/invoicing/:id', async (c) =>
135
+ c.json(await (await S(c)).invoke('invoicing/get', { underlagId: c.req.param('id') })),
136
+ );
137
+ app.post('/api/invoicing/:id/export', async (c) =>
138
+ c.json(await (await S(c)).invoke('invoicing/export', { underlagId: c.req.param('id') })),
139
+ );
140
+ }
@@ -7,12 +7,14 @@ import type { Context } from 'hono';
7
7
  import { PermissionDenied, type ScopeStub } from '@substrat-run/kernel';
8
8
  import type { PrincipalId } from '@substrat-run/contracts';
9
9
  import { buildBikeShopHost, seedBikeShop, type BikeShopWorld } from './seed.js';
10
+ import { mountApi } from './routes.js';
10
11
 
11
12
  // ============================================================================
12
- // A deliberately THIN dev API. Each route authenticates (a dev principal picker
13
- // via the `x-principal` header — a real deployment swaps in a session), gets the
14
- // scope, and invokes ONE operation. There is no business logic here: every rule
15
- // lives in an operation or an engine.
13
+ // The DEV entrypoint. It owns exactly three things — a SQLite host on disk, the
14
+ // `x-principal` persona picker, and the port — and then mounts `routes.ts`, the
15
+ // same route table `worker.ts` mounts. There is no business logic here and no
16
+ // route here either: a route added to this file would exist in dev and 404 in
17
+ // production, which is the one failure this split exists to prevent.
16
18
  // ============================================================================
17
19
 
18
20
  const dataDir = join(dirname(fileURLToPath(import.meta.url)), '..', '.data');
@@ -44,100 +46,13 @@ function stub(c: Context): Promise<ScopeStub> {
44
46
 
45
47
  const app = new Hono();
46
48
 
47
- app.onError((err, c) => {
48
- const message = err instanceof Error ? err.message : String(err);
49
- if (err instanceof PermissionDenied) return c.json({ error: message }, 403);
50
- if (/invalid transition|immutable|already/.test(message)) return c.json({ error: message }, 409);
51
- if (/not found|unknown scope|unknown operation/.test(message)) return c.json({ error: message }, 404);
52
- return c.json({ error: message }, 400);
53
- });
54
-
49
+ // The persona picker — genuinely dev-only, so it stays out of the shared table.
50
+ // Its ABSENCE in the worker is how a client can tell it is talking to a real
51
+ // deployment; the worker answers `/api/me` instead.
55
52
  app.get('/api/cast', (c) => c.json(CAST));
56
53
 
57
- // Customers, bikes, price list (the vertical's own tables).
58
- app.get('/api/customers', async (c) => c.json(await (await stub(c)).invoke('shop/list-customers')));
59
- app.post('/api/customers', async (c) =>
60
- c.json(await (await stub(c)).invoke('shop/create-customer', await c.req.json())),
61
- );
62
- app.post('/api/customers/:id/bikes', async (c) =>
63
- c.json(
64
- await (await stub(c)).invoke('shop/register-bike', {
65
- customerId: c.req.param('id'),
66
- ...(await c.req.json<Record<string, unknown>>()),
67
- }),
68
- ),
69
- );
70
- app.get('/api/prices', async (c) => c.json(await (await stub(c)).invoke('shop/price-list')));
71
- app.post('/api/prices', async (c) =>
72
- c.json(await (await stub(c)).invoke('shop/upsert-price', await c.req.json())),
73
- );
74
-
75
- // Repairs — the vertical's create/complete/close wrap the engine; assign/start/
76
- // report/get/list are the engine's own operations, invoked directly.
77
- app.get('/api/repairs', async (c) =>
78
- c.json(await (await stub(c)).invoke('workorder/list', { status: c.req.query('status') })),
79
- );
80
- app.post('/api/repairs', async (c) =>
81
- c.json(await (await stub(c)).invoke('shop/create-repair', await c.req.json())),
82
- );
83
- app.get('/api/repairs/:id', async (c) =>
84
- c.json(await (await stub(c)).invoke('workorder/get', { orderId: c.req.param('id') })),
85
- );
86
- app.get('/api/repairs/:id/timeline', async (c) =>
87
- c.json(
88
- await (await stub(c)).invoke('shop/timeline', {
89
- entityType: 'workorder',
90
- entityId: c.req.param('id'),
91
- }),
92
- ),
93
- );
94
- app.post('/api/repairs/:id/assign', async (c) =>
95
- c.json(
96
- await (await stub(c)).invoke('workorder/assign', {
97
- orderId: c.req.param('id'),
98
- ...(await c.req.json<Record<string, unknown>>()),
99
- }),
100
- ),
101
- );
102
- app.post('/api/repairs/:id/start', async (c) =>
103
- c.json(await (await stub(c)).invoke('workorder/start', { orderId: c.req.param('id') })),
104
- );
105
- app.post('/api/repairs/:id/time', async (c) =>
106
- c.json(
107
- await (await stub(c)).invoke('workorder/report-time', {
108
- orderId: c.req.param('id'),
109
- ...(await c.req.json<Record<string, unknown>>()),
110
- }),
111
- ),
112
- );
113
- app.post('/api/repairs/:id/material', async (c) =>
114
- c.json(
115
- await (await stub(c)).invoke('workorder/report-material', {
116
- orderId: c.req.param('id'),
117
- ...(await c.req.json<Record<string, unknown>>()),
118
- }),
119
- ),
120
- );
121
- app.post('/api/repairs/:id/complete', async (c) =>
122
- c.json(await (await stub(c)).invoke('shop/complete-repair', { orderId: c.req.param('id') })),
123
- );
124
- app.post('/api/repairs/:id/close', async (c) =>
125
- c.json(await (await stub(c)).invoke('shop/close-repair', { orderId: c.req.param('id') })),
126
- );
127
-
128
- // The customer portal — the per-entity proof walk.
129
- app.get('/api/portal/repairs', async (c) =>
130
- c.json(await (await stub(c)).invoke('shop/portal-repairs')),
131
- );
132
-
133
- // Invoicing (the sibling engine, fed by event).
134
- app.get('/api/invoicing', async (c) => c.json(await (await stub(c)).invoke('invoicing/list')));
135
- app.get('/api/invoicing/:id', async (c) =>
136
- c.json(await (await stub(c)).invoke('invoicing/get', { underlagId: c.req.param('id') })),
137
- );
138
- app.post('/api/invoicing/:id/export', async (c) =>
139
- c.json(await (await stub(c)).invoke('invoicing/export', { underlagId: c.req.param('id') })),
140
- );
54
+ // Everything else — including `/api/invoke` and the shared error envelope.
55
+ mountApi(app, stub);
141
56
 
142
57
  const PORT = Number(process.env.PORT ?? 8873);
143
58
  serve({ fetch: app.fetch, port: PORT });
@@ -2,9 +2,10 @@
2
2
  * This vertical as a deployable Cloudflare Worker — SANDBOX-CLEAN and
3
3
  * control-plane-less: the shape `substrat push` deploys into the platform's
4
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.
5
+ * (kernel + engines + this vertical, bundled), `SWEEPER` (the deployment's own
6
+ * timer, #461) and `CONFIG` (per-instance settings delivered by the platform);
7
+ * no CONTROL_PLANE binding, no service bindings, no ASSETS binding — the
8
+ * platform refuses those.
8
9
  *
9
10
  * `substrat push` derives the deploy config from `substrat.runtimeNeeds` in
10
11
  * package.json (entry = this file, stores = the DO classes exported here) —
@@ -25,8 +26,10 @@ import type { Context } from 'hono';
25
26
  import { HTTPException } from 'hono/http-exception';
26
27
  import {
27
28
  principalId,
29
+ resolveScopedEnvSpec,
28
30
  scopeId,
29
31
  tenantId,
32
+ z,
30
33
  type PrincipalId,
31
34
  type ScopeId,
32
35
  type TenantId,
@@ -41,10 +44,21 @@ import {
41
44
  import { readRoutedNode, RouterAssertionError, type ScopeStub } from '@substrat-run/kernel';
42
45
  import { mountPlatformSurface } from '@substrat-run/vertical-host';
43
46
  import { MODULES, OWNER_ROLE_KEY, ROLES } from './provision.js';
47
+ import { SHOP_ENV } from './manifest.js';
48
+ import { mountApi } from './routes.js';
49
+ import { AUTH_CONFIG_KEY, ConfigDO, type ConfigDo } from './config-do.js';
44
50
 
45
51
  /** The scope-DO class = the app binary: kernel + engines + this vertical, bundled. */
46
52
  export const ScopeDO = defineScopeDO(MODULES, {});
47
53
 
54
+ /**
55
+ * The per-instance config store (`config-do.ts`) — one per tenant, rows keyed by scope.
56
+ * Declared as a store in package.json `substrat.runtimeNeeds.stores`, like `ScopeDO`
57
+ * above and `SweeperDO` below. Re-exported because workerd resolves a DO class from the
58
+ * ENTRY module's exports; defining it in another file is fine, hiding it here is not.
59
+ */
60
+ export { ConfigDO };
61
+
48
62
  /**
49
63
  * The deployment's own timer (#461): a roster-keeping singleton whose alarm runs
50
64
  * each provisioned scope's due recurring work — executor retries and any
@@ -82,6 +96,8 @@ interface Env {
82
96
  SCOPE: DurableObjectNamespace;
83
97
  /** The roster-keeping sweep singleton — the deployment's own timer (#461). */
84
98
  SWEEPER: DurableObjectNamespace;
99
+ /** Per-instance config delivered by the platform (`/internal/configure`). */
100
+ CONFIG: DurableObjectNamespace;
85
101
  /** Local dev only: when 'true', trust the `x-principal` header. NEVER set in prod. */
86
102
  ALLOW_DEV_HEADER?: string;
87
103
  /** Shared secret the router presents (how this worker knows the asserted node is real). */
@@ -110,6 +126,49 @@ function hostFor(env: Env): CloudflareScopeHost {
110
126
  return host;
111
127
  }
112
128
 
129
+ /** This tenant's config DO — one per tenant, holding a row set per scope. */
130
+ function configDo(env: Env, node: Node): DurableObjectStub & ConfigDo {
131
+ return env.CONFIG.get(env.CONFIG.idFromName(node.tenantId)) as DurableObjectStub & ConfigDo;
132
+ }
133
+
134
+ /**
135
+ * The scope's delivered auth choice. Parsed LENIENTLY on purpose: an absent or
136
+ * malformed entry means "nothing delivered", never a throw, so a bad delivery can
137
+ * never lock an instance out of its own login.
138
+ */
139
+ const authChoice = z.object({
140
+ mode: z.literal('oidc'),
141
+ issuer: z.string().min(1),
142
+ clientId: z.string().min(1).optional(),
143
+ clientSecret: z.string().min(1).optional(),
144
+ });
145
+
146
+ /**
147
+ * Everything this instance was configured with, in ONE DO hop: the ordinary declared
148
+ * settings (`SHOP_ENV`) resolved delivered > env > default, and the structured
149
+ * `substrat:auth` choice the dashboard's Identity tab sends.
150
+ *
151
+ * Reading settings THROUGH this — rather than off `env` — is the whole reason the
152
+ * `/internal/configure` hook exists: a spec `default` rides as a worker binding shared
153
+ * by every install of one serving script, so `env.SHOP_NAME` is the same string for
154
+ * every tenant no matter what any of them saved.
155
+ */
156
+ async function instanceConfig(env: Env, node: Node) {
157
+ const delivered = await configDo(env, node).getScopeConfig(node.scopeId);
158
+ const settings = resolveScopedEnvSpec(SHOP_ENV, env as unknown as Record<string, unknown>, delivered).values;
159
+ let identity: z.infer<typeof authChoice> | null = null;
160
+ const raw = delivered[AUTH_CONFIG_KEY];
161
+ if (raw) {
162
+ try {
163
+ const parsed = authChoice.safeParse(JSON.parse(raw));
164
+ identity = parsed.success ? parsed.data : null;
165
+ } catch {
166
+ identity = null;
167
+ }
168
+ }
169
+ return { settings, identity };
170
+ }
171
+
113
172
  /**
114
173
  * THE AUTH SEAM (see the header comment): resolve the caller to a PrincipalId,
115
174
  * or null for nobody. Replace the body with a real session/bearer verifier
@@ -127,25 +186,49 @@ async function authenticatedPrincipal(req: Request, env: Env): Promise<Principal
127
186
  async function stub(c: Context<{ Bindings: Env }>): Promise<ScopeStub> {
128
187
  const node = nodeFor(c.req.raw, c.env);
129
188
  const principal = await authenticatedPrincipal(c.req.raw, c.env);
130
- if (!principal) throw new HTTPException(401, { message: 'unauthorized' });
189
+ if (!principal) throw new HTTPException(401, { message: await unauthorizedReason(c.env, node) });
131
190
  return hostFor(c.env).getScope(principal, node.tenantId, node.scopeId);
132
191
  }
133
192
 
193
+ /**
194
+ * Why the caller is nobody — a DIAGNOSIS, not a bare "unauthorized".
195
+ *
196
+ * The expensive case to debug is the one that looks like a platform bug and is not:
197
+ * a tenant picks an identity provider in the dashboard, the platform delivers it here
198
+ * successfully, and every request is still 401 — because this starter ships no auth.
199
+ * Saying so, and naming the seam, is the difference between a five-minute fix and a
200
+ * support thread. Only runs on the failure path, so it costs a DO hop on 401s alone.
201
+ */
202
+ async function unauthorizedReason(env: Env, node: Node): Promise<string> {
203
+ try {
204
+ const { identity } = await instanceConfig(env, node);
205
+ if (identity) {
206
+ return `unauthorized — this instance has an identity provider configured (${identity.issuer}), but this vertical has not wired it up yet: implement \`authenticatedPrincipal\` in src/worker.ts (the auth seam)`;
207
+ }
208
+ } catch {
209
+ // The config store is unreachable — that is not the caller's problem to hear about.
210
+ }
211
+ return 'unauthorized';
212
+ }
213
+
134
214
  const app = new Hono<{ Bindings: Env }>();
135
215
 
136
- // Who am I — resolves the caller without invoking anything.
216
+ // Who am I, and what instance am I on — resolves the caller without invoking
217
+ // anything. Auth-shaped and host-specific, so it stays OUT of the shared table:
218
+ // the dev server answers `/api/cast` instead, and a client can tell the two apart.
137
219
  app.get('/api/me', async (c) => {
220
+ const node = nodeFor(c.req.raw, c.env);
138
221
  const principal = await authenticatedPrincipal(c.req.raw, c.env);
139
- if (!principal) return c.json({ error: 'unauthorized' }, 401);
140
- return c.json({ principal });
222
+ if (!principal) return c.json({ error: await unauthorizedReason(c.env, node) }, 401);
223
+ const { settings, identity } = await instanceConfig(c.env, node);
224
+ return c.json({ principal, settings, identity: identity ? { issuer: identity.issuer } : null });
141
225
  });
142
226
 
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
- });
227
+ // ── The vertical's API — the SAME table `server.ts` mounts (src/routes.ts) ───
228
+ // Including `/api/invoke`. Mounted BEFORE the platform surface: Hono keeps only the
229
+ // last-registered `onError`, so the platform's envelope wins for the whole app — which
230
+ // is harmless because both handlers classify through the same `classifyError`.
231
+ mountApi(app, stub);
149
232
 
150
233
  // ── /internal/* — the platform-gated management contract ────────────────────
151
234
  // The control plane provisions, heals, inspects and restores installs through
@@ -173,6 +256,15 @@ mountPlatformSurface<Env>(app, {
173
256
  onDeleteScope: async (env, s) => {
174
257
  await sweeper(env).forgetScope(s);
175
258
  },
259
+ // Per-instance config delivery (the dashboard's Settings → Env and Identity tabs).
260
+ // WITHOUT this hook `/internal/configure` answers 501 for the life of the app: the
261
+ // dashboard saves the setting, reports `delivered: false`, and the running worker
262
+ // never sees it — including the `substrat:auth` issuer choice that is the difference
263
+ // between a working login and 401-on-everything. Store it; `instanceConfig` reads it
264
+ // back. Idempotent, so the platform's reconciliation sweep can re-deliver safely.
265
+ onConfigure: async (env, b) => {
266
+ await configDo(env, { tenantId: b.tenantId, scopeId: b.scopeId }).setScopeConfig(b.scopeId, b.entries);
267
+ },
176
268
  });
177
269
 
178
270
  // Unmatched /api/* fails as JSON; everything else gets a pointer, not a UI —
@@ -181,7 +273,7 @@ app.all('/api/*', (c) => c.json({ error: `unknown route: ${new URL(c.req.raw.url
181
273
  app.all('*', (c) =>
182
274
  c.json({
183
275
  service: 'substrat vertical',
184
- api: 'POST /api/invoke { op, input }',
276
+ api: 'POST /api/invoke { op, input } — plus the named routes in src/routes.ts',
185
277
  docs: 'https://substrat.net',
186
278
  }),
187
279
  );
@@ -9,5 +9,5 @@
9
9
  "skipLibCheck": true,
10
10
  "noEmit": true
11
11
  },
12
- "include": ["src/worker.ts", "src/provision.ts", "src/manifest.ts", "src/migrations.ts", "src/module.ts"]
12
+ "include": ["src/worker.ts", "src/routes.ts", "src/config-do.ts", "src/provision.ts", "src/manifest.ts", "src/migrations.ts", "src/module.ts"]
13
13
  }