create-substrat 0.6.4 → 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 +27 -6
- package/package.json +1 -1
- 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,9 +35,9 @@ 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.
|
|
38
|
+
const SUBSTRAT = '^0.83.0';
|
|
39
|
+
const ENGINE_WORKORDER = '^0.7.3';
|
|
40
|
+
const ENGINE_INVOICING = '^0.8.3';
|
|
41
41
|
const BOUNDARY_LINT = '^0.0.8';
|
|
42
42
|
|
|
43
43
|
const DOCS = 'https://substrat.net';
|
|
@@ -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
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 });
|
package/template/src/worker.ts
CHANGED
|
@@ -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)
|
|
6
|
-
*
|
|
7
|
-
* binding
|
|
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:
|
|
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
|
|
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:
|
|
140
|
-
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|