create-substrat 0.10.16 → 0.11.1

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
@@ -17,7 +17,7 @@ import { basename, dirname, join, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
 
19
19
  import { DEV_SERVERS } from './dev-servers.js';
20
- import { TSCONFIG, VITEST_CONFIG } from './project-files.js';
20
+ import { TEST_RUNNER, TSCONFIG, VITEST_CONFIG } from './project-files.js';
21
21
 
22
22
  const HERE = dirname(fileURLToPath(import.meta.url));
23
23
  const TEMPLATE = join(HERE, 'template');
@@ -36,12 +36,12 @@ const TEMPLATE = join(HERE, 'template');
36
36
  // The runtime packages release together off one version line (the changesets `fixed`
37
37
  // group), so one constant is right for all of them. Engines do NOT share a line —
38
38
  // each versions on its own, so one pin per engine, deliberately.
39
- const SUBSTRAT = '^0.139.0';
40
- const ENGINE_WORKORDER = '^0.12.23';
41
- const ENGINE_INVOICING = '^0.11.23';
42
- const BOUNDARY_LINT = '^0.6.1';
43
- const DEV_ISSUER = '^0.2.21';
44
- const ATTACHMENT_EXTRACTORS = '^0.2.0';
39
+ const SUBSTRAT = '^0.141.0';
40
+ const ENGINE_WORKORDER = '^0.12.25';
41
+ const ENGINE_INVOICING = '^0.11.25';
42
+ const BOUNDARY_LINT = '^0.6.2';
43
+ const DEV_ISSUER = '^0.2.23';
44
+ const ATTACHMENT_EXTRACTORS = '^0.2.2';
45
45
 
46
46
  const DOCS = 'https://substrat.net';
47
47
 
@@ -114,8 +114,8 @@ function packageJson(name) {
114
114
  issuer: 'substrat-dev-issuer --personas src/personas.ts',
115
115
  server: 'tsx src/server.ts',
116
116
  test: 'vitest run',
117
- // The live test dashboard in the Browser pane: the `tests` entry of
118
- // .claude/launch.json. `--watch` is explicit, since vitest runs once and exits without a TTY.
117
+ // The live test dashboard: the `tests` entry of .claude/launch.json, opened at the
118
+ // tokenised URL vitest prints. `--watch` is explicit, since vitest runs once and exits without a TTY.
119
119
  'test:ui': 'vitest --watch --ui --open=false -c vitest.ui.config.ts',
120
120
  typecheck: 'tsc --noEmit && tsc -p tsconfig.worker.json --noEmit',
121
121
  'lint:boundaries': 'substrat-boundary-lint',
@@ -144,8 +144,7 @@ function packageJson(name) {
144
144
  concurrently: '^9.0.0',
145
145
  tsx: '^4.19.0',
146
146
  typescript: '^5.6.0',
147
- vitest: '^3.0.0',
148
- '@vitest/ui': '^3.0.0',
147
+ ...TEST_RUNNER,
149
148
  },
150
149
  // Do NOT add `zod` here — import `z` from `@substrat-run/contracts` (AGENTS.md, rule 10).
151
150
  // better-sqlite3 >=13 ships prebuilt binaries for every supported platform and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.10.16",
3
+ "version": "0.11.1",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/project-files.js CHANGED
@@ -52,3 +52,11 @@ export default defineConfig({
52
52
  },
53
53
  });
54
54
  `;
55
+
56
+ /**
57
+ * The test runner a scaffold installs. Here rather than inline in `index.js`'s `packageJson()`
58
+ * so `tools/template-sync.mjs` can hold it to the vitest the workspace runs the template on
59
+ * (#2129): `packages/template-check` tests the template on the catalog's vitest, and a scaffold
60
+ * on a different major is a project nothing on a PR has ever run.
61
+ */
62
+ export const TEST_RUNNER = { vitest: '^5.0.0', '@vitest/ui': '^5.0.0' };
@@ -336,6 +336,11 @@ The page worth opening first on a vertical that serves the Scalar API reference
336
336
  vertical's own origin, so a try-it request carries the session cookie you already have and
337
337
  a 403 in the playground is the permission system working rather than a broken page.
338
338
 
339
+ The `tests` entry is the one whose bare origin shows nothing: the vitest dashboard serves
340
+ only the URL it prints when it starts (`UI started at …/__vitest__/?token=…`) and answers a
341
+ 403 without the token, which guards an API that can write files and run code. Take that URL
342
+ from the server's output; never work around the token.
343
+
339
344
  ## When it breaks — symptom → fix
340
345
 
341
346
  Six failures that have each cost someone a day. What makes them expensive is that none of
@@ -254,13 +254,12 @@ const closeRepairOp: Op<'shop/close-repair'> = async (ctx, input) => {
254
254
  * on their own customer record, so the walk workorder → bike → customer lets
255
255
  * them through for their own repairs and no one else's.
256
256
  *
257
- * Paged by OVER-FETCHING, which is what a permission-filtered walk needs: a page
257
+ * Paged with `pageVisible`, which is what a permission-filtered walk needs: a page
258
258
  * of 20 rows read from the table can leave 3 standing after the proof walk, so the
259
- * fetch size and the page size are not the same number and cannot be made the same
260
- * number. `pageVisible` does the over-fetch and advances the cursor by the last row
261
- * EXAMINED — advancing by the last row RETURNED would re-examine every rejected row
262
- * on the next request, and a page the walk rejects entirely would never advance at
263
- * all. So a SHORT page does not end this walk; only a null `nextCursor` does.
259
+ * fetch size and the page size are not the same number. `pageVisible` reads on past
260
+ * refused rows until the page is full or the budget is spent. It seals the
261
+ * continuation, including on a short page at a budget stop, so a refused row's
262
+ * position never reaches the caller (#2074).
264
263
  */
265
264
  const portalRepairsOp: Op<'shop/portal-repairs'> = async (ctx, input) =>
266
265
  pageVisible(
@@ -268,6 +267,7 @@ const portalRepairsOp: Op<'shop/portal-repairs'> = async (ctx, input) =>
268
267
  input,
269
268
  async (order) =>
270
269
  (await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id })).allowed,
270
+ { continuation: ctx.pageContinuation('workorder:portal-repairs', input) },
271
271
  );
272
272
 
273
273
  /**
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  definePermissions,
3
+ type EntityGrantShape,
3
4
  type PermissionKey,
4
5
  type RoleDefinition,
5
6
  } from '@substrat-run/contracts';
@@ -65,9 +66,18 @@ export const portalPerms: PermissionKey[] = [WO.read];
65
66
  * Entity-narrowed grant SHAPES. The grants themselves are per-principal and
66
67
  * minted at runtime, so they can never be a build artifact; their shape is what
67
68
  * tells a reviewer which keys are reachable outside the role table.
69
+ *
70
+ * `customer` is a BOOTSTRAP shape: a person linked to a customer record is
71
+ * given all of it at once (`grantEntityShape`, never key by key), and when you
72
+ * add a key here, the platform's next reconcile gives it to everyone who
73
+ * already holds the shape. A customer record names no principal and can have
74
+ * several people, so its holder is `'grantee'`: whoever holds a key of it
75
+ * there. That stays true because `ctx.grant` cannot give these keys on a
76
+ * customer. A shape people reach by sharing (`ctx.grant`) is declared without
77
+ * `bootstrap`, and is never topped up.
68
78
  */
69
- export const ENTITY_GRANTS: { entityType: string; permissions: PermissionKey[] }[] = [
70
- { entityType: 'customer', permissions: portalPerms },
79
+ export const ENTITY_GRANTS: EntityGrantShape[] = [
80
+ { entityType: 'customer', permissions: portalPerms, bootstrap: true, holder: 'grantee' },
71
81
  ];
72
82
 
73
83
  /**
@@ -1,5 +1,5 @@
1
1
  import type { Context, Hono } from 'hono';
2
- import { mountOperations, problemResponse, type ResolveStub } from '@substrat-run/vertical-host';
2
+ import { externalInput, externalJson, mountOperations, problemResponse, type ResolveStub } from '@substrat-run/vertical-host';
3
3
  import { bikeShopEngineRoutes, bikeShopInvoicingRoutes, bikeShopOperations } from './operations.js';
4
4
 
5
5
  /**
@@ -76,7 +76,9 @@ export function mountApi(
76
76
  // `shop/complete-repair` wraps) is still callable here, on BOTH hosts.
77
77
  app.post('/api/invoke', async (c) => {
78
78
  const { op, input } = await c.req.json<{ op: string; input?: unknown }>();
79
- return c.json((await (await resolveStub(c)).invoke(op, input)) ?? null);
79
+ // Through the platform's one door (#2073), as the derived routes are: a caller cannot ask
80
+ // for a page's row cursors, and none ever leave in a response.
81
+ return externalJson(c, (await (await resolveStub(c)).invoke(op, externalInput(input))) ?? null);
80
82
  });
81
83
 
82
84
  // Returns what it mounted, in registration order — a test pins the complete
@@ -12,7 +12,7 @@ import {
12
12
  import { ulid } from '@substrat-run/kernel';
13
13
  import { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
14
14
  import { defaultAttachmentExtractors } from '@substrat-run/attachment-extractors';
15
- import { ENTITLEMENT_KEYS, MODULES, OWNER_ROLE_KEY, portalPerms, ROLES } from './provision.js';
15
+ import { ENTITLEMENT_KEYS, ENTITY_GRANTS, MODULES, OWNER_ROLE_KEY, portalPerms, ROLES } from './provision.js';
16
16
 
17
17
  // The provisioning surface (modules, roles, grant shapes) lives in
18
18
  // provision.ts — node-free so the worker bundles it and `substrat push` reads
@@ -86,13 +86,14 @@ async function provisionShop(
86
86
  * Idempotent seed. Everything that mutates the control plane runs only on a
87
87
  * FRESH data dir (guarded by cast.json); on restart the tenants, roles, grants
88
88
  * and entities are already in the SQLite files, so we just rebuild the handle
89
- * object. Safe to call on every server start and on every test.
89
+ * object — and re-apply the portal grants, which are idempotent. Safe to call
90
+ * on every server start and on every test.
90
91
  */
91
92
  export async function seedBikeShop(host: SqliteScopeHost, dir: string): Promise<BikeShopWorld> {
92
93
  const castPath = join(dir, 'cast.json');
93
94
  if (existsSync(castPath)) {
94
95
  const raw = JSON.parse(readFileSync(castPath, 'utf8')) as Record<string, string>;
95
- return {
96
+ const world: BikeShopWorld = {
96
97
  t1: tenantId.parse(raw.t1),
97
98
  s1: scopeId.parse(raw.s1),
98
99
  t2: tenantId.parse(raw.t2),
@@ -107,6 +108,8 @@ export async function seedBikeShop(host: SqliteScopeHost, dir: string): Promise<
107
108
  crescentId: raw.crescentId!,
108
109
  bianchiId: raw.bianchiId!,
109
110
  };
111
+ await grantPortals(host, world);
112
+ return world;
110
113
  }
111
114
 
112
115
  const staff = platformActorId.parse(ulid());
@@ -202,26 +205,38 @@ export async function seedBikeShop(host: SqliteScopeHost, dir: string): Promise<
202
205
  world.crescentId = crescent.id;
203
206
  world.bianchiId = bianchi.id;
204
207
 
205
- // Portal grants: entity-narrowed per customer (ENTITY_GRANTS). Lisbeth and
206
- // Otto each hold workorder:read on their OWN customer record only — the walk
207
- // workorder → bike → customer does the rest.
208
+ await grantPortals(host, world);
209
+
210
+ writeFileSync(castPath, JSON.stringify(world, null, 2));
211
+ return world;
212
+ }
213
+
214
+ /**
215
+ * Portal grants: the `customer` shape (ENTITY_GRANTS) on each customer's own
216
+ * record. Lisbeth and Otto each hold workorder:read on their OWN customer
217
+ * record only — the walk workorder → bike → customer does the rest.
218
+ *
219
+ * Given as the SHAPE, so a key you add to `portalPerms` later reaches them,
220
+ * and then topped up to the shape as it is now, which is what the platform's
221
+ * reconcile does for a deployed install. Both are idempotent, so this runs on
222
+ * every boot, and a world seeded before you added a key receives it.
223
+ */
224
+ async function grantPortals(host: SqliteScopeHost, world: BikeShopWorld): Promise<void> {
225
+ const staff = platformActorId.parse(ulid());
226
+ const node = { tenantId: world.t1, scopeId: world.s1 };
208
227
  for (const [principal, customerId] of [
209
228
  [world.lisbeth, world.lisbethId],
210
229
  [world.otto, world.ottoId],
211
230
  ] as const) {
212
- for (const permission of portalPerms) {
213
- await host.admin.grant(staff, {
214
- principalId: principal,
215
- permission,
216
- node: { tenantId: world.t1, scopeId: world.s1 },
217
- entity: { entityType: 'customer', entityId: customerId },
218
- grantedBy: world.greta,
219
- });
220
- }
231
+ await host.admin.grantEntityShape(staff, {
232
+ principalId: principal,
233
+ node,
234
+ entity: { entityType: 'customer', entityId: customerId },
235
+ permissions: portalPerms,
236
+ grantedBy: world.greta,
237
+ });
221
238
  }
222
-
223
- writeFileSync(castPath, JSON.stringify(world, null, 2));
224
- return world;
239
+ await host.admin.reconcileEntityGrantShapes(staff, node, ENTITY_GRANTS);
225
240
  }
226
241
 
227
242
  /**
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The generic `/api/invoke` route goes through the platform's one door (#2073).
3
+ *
4
+ * A page's `rowCursors` are each row's own cursor: an in-process answer for a walk that stops
5
+ * partway through a page. A handler that filtered its entries after the read would leave them
6
+ * naming the rows it dropped, so they never leave in a response — and a caller cannot ask for
7
+ * them. This route is hand-written, not derived, so it is held to that here.
8
+ */
9
+ import { Hono } from 'hono';
10
+ import { describe, expect, it } from 'vitest';
11
+ import type { ScopeStub } from '@substrat-run/kernel';
12
+ import { mountApi } from '../src/routes.js';
13
+
14
+ function harness(result: unknown) {
15
+ const got: { input?: unknown } = {};
16
+ const app = new Hono();
17
+ mountApi(app, async () => ({ invoke: async (_op: string, input: unknown) => ((got.input = input), result) }) as unknown as ScopeStub);
18
+ const call = (input: unknown) =>
19
+ app.request('/api/invoke', {
20
+ method: 'POST',
21
+ headers: { 'content-type': 'application/json' },
22
+ body: JSON.stringify({ op: 'shop/anything', input }),
23
+ });
24
+ return { call, got };
25
+ }
26
+
27
+ describe('/api/invoke — the one hand-written route (#2073)', () => {
28
+ it("never answers with a page's rowCursors, at the top or nested", async () => {
29
+ const page = { entries: [{ id: 'a' }], nextCursor: 'a', rowCursors: ['a', 'HIDDEN'] };
30
+ for (const result of [page, { nested: page, again: page }]) {
31
+ const text = await (await harness(result).call({})).text();
32
+ expect(text).toContain('"a"');
33
+ expect(text).not.toContain('HIDDEN');
34
+ }
35
+ });
36
+
37
+ it('keeps a rowCursors field on something that is not a page', async () => {
38
+ const res = await harness({ id: 'a', rowCursors: ['kept'] }).call({});
39
+ expect(await res.json()).toEqual({ id: 'a', rowCursors: ['kept'] });
40
+ });
41
+
42
+ it('a caller cannot ask for rowCursors: the operation never sees the flag', async () => {
43
+ const { call, got } = harness(null);
44
+ await call({ q: 'x', rowCursors: true });
45
+ expect(got.input).toEqual({ q: 'x' });
46
+ });
47
+ });
@@ -0,0 +1,75 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
5
+ import { platformActorId, principalId, type Page } from '@substrat-run/contracts';
6
+ import { ulid } from '@substrat-run/kernel';
7
+ import type { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
8
+ import { INVOICING_PERM as INV } from '@substrat-run/engine-invoicing';
9
+ import { buildBikeShopHost, ENTITY_GRANTS, seedBikeShop, type BikeShopWorld } from '../src/seed.js';
10
+ import { portalPerms } from '../src/provision.js';
11
+
12
+ // ============================================================================
13
+ // The portal grant is a declared SHAPE (`ENTITY_GRANTS`), so a key you add to
14
+ // `portalPerms` reaches the customers who already hold it — including ones a
15
+ // release before the shape grant gave it key by key.
16
+ //
17
+ // The second shop (t2) stands for one an older release left behind: never
18
+ // reconciled, one repair, and Kerstin given one key of an older shape on her
19
+ // customer's record, with no holder marker. The reconcile carries the shape
20
+ // grown by the key she lacks (`workorder:read`, the key `shop/portal-repairs`
21
+ // walks). Before it she sees nothing; after it she sees her repair.
22
+ // ============================================================================
23
+
24
+ describe('a key the portal shape gains reaches a customer granted before markers', () => {
25
+ let dir: string;
26
+ let host: SqliteScopeHost;
27
+ let w: BikeShopWorld;
28
+ let repairId: string;
29
+ const staff = platformActorId.parse(ulid());
30
+ const kerstin = principalId.parse(ulid());
31
+
32
+ beforeAll(async () => {
33
+ dir = mkdtempSync(join(tmpdir(), 'bike-shop-portal-shape-'));
34
+ host = buildBikeShopHost(dir);
35
+ w = await seedBikeShop(host, dir);
36
+ const shop = await host.getScope(w.rutger, w.t2, w.s2);
37
+ const customer = await shop.invoke<{ id: string }>('shop/create-customer', { number: '1', name: 'Kerstin' });
38
+ const bike = await shop.invoke<{ id: string }>('shop/register-bike', { customerId: customer.id, label: 'Crescent' });
39
+ repairId = (await shop.invoke<{ id: string }>('shop/create-repair', { bikeId: bike.id, kind: 'punktering', title: 'Punktering' })).id;
40
+ await host.admin.grant(staff, {
41
+ principalId: kerstin,
42
+ permission: INV.read,
43
+ node: { tenantId: w.t2, scopeId: w.s2 },
44
+ entity: { entityType: 'customer', entityId: customer.id },
45
+ grantedBy: w.rutger,
46
+ });
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await host.close();
51
+ rmSync(dir, { recursive: true, force: true });
52
+ });
53
+
54
+ const sees = async () =>
55
+ (await (await host.getScope(kerstin, w.t2, w.s2)).invoke<Page<{ id: string }>>('shop/portal-repairs')).entries.map((o) => o.id);
56
+
57
+ it('the seeded customers each receive one audited shape grant', async () => {
58
+ const grants = await host.admin.auditLog(staff, { tenantId: w.t1, scopeId: w.s1, action: 'grantEntityShape' });
59
+ expect(grants.map((entry) => (entry.after as { principalId: string }).principalId).sort()).toEqual([w.lisbeth, w.otto].sort());
60
+ });
61
+
62
+ it('the shape is declared bootstrap, and its holder is whoever holds a key of it', () => {
63
+ expect(ENTITY_GRANTS).toEqual([{ entityType: 'customer', permissions: portalPerms, bootstrap: true, holder: 'grantee' }]);
64
+ });
65
+
66
+ it('before the reconcile she sees nothing', async () => {
67
+ expect(await sees()).toEqual([]);
68
+ });
69
+
70
+ it('after it, she sees her repair', async () => {
71
+ const grown = ENTITY_GRANTS.map((g) => ({ ...g, permissions: [INV.read, ...g.permissions] }));
72
+ await host.admin.reconcileEntityGrantShapes(staff, { tenantId: w.t2, scopeId: w.s2 }, grown);
73
+ expect(await sees()).toEqual([repairId]);
74
+ });
75
+ });
@@ -1,32 +1,20 @@
1
1
  /**
2
2
  * The config behind `pnpm test:ui` — your own `vitest.config.ts`, plus the live test
3
- * dashboard served on a port Claude Desktop's Browser pane can open (`.claude/launch.json`,
4
- * the `tests` entry).
3
+ * dashboard on a fixed port (`.claude/launch.json`, the `tests` entry).
5
4
  *
6
- * The UI lives at `/__vitest__/` and the bare origin is a 404, but a launch entry carries
7
- * a port and never a path, so `/` is redirected to the UI here. `VITEST_UI_PORT` moves
8
- * the port; it binds 127.0.0.1 only, since the dashboard can re-run your tests. The port
9
- * is strict: a busy one fails the start rather than moving the dashboard somewhere the
10
- * launch entry does not point.
5
+ * Open it at the URL vitest prints when it starts (`UI started at
6
+ * http://127.0.0.1:5290/__vitest__/?token=…`). The dashboard refuses any request without that
7
+ * token, because its API can re-run your tests, write files and run code; the token is what
8
+ * keeps another page in your browser from driving it. So nothing here redirects the bare
9
+ * origin to it. `VITEST_UI_PORT` moves the port; it binds 127.0.0.1 only. The port is strict:
10
+ * a busy one fails the start rather than moving the dashboard somewhere the launch entry does
11
+ * not point.
11
12
  */
12
- import { mergeConfig, type Plugin } from 'vitest/config';
13
+ import { mergeConfig } from 'vitest/config';
13
14
 
14
15
  import base from './vitest.config.ts';
15
16
 
16
- const redirectRootToUi: Plugin = {
17
- name: 'substrat:vitest-ui-root',
18
- configureServer(server) {
19
- server.middlewares.use((req, res, next) => {
20
- if (req.url !== '/') return next();
21
- res.statusCode = 302;
22
- res.setHeader('Location', '/__vitest__/');
23
- res.end();
24
- });
25
- },
26
- };
27
-
28
17
  export default mergeConfig(base, {
29
- plugins: [redirectRootToUi],
30
18
  test: {
31
19
  api: {
32
20
  host: '127.0.0.1',