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/LICENSE +202 -661
- package/index.js +28 -7
- package/package.json +1 -1
- package/template/.substrat/playbook.md +14 -0
- package/template/AGENTS.md +29 -6
- package/template/src/config-do.ts +86 -0
- package/template/src/manifest.ts +28 -1
- package/template/src/routes.ts +140 -0
- package/template/src/server.ts +11 -96
- package/template/src/worker.ts +106 -14
- package/template/tsconfig.worker.json +1 -1
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.
|
|
39
|
-
const ENGINE_WORKORDER = '^0.
|
|
40
|
-
const ENGINE_INVOICING = '^0.
|
|
41
|
-
const BOUNDARY_LINT = '^0.0.
|
|
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
|
|
154
|
-
// (tsconfig.worker.json) — the node config must not
|
|
155
|
-
|
|
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
|
@@ -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
|
|
package/template/AGENTS.md
CHANGED
|
@@ -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/
|
|
48
|
-
src/
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|
package/template/src/manifest.ts
CHANGED
|
@@ -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
|
+
}
|
package/template/src/server.ts
CHANGED
|
@@ -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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
58
|
-
app
|
|
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 });
|