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 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.40.0 is the release that ships `defineScopeSweeperDO` (#461), which the
24
- // template's worker imports — this pin and that adapter release move together.
25
- const SUBSTRAT = '^0.40.0';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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
- mounted), and package.json already carries the `substrat.runtimeNeeds` block the CLI
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
@@ -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` carries the platform's `/internal/*` management contract and **the
57
- auth seam** — the dev `x-principal` header is the only caller resolution until
58
- you wire real auth there; deploying with `ALLOW_DEV_HEADER` set is a
59
- cross-tenant hole with a UI.
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
 
@@ -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
- assertPlatformCall,
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. A vertical without them cannot be installed or repaired, so
172
- // keep the FULL set even though your app code never calls them.
173
-
174
- function gatePlatform(c: { env: Env; req: { raw: Request } }): void {
175
- try {
176
- assertPlatformCall(c.req.raw.headers, { expectedSecret: c.env.PLATFORM_SECRET });
177
- } catch (e) {
178
- if (e instanceof PlatformCallError) throw new HTTPException(403, { message: e.message });
179
- throw e;
180
- }
181
- }
182
-
183
- const provisionBody = z.object({
184
- tenantId,
185
- scopeId,
186
- owner: principalId,
187
- slug: z.string().min(1),
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 —