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/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
|
}
|