create-substrat 0.6.3 → 0.7.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
@@ -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.76.0';
39
- const ENGINE_WORKORDER = '^0.6.3';
40
- const ENGINE_INVOICING = '^0.7.3';
41
- const BOUNDARY_LINT = '^0.0.7';
38
+ const SUBSTRAT = '^0.83.0';
39
+ const ENGINE_WORKORDER = '^0.7.3';
40
+ const ENGINE_INVOICING = '^0.8.3';
41
+ const BOUNDARY_LINT = '^0.0.8';
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.3",
3
+ "version": "0.7.0",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -277,6 +277,20 @@ Field names mirror the SQL columns, snake_case included — a prettier naming he
277
277
  description of the same rows. Not every table is an entity: an entity is something the
278
278
  platform can point at (attachments hang off one, grants narrow to one, events are about one).
279
279
 
280
+ `primaryKey` defaults to `['id']` — declare it where the identity is something else. The side
281
+ table you add for extra data on an engine's entity is keyed by *that engine's id*
282
+ (`primaryKey: ['workorder_id']`); its identity IS the work order's, and an `id` of its own
283
+ would permit two side rows for one work order. A value-keyed table is keyed by its values
284
+ (`primaryKey: ['customer_id', 'year', 'month']`). It is separate from `key`, which is an
285
+ additional uniqueness rule — a table legitimately has both. An entity with neither an `id`
286
+ field nor a `primaryKey` is refused rather than emitted without one.
287
+
288
+ A **composite** key means the entity cannot be pointed at: attachments, grants, link edges
289
+ and event subjects all need one id, so naming such an entity in `parents`,
290
+ `attachmentTargets`, `relations`, `emits.entity` or a narrowed `permission.entity` is a
291
+ compile error. It is still a full model member with migrations and a row type. A
292
+ single-column key that is not called `id` stays fully pointable.
293
+
280
294
  Behaviour stays prose in `DESIGN.md`. Inventing a way to declare a state *transition* means
281
295
  the boundary slipped.
282
296
 
@@ -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,10 +66,23 @@ 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
 
@@ -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
  });
@@ -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 });