create-substrat 0.2.0 → 0.3.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 +5 -3
- package/package.json +1 -1
- package/template/.substrat/playbook.md +3 -2
- package/template/AGENTS.md +7 -4
- package/template/src/worker.ts +19 -182
package/index.js
CHANGED
|
@@ -20,9 +20,10 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
20
20
|
const TEMPLATE = join(HERE, 'template');
|
|
21
21
|
|
|
22
22
|
// Published today; Substrat is 0.x, so these are caret ranges on the current minor.
|
|
23
|
-
// 0.
|
|
24
|
-
// template's worker
|
|
25
|
-
|
|
23
|
+
// 0.45.0 is the release that ships `@substrat-run/vertical-host` (#510) — the
|
|
24
|
+
// mountPlatformSurface the template's worker mounts — so this pin and that release
|
|
25
|
+
// move together (it also covers `defineScopeSweeperDO`, #461, from 0.40.0).
|
|
26
|
+
const SUBSTRAT = '^0.45.0';
|
|
26
27
|
// Engines version on their own line (0.3.x), independent of the kernel/contracts line.
|
|
27
28
|
const ENGINES = '^0.3.37';
|
|
28
29
|
const BOUNDARY_LINT = '^0.0.5';
|
|
@@ -93,6 +94,7 @@ function packageJson(name) {
|
|
|
93
94
|
'@substrat-run/contracts': SUBSTRAT,
|
|
94
95
|
'@substrat-run/adapter-sqlite': SUBSTRAT,
|
|
95
96
|
'@substrat-run/adapter-cloudflare': SUBSTRAT,
|
|
97
|
+
'@substrat-run/vertical-host': SUBSTRAT,
|
|
96
98
|
'@substrat-run/engine-workorder': ENGINES,
|
|
97
99
|
'@substrat-run/engine-invoicing': ENGINES,
|
|
98
100
|
hono: '^4.6.0',
|
package/package.json
CHANGED
|
@@ -363,8 +363,9 @@ Only if the user asks. Local-first is a legitimate stopping point.
|
|
|
363
363
|
|
|
364
364
|
Substrat runs on Cloudflare via `@substrat-run/adapter-cloudflare` (Durable Objects).
|
|
365
365
|
**This starter is pushable as scaffolded**: `src/worker.ts` is the deploy entry (the
|
|
366
|
-
sandbox-clean shape, with the platform's `/internal/*` management contract already
|
|
367
|
-
|
|
366
|
+
sandbox-clean shape, with the platform's `/internal/*` management contract already mounted
|
|
367
|
+
via `mountPlatformSurface` from `@substrat-run/vertical-host` — you never re-author those
|
|
368
|
+
routes), and package.json already carries the `substrat.runtimeNeeds` block the CLI
|
|
368
369
|
derives the deploy config from (stores, node-compat, build) — you never author wrangler
|
|
369
370
|
config. When you reshape the vertical, keep `src/provision.ts` the single source of
|
|
370
371
|
MODULES/ROLES: both the dev server and the worker register from it, so a module added
|
package/template/AGENTS.md
CHANGED
|
@@ -53,10 +53,13 @@ test/scenario.test.ts the scenario — including the denials
|
|
|
53
53
|
server's SQLite host and the worker's `ScopeDO`), and `substrat push` reads the
|
|
54
54
|
permission registry from it (package.json `substrat.permissions`). Roles or
|
|
55
55
|
modules defined anywhere else will run locally and silently not deploy.
|
|
56
|
-
`worker.ts`
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
`worker.ts` **mounts** the platform's `/internal/*` management contract via
|
|
57
|
+
`mountPlatformSurface` from `@substrat-run/vertical-host` (one call — the routes
|
|
58
|
+
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.
|
|
60
63
|
|
|
61
64
|
## The rules (non-negotiable)
|
|
62
65
|
|
package/template/src/worker.ts
CHANGED
|
@@ -24,14 +24,9 @@ import { Hono } from 'hono';
|
|
|
24
24
|
import type { Context } from 'hono';
|
|
25
25
|
import { HTTPException } from 'hono/http-exception';
|
|
26
26
|
import {
|
|
27
|
-
entitlementGrant,
|
|
28
27
|
principalId,
|
|
29
|
-
projectedIdentityLink,
|
|
30
|
-
queryScopeInput,
|
|
31
|
-
readScopeTableInput,
|
|
32
28
|
scopeId,
|
|
33
29
|
tenantId,
|
|
34
|
-
z,
|
|
35
30
|
type PrincipalId,
|
|
36
31
|
type ScopeId,
|
|
37
32
|
type TenantId,
|
|
@@ -43,13 +38,8 @@ import {
|
|
|
43
38
|
SCOPE_SWEEPER_NAME,
|
|
44
39
|
type ScopeSweeperDo,
|
|
45
40
|
} from '@substrat-run/adapter-cloudflare';
|
|
46
|
-
import {
|
|
47
|
-
|
|
48
|
-
PlatformCallError,
|
|
49
|
-
readRoutedNode,
|
|
50
|
-
RouterAssertionError,
|
|
51
|
-
type ScopeStub,
|
|
52
|
-
} from '@substrat-run/kernel';
|
|
41
|
+
import { readRoutedNode, RouterAssertionError, type ScopeStub } from '@substrat-run/kernel';
|
|
42
|
+
import { mountPlatformSurface } from '@substrat-run/vertical-host';
|
|
53
43
|
import { MODULES, OWNER_ROLE_KEY, ROLES } from './provision.js';
|
|
54
44
|
|
|
55
45
|
/** The scope-DO class = the app binary: kernel + engines + this vertical, bundled. */
|
|
@@ -143,15 +133,6 @@ async function stub(c: Context<{ Bindings: Env }>): Promise<ScopeStub> {
|
|
|
143
133
|
|
|
144
134
|
const app = new Hono<{ Bindings: Env }>();
|
|
145
135
|
|
|
146
|
-
app.onError((err, c) => {
|
|
147
|
-
if (err instanceof HTTPException) return err.getResponse();
|
|
148
|
-
const m = err instanceof Error ? err.message : String(err);
|
|
149
|
-
if (/permission denied/i.test(m)) return c.json({ error: m }, 403);
|
|
150
|
-
if (/not found|unknown scope/i.test(m)) return c.json({ error: m }, 404);
|
|
151
|
-
if (/invalid transition|immutable/i.test(m)) return c.json({ error: m }, 409);
|
|
152
|
-
return c.json({ error: m }, 400);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
136
|
// Who am I — resolves the caller without invoking anything.
|
|
156
137
|
app.get('/api/me', async (c) => {
|
|
157
138
|
const principal = await authenticatedPrincipal(c.req.raw, c.env);
|
|
@@ -168,167 +149,23 @@ app.post('/api/invoke', async (c) => {
|
|
|
168
149
|
|
|
169
150
|
// ── /internal/* — the platform-gated management contract ────────────────────
|
|
170
151
|
// The control plane provisions, heals, inspects and restores installs through
|
|
171
|
-
// these routes.
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
name: z.string().min(1),
|
|
189
|
-
entitlements: z.array(entitlementGrant).optional(),
|
|
190
|
-
identityLinks: z.array(projectedIdentityLink).optional(),
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
// Provision ONE scope on the platform's instruction, CP-lessly: migrate the
|
|
194
|
-
// modules, project this vertical's roles + the tenant's entitlements locally,
|
|
195
|
-
// grant the owner their role at scope level. Platform-secret gated; idempotent.
|
|
196
|
-
app.post('/internal/provision', async (c) => {
|
|
197
|
-
gatePlatform(c);
|
|
198
|
-
const body = provisionBody.parse(await c.req.json());
|
|
199
|
-
await hostFor(c.env).provisionScopeLocal({
|
|
200
|
-
tenantId: body.tenantId,
|
|
201
|
-
scopeId: body.scopeId,
|
|
202
|
-
owner: body.owner,
|
|
203
|
-
roles: ROLES,
|
|
204
|
-
ownerRoleKey: OWNER_ROLE_KEY,
|
|
205
|
-
entitlements: body.entitlements,
|
|
206
|
-
identityLinks: body.identityLinks,
|
|
207
|
-
});
|
|
208
|
-
// Provision is where the deployment learns a scope exists — put it on the
|
|
209
|
-
// sweep roster so its declared schedules run. (Never note a snapshot fork:
|
|
210
|
-
// recurring side effects must not fire off a preview copy, and fork-ness is
|
|
211
|
-
// only knowable platform-side — which is why this rides provision/reconcile,
|
|
212
|
-
// not request traffic.)
|
|
213
|
-
await sweeper(c.env).noteScope(body.tenantId, body.scopeId);
|
|
214
|
-
return c.json({ tenantId: body.tenantId, scopeId: body.scopeId, owner: body.owner }, 201);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
// Repair (reconcile): re-deliver roles/entitlements/identity links to a scope.
|
|
218
|
-
// This starter has no durable owner-of-record store (that lives with real auth
|
|
219
|
-
// — the auth seam), so the owner must be re-supplied; without one the refusal
|
|
220
|
-
// names the remedy instead of healing wrongly.
|
|
221
|
-
const reconcileBody = provisionBody.partial({ owner: true, slug: true, name: true });
|
|
222
|
-
app.post('/internal/reconcile', async (c) => {
|
|
223
|
-
gatePlatform(c);
|
|
224
|
-
const body = reconcileBody.parse(await c.req.json());
|
|
225
|
-
if (!body.owner) {
|
|
226
|
-
throw new HTTPException(409, {
|
|
227
|
-
message:
|
|
228
|
-
'no owner of record: this starter keeps none (an identity store — the auth seam — owns it). ' +
|
|
229
|
-
'Re-run the full install, or wire auth and record the owner durably.',
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
await hostFor(c.env).provisionScopeLocal({
|
|
233
|
-
tenantId: body.tenantId,
|
|
234
|
-
scopeId: body.scopeId,
|
|
235
|
-
owner: body.owner,
|
|
236
|
-
roles: ROLES,
|
|
237
|
-
ownerRoleKey: OWNER_ROLE_KEY,
|
|
238
|
-
entitlements: body.entitlements,
|
|
239
|
-
identityLinks: body.identityLinks,
|
|
240
|
-
});
|
|
241
|
-
// Reconcile is the roster's backfill: scopes provisioned before the sweeper
|
|
242
|
-
// shipped join it on their next platform repair.
|
|
243
|
-
await sweeper(c.env).noteScope(body.tenantId, body.scopeId);
|
|
244
|
-
return c.json({ tenantId: body.tenantId, scopeId: body.scopeId, owner: body.owner });
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// Read-only scope-table introspection (console/dashboard Data view).
|
|
248
|
-
app.get('/internal/tables', async (c) => {
|
|
249
|
-
gatePlatform(c);
|
|
250
|
-
return c.json(await hostFor(c.env).introspectScopeTables(scopeId.parse(c.req.query('scopeId'))));
|
|
251
|
-
});
|
|
252
|
-
app.get('/internal/tables/:table', async (c) => {
|
|
253
|
-
gatePlatform(c);
|
|
254
|
-
const scope = scopeId.parse(c.req.query('scopeId'));
|
|
255
|
-
const input = readScopeTableInput.parse({
|
|
256
|
-
table: c.req.param('table'),
|
|
257
|
-
limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
|
|
258
|
-
offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined,
|
|
259
|
-
});
|
|
260
|
-
return c.json(await hostFor(c.env).introspectScopeTable(scope, input));
|
|
261
|
-
});
|
|
262
|
-
// The SQL console: one read-only statement, enforced in the DO.
|
|
263
|
-
app.post('/internal/query', async (c) => {
|
|
264
|
-
gatePlatform(c);
|
|
265
|
-
const body = queryScopeInput.extend({ scopeId }).parse(await c.req.json());
|
|
266
|
-
try {
|
|
267
|
-
return c.json(await hostFor(c.env).introspectScopeQuery(body.scopeId, { sql: body.sql }));
|
|
268
|
-
} catch (e) {
|
|
269
|
-
if (e instanceof Error && e.message.includes('read-only console')) {
|
|
270
|
-
throw new HTTPException(400, { message: e.message });
|
|
271
|
-
}
|
|
272
|
-
throw e;
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// Platform-intent drain surface: the control plane PULLS pending intents from
|
|
277
|
-
// this deployment's scope DOs and journals outcomes back.
|
|
278
|
-
app.get('/internal/platform-requests', async (c) => {
|
|
279
|
-
gatePlatform(c);
|
|
280
|
-
const t = tenantId.parse(c.req.query('tenantId'));
|
|
281
|
-
const s = scopeId.parse(c.req.query('scopeId'));
|
|
282
|
-
return c.json(await hostFor(c.env).listPlatformRequests(t, s));
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
// Scope-storage lifecycle: snapshot/delete/export/restore/bookmarks/rewind —
|
|
286
|
-
// what `substrat scope pull`/`restore` and the in-place update backout use.
|
|
287
|
-
app.post('/internal/snapshot', async (c) => {
|
|
288
|
-
gatePlatform(c);
|
|
289
|
-
const body = z.object({ sourceScopeId: scopeId, newScopeId: scopeId }).parse(await c.req.json());
|
|
290
|
-
return c.json(await hostFor(c.env).snapshotScopeLocal(body.sourceScopeId, body.newScopeId), 201);
|
|
291
|
-
});
|
|
292
|
-
app.post('/internal/delete-scope', async (c) => {
|
|
293
|
-
gatePlatform(c);
|
|
294
|
-
const body = z.object({ scopeId }).parse(await c.req.json());
|
|
295
|
-
await hostFor(c.env).deleteScopeLocal(body.scopeId);
|
|
296
|
-
// Off the sweep roster too — a deleted scope must not be woken by the alarm.
|
|
297
|
-
await sweeper(c.env).forgetScope(body.scopeId);
|
|
298
|
-
return c.json({ deleted: body.scopeId });
|
|
299
|
-
});
|
|
300
|
-
app.get('/internal/export', async (c) => {
|
|
301
|
-
gatePlatform(c);
|
|
302
|
-
return c.json(await hostFor(c.env).exportScopeLocal(scopeId.parse(c.req.query('scopeId'))));
|
|
303
|
-
});
|
|
304
|
-
app.post('/internal/restore', async (c) => {
|
|
305
|
-
gatePlatform(c);
|
|
306
|
-
const body = z
|
|
307
|
-
.object({
|
|
308
|
-
tenantId: tenantId.optional(),
|
|
309
|
-
scopeId,
|
|
310
|
-
tables: z.array(
|
|
311
|
-
z.object({ name: z.string(), ddl: z.string(), columns: z.array(z.string()), rows: z.array(z.array(z.unknown())) }),
|
|
312
|
-
),
|
|
313
|
-
})
|
|
314
|
-
.parse(await c.req.json());
|
|
315
|
-
const host = hostFor(c.env);
|
|
316
|
-
const result = await host.restoreScopeLocal(body.scopeId, body.tables);
|
|
317
|
-
// Re-project role definitions after an import — a dump may carry tuples but
|
|
318
|
-
// no role definitions; roles are code-defined, so re-projecting is always safe.
|
|
319
|
-
if (body.tenantId) await host.projectRolesLocal(body.tenantId, body.scopeId, ROLES);
|
|
320
|
-
return c.json(result);
|
|
321
|
-
});
|
|
322
|
-
app.get('/internal/bookmarks', async (c) => {
|
|
323
|
-
gatePlatform(c);
|
|
324
|
-
return c.json(await hostFor(c.env).migrationBookmarksLocal(scopeId.parse(c.req.query('scopeId'))));
|
|
325
|
-
});
|
|
326
|
-
app.post('/internal/rewind', async (c) => {
|
|
327
|
-
gatePlatform(c);
|
|
328
|
-
const body = z
|
|
329
|
-
.object({ scopeId, bookmark: z.string().min(1), force: z.boolean().optional() })
|
|
330
|
-
.parse(await c.req.json());
|
|
331
|
-
return c.json(await hostFor(c.env).rewindScopeLocal(body.scopeId, body.bookmark, { force: body.force }));
|
|
152
|
+
// these routes. The whole contract — provision, reconcile, introspection, the
|
|
153
|
+
// read-only SQL console, platform-request drain, snapshot/delete/export/restore,
|
|
154
|
+
// and bookmarks/rewind — plus the guaranteed { error } envelope is authored ONCE
|
|
155
|
+
// in @substrat-run/vertical-host (issue #510); mount it and it cannot drift.
|
|
156
|
+
//
|
|
157
|
+
// This starter's hooks keep the deployment's sweep roster (#461) in step: a newly
|
|
158
|
+
// provisioned scope joins it (so its schedules run), and a deleted one leaves it.
|
|
159
|
+
// Reconcile needs a durable owner-of-record to heal from — this starter keeps none
|
|
160
|
+
// (that lives with real auth, the auth seam), so `resolveOwner` is omitted and
|
|
161
|
+
// /internal/reconcile answers 501 until you wire auth and supply one.
|
|
162
|
+
mountPlatformSurface<Env>(app, {
|
|
163
|
+
platformSecret: (env) => env.PLATFORM_SECRET,
|
|
164
|
+
hostFor,
|
|
165
|
+
roles: ROLES,
|
|
166
|
+
ownerRoleKey: OWNER_ROLE_KEY,
|
|
167
|
+
onProvision: (env, b) => sweeper(env).noteScope(b.tenantId, b.scopeId),
|
|
168
|
+
onDeleteScope: (env, s) => sweeper(env).forgetScope(s),
|
|
332
169
|
});
|
|
333
170
|
|
|
334
171
|
// Unmatched /api/* fails as JSON; everything else gets a pointer, not a UI —
|