create-substrat 0.10.16 → 0.11.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
@@ -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';
39
+ const SUBSTRAT = '^0.140.0';
40
+ const ENGINE_WORKORDER = '^0.12.24';
41
+ const ENGINE_INVOICING = '^0.11.24';
42
42
  const BOUNDARY_LINT = '^0.6.1';
43
- const DEV_ISSUER = '^0.2.21';
44
- const ATTACHMENT_EXTRACTORS = '^0.2.0';
43
+ const DEV_ISSUER = '^0.2.22';
44
+ const ATTACHMENT_EXTRACTORS = '^0.2.1';
45
45
 
46
46
  const DOCS = 'https://substrat.net';
47
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.10.16",
3
+ "version": "0.11.0",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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
+ });