create-substrat 0.0.1 → 0.1.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.
@@ -0,0 +1,297 @@
1
+ import {
2
+ addDecimal,
3
+ compareDecimal,
4
+ moneyOf,
5
+ mulMoney,
6
+ z,
7
+ type EntityRef,
8
+ type Money,
9
+ } from '@substrat-run/contracts';
10
+ import {
11
+ assertAllowed,
12
+ ulid,
13
+ type ModuleRegistration,
14
+ type OperationHandler,
15
+ } from '@substrat-run/kernel';
16
+ import {
17
+ closeWorkOrder,
18
+ completeWorkOrder,
19
+ createWorkOrder,
20
+ getReportedLines,
21
+ listOrders,
22
+ PERM as WO,
23
+ type BillableLine,
24
+ type WorkOrder,
25
+ } from '@substrat-run/engine-workorder';
26
+ import { bikeShopManifest, SHOP_PERM } from './manifest.js';
27
+ import { bikeShopMigrations } from './migrations.js';
28
+
29
+ // ============================================================================
30
+ // The bike-shop operations. Each is either:
31
+ // - a thin custodian of the vertical's own tables (customers, bikes, prices),
32
+ // OR
33
+ // - a COMPOSITION that wraps an engine's in-scope function inside the same
34
+ // transaction and adds the vertical's policy (the pricing moment).
35
+ //
36
+ // Every operation's FIRST line is the permission check. Data access is
37
+ // `ctx.sql` only. No `fetch`, no `node:*`, no other engine's tables.
38
+ // ============================================================================
39
+
40
+ export interface CustomerRow {
41
+ id: string;
42
+ number: string;
43
+ name: string;
44
+ phone: string | null;
45
+ created_at: string;
46
+ }
47
+
48
+ export interface BikeRow {
49
+ id: string;
50
+ customer_id: string;
51
+ label: string;
52
+ frame_no: string | null;
53
+ created_at: string;
54
+ }
55
+
56
+ export interface PriceRow {
57
+ article: string;
58
+ description: string;
59
+ unit: string;
60
+ price_amount: string;
61
+ currency: string;
62
+ min_qty: string | null;
63
+ internal: number;
64
+ }
65
+
66
+ const createCustomerOp: OperationHandler<
67
+ { number: string; name: string; phone?: string },
68
+ CustomerRow
69
+ > = async (ctx, input) => {
70
+ assertAllowed(await ctx.check(SHOP_PERM.customerManage));
71
+ const id = ulid();
72
+ ctx.sql.exec(
73
+ `INSERT INTO shop_customers (id, number, name, phone, created_at) VALUES (?, ?, ?, ?, ?)`,
74
+ [id, input.number, input.name, input.phone ?? null, new Date().toISOString()],
75
+ );
76
+ return ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [id])[0]!;
77
+ };
78
+
79
+ const listCustomersOp: OperationHandler<undefined, (CustomerRow & { bikes: BikeRow[] })[]> = async (
80
+ ctx,
81
+ ) => {
82
+ assertAllowed(await ctx.check(SHOP_PERM.customerManage));
83
+ const customers = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers ORDER BY number');
84
+ return customers.map((c) => ({
85
+ ...c,
86
+ bikes: ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE customer_id = ? ORDER BY label', [
87
+ c.id,
88
+ ]),
89
+ }));
90
+ };
91
+
92
+ const registerBikeOp: OperationHandler<
93
+ { customerId: string; label: string; frameNo?: string },
94
+ BikeRow
95
+ > = async (ctx, input) => {
96
+ assertAllowed(await ctx.check(SHOP_PERM.bikeManage));
97
+ const customer = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [
98
+ input.customerId,
99
+ ])[0];
100
+ if (!customer) throw new Error(`customer not found: ${input.customerId}`);
101
+ const id = ulid();
102
+ ctx.sql.exec(
103
+ `INSERT INTO shop_bikes (id, customer_id, label, frame_no, created_at) VALUES (?, ?, ?, ?, ?)`,
104
+ [id, customer.id, input.label, input.frameNo ?? null, new Date().toISOString()],
105
+ );
106
+ // Record the bike → customer edge the manifest declared, so the portal walk
107
+ // (workorder → bike → customer) can resolve an entity-narrowed grant.
108
+ ctx.link({ entityType: 'bike', entityId: id }, { entityType: 'customer', entityId: customer.id });
109
+ return ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [id])[0]!;
110
+ };
111
+
112
+ const upsertPriceOp: OperationHandler<
113
+ {
114
+ article: string;
115
+ description: string;
116
+ unit: string;
117
+ priceAmount: string;
118
+ currency?: string;
119
+ minQty?: string;
120
+ internal?: boolean;
121
+ },
122
+ PriceRow
123
+ > = async (ctx, input) => {
124
+ assertAllowed(await ctx.check(SHOP_PERM.customerManage));
125
+ ctx.sql.exec(
126
+ `INSERT OR REPLACE INTO shop_price_list
127
+ (article, description, unit, price_amount, currency, min_qty, internal)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
129
+ [
130
+ input.article,
131
+ input.description,
132
+ input.unit,
133
+ input.priceAmount,
134
+ input.currency ?? 'SEK',
135
+ input.minQty ?? null,
136
+ input.internal ? 1 : 0,
137
+ ],
138
+ );
139
+ return ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list WHERE article = ?', [
140
+ input.article,
141
+ ])[0]!;
142
+ };
143
+
144
+ const priceListOp: OperationHandler<undefined, PriceRow[]> = async (ctx) => {
145
+ assertAllowed(await ctx.check(SHOP_PERM.customerManage));
146
+ return ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list ORDER BY article');
147
+ };
148
+
149
+ /**
150
+ * Open a repair: a vertical operation that composes the engine's `createWorkOrder`.
151
+ * The vertical resolves its own vocabulary (a bike, its owner) into the engine's
152
+ * `facility`/`customer` refs; the engine owns the number, the state, the event.
153
+ */
154
+ const createRepairOp: OperationHandler<
155
+ { bikeId: string; kind: string; title: string; description?: string },
156
+ WorkOrder
157
+ > = async (ctx, input) => {
158
+ assertAllowed(await ctx.check(WO.create));
159
+ const bike = ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [input.bikeId])[0];
160
+ if (!bike) throw new Error(`bike not found: ${input.bikeId}`);
161
+ return createWorkOrder(ctx, {
162
+ facility: { entityType: 'bike', entityId: bike.id },
163
+ customer: { entityType: 'customer', entityId: bike.customer_id },
164
+ kind: input.kind,
165
+ title: input.title,
166
+ ...(input.description !== undefined ? { description: input.description } : {}),
167
+ });
168
+ };
169
+
170
+ /**
171
+ * THE PRICING MOMENT — the whole reason a vertical exists. Read the engine's
172
+ * reported time and material, price them against the vertical's OWN price list
173
+ * (labor bills at least its minimum quantity; internal articles are dropped),
174
+ * then hand the priced lines back to the engine's `completeWorkOrder`. One
175
+ * transaction: the engine's invariant stays intact and pricing is 100% vertical.
176
+ * The engine's `workorder.completed` event carries these lines, and the
177
+ * invoicing engine consumes it — no import between the two.
178
+ */
179
+ const completeRepairOp: OperationHandler<
180
+ { orderId: string },
181
+ { order: WorkOrder; billable: BillableLine[]; total: Money }
182
+ > = async (ctx, input) => {
183
+ assertAllowed(await ctx.check(WO.complete));
184
+ const reported = getReportedLines(ctx, input.orderId);
185
+ const prices = new Map<string, PriceRow>(
186
+ ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list').map((p) => [p.article, p]),
187
+ );
188
+
189
+ const billable: BillableLine[] = [];
190
+
191
+ // Labor: sum reported hours, then bill at least the minimum quantity.
192
+ const laborPrice = prices.get('labor');
193
+ const reportedHours = reported.time.reduce((sum, t) => addDecimal(sum, t.hours), '0');
194
+ if (laborPrice && compareDecimal(reportedHours, '0') > 0) {
195
+ const minQty = laborPrice.min_qty ?? '0';
196
+ const qty = compareDecimal(reportedHours, minQty) >= 0 ? reportedHours : minQty;
197
+ const unitPrice = moneyOf(laborPrice.price_amount, laborPrice.currency);
198
+ billable.push({
199
+ article: 'labor',
200
+ description: laborPrice.description,
201
+ qty,
202
+ unit: laborPrice.unit,
203
+ unitPrice,
204
+ lineTotal: mulMoney(qty, unitPrice),
205
+ sourceType: 'time',
206
+ sourceId: input.orderId,
207
+ });
208
+ }
209
+
210
+ // Parts: one billable line per reported material; internal articles dropped.
211
+ for (const m of reported.material) {
212
+ const price = prices.get(m.article);
213
+ if (!price) throw new Error(`no price for article: ${m.article}`);
214
+ if (price.internal) continue;
215
+ const unitPrice = moneyOf(price.price_amount, price.currency);
216
+ billable.push({
217
+ article: m.article,
218
+ description: price.description,
219
+ qty: m.qty,
220
+ unit: price.unit,
221
+ unitPrice,
222
+ lineTotal: mulMoney(m.qty, unitPrice),
223
+ sourceType: 'material',
224
+ sourceId: m.id,
225
+ });
226
+ }
227
+
228
+ const result = completeWorkOrder(ctx, { orderId: input.orderId, billable });
229
+ return { order: result.order, billable, total: result.total };
230
+ };
231
+
232
+ /**
233
+ * Pickup: hand the bike back (completed → closed). A thin composition of the
234
+ * engine's in-scope `closeWorkOrder`; the vertical owns the vocabulary
235
+ * ("pickup"), the engine owns the transition.
236
+ */
237
+ const closeRepairOp: OperationHandler<{ orderId: string }, WorkOrder> = async (ctx, input) => {
238
+ assertAllowed(await ctx.check(WO.close));
239
+ return closeWorkOrder(ctx, { orderId: input.orderId });
240
+ };
241
+
242
+ /**
243
+ * The customer PORTAL listing. A proof walk: no blanket `workorder:read`, one
244
+ * PER-ENTITY check per repair. A portal customer holds an entity-narrowed grant
245
+ * on their own customer record, so the walk workorder → bike → customer lets
246
+ * them through for their own repairs and no one else's.
247
+ */
248
+ const portalRepairsOp: OperationHandler<undefined, WorkOrder[]> = async (ctx) => {
249
+ const visible: WorkOrder[] = [];
250
+ for (const order of listOrders(ctx)) {
251
+ const decision = await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id });
252
+ if (decision.allowed) visible.push(order);
253
+ }
254
+ return visible;
255
+ };
256
+
257
+ const timelineInput = z.object({
258
+ entityType: z.string().min(1),
259
+ entityId: z.string().min(1),
260
+ });
261
+
262
+ /**
263
+ * An entity's event timeline, read straight off the spine (a read of `_substrat_*`
264
+ * for a projection is allowed; writing it is not). Gated by a per-entity
265
+ * `workorder:read` check, so it obeys the same walk as the portal.
266
+ */
267
+ const timelineOp: OperationHandler<
268
+ z.infer<typeof timelineInput>,
269
+ { type: string; occurred_at: string; actor: string }[]
270
+ > = async (ctx, rawInput) => {
271
+ const entity: EntityRef = timelineInput.parse(rawInput);
272
+ assertAllowed(await ctx.check(WO.read, entity));
273
+ // Append order is authoritative — rowid, not ULID (ids minted in the same
274
+ // millisecond are not mutually ordered).
275
+ return ctx.sql.query(
276
+ `SELECT type, occurred_at, actor FROM _substrat_outbox
277
+ WHERE entity_type = ? AND entity_id = ? ORDER BY rowid`,
278
+ [entity.entityType, entity.entityId],
279
+ );
280
+ };
281
+
282
+ export const bikeShopModule: ModuleRegistration = {
283
+ manifest: bikeShopManifest,
284
+ migrations: bikeShopMigrations,
285
+ operations: {
286
+ 'shop/create-customer': createCustomerOp as never,
287
+ 'shop/list-customers': listCustomersOp as never,
288
+ 'shop/register-bike': registerBikeOp as never,
289
+ 'shop/upsert-price': upsertPriceOp as never,
290
+ 'shop/price-list': priceListOp as never,
291
+ 'shop/create-repair': createRepairOp as never,
292
+ 'shop/complete-repair': completeRepairOp as never,
293
+ 'shop/close-repair': closeRepairOp as never,
294
+ 'shop/portal-repairs': portalRepairsOp as never,
295
+ 'shop/timeline': timelineOp as never,
296
+ },
297
+ };
@@ -0,0 +1,265 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ platformActorId,
5
+ principalId,
6
+ scopeId,
7
+ tenantId,
8
+ type PermissionKey,
9
+ type PrincipalId,
10
+ type RoleDefinition,
11
+ type ScopeId,
12
+ type TenantId,
13
+ } from '@substrat-run/contracts';
14
+ import { ulid } from '@substrat-run/kernel';
15
+ import { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
16
+ import { workorderModule, PERM as WO } from '@substrat-run/engine-workorder';
17
+ import { invoicingModule, INVOICING_PERM as INV } from '@substrat-run/engine-invoicing';
18
+ import { bikeShopModule } from './module.js';
19
+ import { SHOP_PERM } from './manifest.js';
20
+
21
+ // ============================================================================
22
+ // The seeded world. TWO tenants on purpose: the first is the shop the scenario
23
+ // exercises; the second exists only so the scenario can watch the tenant
24
+ // boundary turn its admin away when he reaches across (a cross-tenant attacker
25
+ // is the cheapest possible proof that isolation is real).
26
+ // ============================================================================
27
+
28
+ export interface BikeShopWorld {
29
+ t1: TenantId; // the shop under test
30
+ s1: ScopeId; // its workshop scope
31
+ t2: TenantId; // a second, unrelated shop
32
+ s2: ScopeId; // its workshop scope
33
+ greta: PrincipalId; // workshop-admin @ t1
34
+ mans: PrincipalId; // mechanic @ t1
35
+ lisbeth: PrincipalId; // portal customer @ t1 (owns a bike)
36
+ otto: PrincipalId; // portal customer @ t1 (owns a different bike)
37
+ rutger: PrincipalId; // workshop-admin @ t2 — the cross-tenant attacker
38
+ lisbethId: string; // customer record ids (filled on first seed)
39
+ ottoId: string;
40
+ crescentId: string; // Lisbeth's bike
41
+ bianchiId: string; // Otto's bike
42
+ }
43
+
44
+ /**
45
+ * The modules this vertical composes, in registration order. Exported so the
46
+ * permission checkpoint (`pnpm lint:permissions`) renders from the same array
47
+ * the running host registers — the artifact can never drift from reality.
48
+ */
49
+ export const MODULES = [workorderModule, invoicingModule, bikeShopModule];
50
+
51
+ const adminPerms: PermissionKey[] = [
52
+ SHOP_PERM.customerManage,
53
+ SHOP_PERM.bikeManage,
54
+ WO.create,
55
+ WO.read,
56
+ WO.assign,
57
+ WO.report,
58
+ WO.complete,
59
+ WO.close,
60
+ INV.read,
61
+ INV.export,
62
+ ];
63
+
64
+ /**
65
+ * This vertical's role table — identical in every tenant, so it is a plain
66
+ * constant the permission snapshot can render without naming a tenant. Exported
67
+ * for the same reason as MODULES.
68
+ */
69
+ export const ROLES: RoleDefinition[] = [
70
+ { key: 'workshop-admin', permissions: adminPerms, source: 'vertical' },
71
+ { key: 'mechanic', permissions: [WO.read, WO.report], source: 'vertical' },
72
+ ];
73
+
74
+ /** What a portal customer receives, narrowed to their own customer record. */
75
+ const portalPerms: PermissionKey[] = [WO.read];
76
+
77
+ /**
78
+ * Entity-narrowed grant SHAPES. The grants themselves are per-principal and
79
+ * minted at runtime, so they can never be a build artifact; their shape is what
80
+ * tells a reviewer which keys are reachable outside the role table.
81
+ */
82
+ export const ENTITY_GRANTS: { entityType: string; permissions: PermissionKey[] }[] = [
83
+ { entityType: 'customer', permissions: portalPerms },
84
+ ];
85
+
86
+ export function buildBikeShopHost(dir: string): SqliteScopeHost {
87
+ const host = new SqliteScopeHost({ dir });
88
+ for (const m of MODULES) host.registerModule(m);
89
+ return host;
90
+ }
91
+
92
+ /**
93
+ * Provision ONE tenant of this vertical: tenant, entitlements for every module
94
+ * it runs, an active scope, the role table, and the owner holding workshop-admin.
95
+ * This is what an instantiate button would call — no demo cast, no fixtures.
96
+ */
97
+ async function provisionShop(
98
+ host: SqliteScopeHost,
99
+ input: { tenantId: TenantId; scopeId: ScopeId; owner: PrincipalId; slug: string; name: string },
100
+ ): Promise<void> {
101
+ const staff = platformActorId.parse(ulid());
102
+ await host.admin.createTenant(staff, { id: input.tenantId, slug: input.slug, name: input.name });
103
+ // Entitlements are default-deny: the SKU flag for each module this vertical
104
+ // runs must be granted before any of its operations resolve.
105
+ for (const key of ['workorder', 'invoicing', 'bikeshop']) {
106
+ await host.admin.grantEntitlement(staff, input.tenantId, key);
107
+ }
108
+ await host.provisionScope(staff, {
109
+ tenantId: input.tenantId,
110
+ scopeId: input.scopeId,
111
+ jurisdiction: 'global',
112
+ });
113
+ // Provisioning writes the scope as `provisioning`; nothing may use it until it
114
+ // is active (here the platform and the vertical are one process, so the
115
+ // confirmation is immediate).
116
+ await host.admin.activateScope(staff, input.tenantId, input.scopeId);
117
+ for (const role of ROLES) await host.admin.defineRole(staff, input.tenantId, role);
118
+ await host.admin.assignRole(staff, {
119
+ principalId: input.owner,
120
+ roleKey: 'workshop-admin',
121
+ node: { tenantId: input.tenantId, scopeId: null },
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Idempotent seed. Everything that mutates the control plane runs only on a
127
+ * FRESH data dir (guarded by cast.json); on restart the tenants, roles, grants
128
+ * and entities are already in the SQLite files, so we just rebuild the handle
129
+ * object. Safe to call on every server start and on every test.
130
+ */
131
+ export async function seedBikeShop(host: SqliteScopeHost, dir: string): Promise<BikeShopWorld> {
132
+ const castPath = join(dir, 'cast.json');
133
+ if (existsSync(castPath)) {
134
+ const raw = JSON.parse(readFileSync(castPath, 'utf8')) as Record<string, string>;
135
+ return {
136
+ t1: tenantId.parse(raw.t1),
137
+ s1: scopeId.parse(raw.s1),
138
+ t2: tenantId.parse(raw.t2),
139
+ s2: scopeId.parse(raw.s2),
140
+ greta: principalId.parse(raw.greta),
141
+ mans: principalId.parse(raw.mans),
142
+ lisbeth: principalId.parse(raw.lisbeth),
143
+ otto: principalId.parse(raw.otto),
144
+ rutger: principalId.parse(raw.rutger),
145
+ lisbethId: raw.lisbethId!,
146
+ ottoId: raw.ottoId!,
147
+ crescentId: raw.crescentId!,
148
+ bianchiId: raw.bianchiId!,
149
+ };
150
+ }
151
+
152
+ const staff = platformActorId.parse(ulid());
153
+ const world: BikeShopWorld = {
154
+ t1: tenantId.parse(ulid()),
155
+ s1: scopeId.parse(ulid()),
156
+ t2: tenantId.parse(ulid()),
157
+ s2: scopeId.parse(ulid()),
158
+ greta: principalId.parse(ulid()),
159
+ mans: principalId.parse(ulid()),
160
+ lisbeth: principalId.parse(ulid()),
161
+ otto: principalId.parse(ulid()),
162
+ rutger: principalId.parse(ulid()),
163
+ lisbethId: '',
164
+ ottoId: '',
165
+ crescentId: '',
166
+ bianchiId: '',
167
+ };
168
+
169
+ // The shop under test, and a second unrelated shop whose admin will attack it.
170
+ await provisionShop(host, {
171
+ tenantId: world.t1,
172
+ scopeId: world.s1,
173
+ owner: world.greta,
174
+ slug: 'kedja-kugghjul',
175
+ name: 'Kedja & Kugghjul Cykelverkstad',
176
+ });
177
+ await provisionShop(host, {
178
+ tenantId: world.t2,
179
+ scopeId: world.s2,
180
+ owner: world.rutger,
181
+ slug: 'trampolin',
182
+ name: 'Trampolin Cykel',
183
+ });
184
+
185
+ // The rest of t1's cast.
186
+ await host.admin.assignRole(staff, {
187
+ principalId: world.mans,
188
+ roleKey: 'mechanic',
189
+ node: { tenantId: world.t1, scopeId: world.s1 },
190
+ });
191
+
192
+ // Seed entities go through the operations (NEVER raw SQL): the seed exercises
193
+ // the same permission checks and event spine the app does.
194
+ const greta = await host.getScope(world.greta, world.t1, world.s1);
195
+ const lisbethCustomer = await greta.invoke<{ id: string }>('shop/create-customer', {
196
+ number: '2001',
197
+ name: 'Lisbeth Sandell',
198
+ phone: '070-123 45 67',
199
+ });
200
+ const ottoCustomer = await greta.invoke<{ id: string }>('shop/create-customer', {
201
+ number: '2002',
202
+ name: 'Otto Vinge',
203
+ });
204
+ const crescent = await greta.invoke<{ id: string }>('shop/register-bike', {
205
+ customerId: lisbethCustomer.id,
206
+ label: 'Crescent Elina 3-vxl',
207
+ frameNo: 'CR-88412',
208
+ });
209
+ const bianchi = await greta.invoke<{ id: string }>('shop/register-bike', {
210
+ customerId: ottoCustomer.id,
211
+ label: 'Bianchi Oltre XR3',
212
+ });
213
+ await greta.invoke('shop/upsert-price', {
214
+ article: 'labor',
215
+ description: 'Mechanic time',
216
+ unit: 'hour',
217
+ priceAmount: '495',
218
+ minQty: '0.5',
219
+ });
220
+ await greta.invoke('shop/upsert-price', {
221
+ article: 'shop-supplies',
222
+ description: 'Workshop supplies',
223
+ unit: 'ea',
224
+ priceAmount: '15',
225
+ internal: true,
226
+ });
227
+ await greta.invoke('shop/upsert-price', {
228
+ article: 'tube-28',
229
+ description: 'Inner tube 28"',
230
+ unit: 'ea',
231
+ priceAmount: '89',
232
+ });
233
+ await greta.invoke('shop/upsert-price', {
234
+ article: 'chain-9s',
235
+ description: 'Chain, 9-speed',
236
+ unit: 'ea',
237
+ priceAmount: '249',
238
+ });
239
+
240
+ world.lisbethId = lisbethCustomer.id;
241
+ world.ottoId = ottoCustomer.id;
242
+ world.crescentId = crescent.id;
243
+ world.bianchiId = bianchi.id;
244
+
245
+ // Portal grants: entity-narrowed per customer (ENTITY_GRANTS). Lisbeth and
246
+ // Otto each hold workorder:read on their OWN customer record only — the walk
247
+ // workorder → bike → customer does the rest.
248
+ for (const [principal, customerId] of [
249
+ [world.lisbeth, world.lisbethId],
250
+ [world.otto, world.ottoId],
251
+ ] as const) {
252
+ for (const permission of portalPerms) {
253
+ await host.admin.grant(staff, {
254
+ principalId: principal,
255
+ permission,
256
+ node: { tenantId: world.t1, scopeId: world.s1 },
257
+ entity: { entityType: 'customer', entityId: customerId },
258
+ grantedBy: world.greta,
259
+ });
260
+ }
261
+ }
262
+
263
+ writeFileSync(castPath, JSON.stringify(world, null, 2));
264
+ return world;
265
+ }