create-substrat 0.0.0 → 0.1.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.
@@ -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
+ }
@@ -0,0 +1,145 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { serve } from '@hono/node-server';
5
+ import { Hono } from 'hono';
6
+ import type { Context } from 'hono';
7
+ import { PermissionDenied, type ScopeStub } from '@substrat-run/kernel';
8
+ import type { PrincipalId } from '@substrat-run/contracts';
9
+ import { buildBikeShopHost, seedBikeShop, type BikeShopWorld } from './seed.js';
10
+
11
+ // ============================================================================
12
+ // A deliberately THIN dev API. Each route authenticates (a dev principal picker
13
+ // via the `x-principal` header — a real deployment swaps in a session), gets the
14
+ // scope, and invokes ONE operation. There is no business logic here: every rule
15
+ // lives in an operation or an engine.
16
+ // ============================================================================
17
+
18
+ const dataDir = join(dirname(fileURLToPath(import.meta.url)), '..', '.data');
19
+ mkdirSync(dataDir, { recursive: true });
20
+
21
+ const host = buildBikeShopHost(dataDir);
22
+ const world: BikeShopWorld = await seedBikeShop(host, dataDir);
23
+
24
+ // The dev cast, keyed by the `x-principal` header value. Every entry is a real
25
+ // principal with real tuples — nothing here is a bypass.
26
+ const CAST: Record<string, { name: string; principal: PrincipalId }> = {
27
+ greta: { name: 'Greta (workshop-admin)', principal: world.greta },
28
+ mans: { name: 'Måns (mechanic)', principal: world.mans },
29
+ lisbeth: { name: 'Lisbeth (portal customer)', principal: world.lisbeth },
30
+ otto: { name: 'Otto (portal customer)', principal: world.otto },
31
+ rutger: { name: 'Rutger (other shop — attacker)', principal: world.rutger },
32
+ };
33
+
34
+ function principalOf(c: Context): PrincipalId {
35
+ const who = c.req.header('x-principal') ?? 'greta';
36
+ const entry = CAST[who];
37
+ if (!entry) throw new PermissionDenied(`unknown principal: ${who}`);
38
+ return entry.principal;
39
+ }
40
+
41
+ function stub(c: Context): Promise<ScopeStub> {
42
+ return host.getScope(principalOf(c), world.t1, world.s1);
43
+ }
44
+
45
+ const app = new Hono();
46
+
47
+ app.onError((err, c) => {
48
+ const message = err instanceof Error ? err.message : String(err);
49
+ if (err instanceof PermissionDenied) return c.json({ error: message }, 403);
50
+ if (/invalid transition|immutable|already/.test(message)) return c.json({ error: message }, 409);
51
+ if (/not found|unknown scope|unknown operation/.test(message)) return c.json({ error: message }, 404);
52
+ return c.json({ error: message }, 400);
53
+ });
54
+
55
+ app.get('/api/cast', (c) => c.json(CAST));
56
+
57
+ // Customers, bikes, price list (the vertical's own tables).
58
+ app.get('/api/customers', async (c) => c.json(await (await stub(c)).invoke('shop/list-customers')));
59
+ app.post('/api/customers', async (c) =>
60
+ c.json(await (await stub(c)).invoke('shop/create-customer', await c.req.json())),
61
+ );
62
+ app.post('/api/customers/:id/bikes', async (c) =>
63
+ c.json(
64
+ await (await stub(c)).invoke('shop/register-bike', {
65
+ customerId: c.req.param('id'),
66
+ ...(await c.req.json<Record<string, unknown>>()),
67
+ }),
68
+ ),
69
+ );
70
+ app.get('/api/prices', async (c) => c.json(await (await stub(c)).invoke('shop/price-list')));
71
+ app.post('/api/prices', async (c) =>
72
+ c.json(await (await stub(c)).invoke('shop/upsert-price', await c.req.json())),
73
+ );
74
+
75
+ // Repairs — the vertical's create/complete/close wrap the engine; assign/start/
76
+ // report/get/list are the engine's own operations, invoked directly.
77
+ app.get('/api/repairs', async (c) =>
78
+ c.json(await (await stub(c)).invoke('workorder/list', { status: c.req.query('status') })),
79
+ );
80
+ app.post('/api/repairs', async (c) =>
81
+ c.json(await (await stub(c)).invoke('shop/create-repair', await c.req.json())),
82
+ );
83
+ app.get('/api/repairs/:id', async (c) =>
84
+ c.json(await (await stub(c)).invoke('workorder/get', { orderId: c.req.param('id') })),
85
+ );
86
+ app.get('/api/repairs/:id/timeline', async (c) =>
87
+ c.json(
88
+ await (await stub(c)).invoke('shop/timeline', {
89
+ entityType: 'workorder',
90
+ entityId: c.req.param('id'),
91
+ }),
92
+ ),
93
+ );
94
+ app.post('/api/repairs/:id/assign', async (c) =>
95
+ c.json(
96
+ await (await stub(c)).invoke('workorder/assign', {
97
+ orderId: c.req.param('id'),
98
+ ...(await c.req.json<Record<string, unknown>>()),
99
+ }),
100
+ ),
101
+ );
102
+ app.post('/api/repairs/:id/start', async (c) =>
103
+ c.json(await (await stub(c)).invoke('workorder/start', { orderId: c.req.param('id') })),
104
+ );
105
+ app.post('/api/repairs/:id/time', async (c) =>
106
+ c.json(
107
+ await (await stub(c)).invoke('workorder/report-time', {
108
+ orderId: c.req.param('id'),
109
+ ...(await c.req.json<Record<string, unknown>>()),
110
+ }),
111
+ ),
112
+ );
113
+ app.post('/api/repairs/:id/material', async (c) =>
114
+ c.json(
115
+ await (await stub(c)).invoke('workorder/report-material', {
116
+ orderId: c.req.param('id'),
117
+ ...(await c.req.json<Record<string, unknown>>()),
118
+ }),
119
+ ),
120
+ );
121
+ app.post('/api/repairs/:id/complete', async (c) =>
122
+ c.json(await (await stub(c)).invoke('shop/complete-repair', { orderId: c.req.param('id') })),
123
+ );
124
+ app.post('/api/repairs/:id/close', async (c) =>
125
+ c.json(await (await stub(c)).invoke('shop/close-repair', { orderId: c.req.param('id') })),
126
+ );
127
+
128
+ // The customer portal — the per-entity proof walk.
129
+ app.get('/api/portal/repairs', async (c) =>
130
+ c.json(await (await stub(c)).invoke('shop/portal-repairs')),
131
+ );
132
+
133
+ // Invoicing (the sibling engine, fed by event).
134
+ app.get('/api/invoicing', async (c) => c.json(await (await stub(c)).invoke('invoicing/list')));
135
+ app.get('/api/invoicing/:id', async (c) =>
136
+ c.json(await (await stub(c)).invoke('invoicing/get', { underlagId: c.req.param('id') })),
137
+ );
138
+ app.post('/api/invoicing/:id/export', async (c) =>
139
+ c.json(await (await stub(c)).invoke('invoicing/export', { underlagId: c.req.param('id') })),
140
+ );
141
+
142
+ const PORT = Number(process.env.PORT ?? 8873);
143
+ serve({ fetch: app.fetch, port: PORT });
144
+ console.log(`Bike-shop API on http://localhost:${PORT} — data in ${dataDir}`);
145
+ console.log(`Pick a principal with the "x-principal" header: ${Object.keys(CAST).join(', ')}`);
@@ -0,0 +1,249 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import Database from 'better-sqlite3';
5
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
6
+ import { addMoney, moneyOf, mulMoney } from '@substrat-run/contracts';
7
+ import type { ScopeStub } from '@substrat-run/kernel';
8
+ import type { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
9
+ import type { WorkOrder, BillableLine } from '@substrat-run/engine-workorder';
10
+ import { buildBikeShopHost, seedBikeShop, type BikeShopWorld } from '../src/seed.js';
11
+
12
+ // ============================================================================
13
+ // The bike-shop scenario, replayed headlessly against a temp dir: a repair's
14
+ // full lifecycle (create → assign → start → report → priced completion) drives
15
+ // the invoicing engine BY EVENT, then every door that should be shut is proven
16
+ // shut — each denial pinned to its message and paired with a control that a
17
+ // neighbouring door is open, so a green test can never be a silently-broken one.
18
+ // ============================================================================
19
+
20
+ describe('bike-shop scenario', () => {
21
+ let dir: string;
22
+ let host: SqliteScopeHost;
23
+ let w: BikeShopWorld;
24
+ let greta: ScopeStub;
25
+ let mans: ScopeStub;
26
+ let repairId: string;
27
+
28
+ beforeAll(async () => {
29
+ dir = mkdtempSync(join(tmpdir(), 'substrat-bikeshop-'));
30
+ host = buildBikeShopHost(dir);
31
+ w = await seedBikeShop(host, dir);
32
+ greta = await host.getScope(w.greta, w.t1, w.s1);
33
+ mans = await host.getScope(w.mans, w.t1, w.s1);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await host.close();
38
+ rmSync(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ it('1. provisions and applies all three module journals', () => {
42
+ const db = new Database(join(dir, `${w.t1}__${w.s1}.sqlite`), { readonly: true });
43
+ const rows = db
44
+ .prepare('SELECT DISTINCT module_id FROM _substrat_migrations ORDER BY module_id')
45
+ .all() as { module_id: string }[];
46
+ db.close();
47
+ expect(rows.map((r) => r.module_id)).toEqual([
48
+ '@substrat-run/engine-invoicing',
49
+ '@substrat-run/engine-workorder',
50
+ 'bikeshop',
51
+ ]);
52
+ });
53
+
54
+ it("2. greta opens a repair for Lisbeth's Crescent", async () => {
55
+ const repair = await greta.invoke<WorkOrder>('shop/create-repair', {
56
+ bikeId: w.crescentId,
57
+ kind: 'puncture',
58
+ title: 'Rear puncture, gears skipping',
59
+ });
60
+ repairId = repair.id;
61
+ expect(repair.number).toBe(1);
62
+ expect(repair.status).toBe('planned');
63
+ expect(repair.facility).toEqual({ entityType: 'bike', entityId: w.crescentId });
64
+ expect(repair.customer.entityId).toBe(w.lisbethId);
65
+
66
+ const timeline = await greta.invoke<{ type: string }[]>('shop/timeline', {
67
+ entityType: 'workorder',
68
+ entityId: repairId,
69
+ });
70
+ expect(timeline.map((e) => e.type)).toContain('workorder.created');
71
+ });
72
+
73
+ it('3. assign → start → report time and parts', async () => {
74
+ await greta.invoke('workorder/assign', { orderId: repairId, technician: w.mans });
75
+ await mans.invoke('workorder/start', { orderId: repairId });
76
+ await mans.invoke('workorder/report-time', { orderId: repairId, hours: '0.25' });
77
+ await mans.invoke('workorder/report-material', { orderId: repairId, article: 'tube-28', qty: '1' });
78
+ await mans.invoke('workorder/report-material', {
79
+ orderId: repairId,
80
+ article: 'shop-supplies',
81
+ qty: '1',
82
+ });
83
+ const detail = await mans.invoke<{ order: WorkOrder; time: unknown[]; material: unknown[] }>(
84
+ 'workorder/get',
85
+ { orderId: repairId },
86
+ );
87
+ expect(detail.order.status).toBe('in_progress');
88
+ expect(detail.time).toHaveLength(1);
89
+ expect(detail.material).toHaveLength(2);
90
+ });
91
+
92
+ it('4. every shut door is shut — pinned messages, each paired with an open one', async () => {
93
+ // A mechanic cannot assign…
94
+ await expect(
95
+ mans.invoke('workorder/assign', { orderId: repairId, technician: w.mans }),
96
+ ).rejects.toThrow(/permission denied/);
97
+ // …but the neighbouring door IS open: the same mechanic can report time.
98
+ await expect(
99
+ mans.invoke('workorder/report-time', { orderId: repairId, hours: '0.1' }),
100
+ ).resolves.toBeTruthy();
101
+
102
+ // A portal customer cannot report time…
103
+ const lisbeth = await host.getScope(w.lisbeth, w.t1, w.s1);
104
+ await expect(
105
+ lisbeth.invoke('workorder/report-time', { orderId: repairId, hours: '1' }),
106
+ ).rejects.toThrow(/permission denied/);
107
+ // …but she CAN see her own repair through the portal walk.
108
+ await expect(lisbeth.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toHaveLength(1);
109
+
110
+ // The cross-tenant attacker: claiming t1's scope under his OWN tenant fails
111
+ // the (tenant, scope) pair check…
112
+ await expect(host.getScope(w.rutger, w.t2, w.s1)).rejects.toThrow(/unknown scope/);
113
+ // …the control: his own (t2, s2) pair resolves.
114
+ await expect(host.getScope(w.rutger, w.t2, w.s2)).resolves.toBeTruthy();
115
+ // With the correct pair he can mint a stub but holds no tuples in t1 — every
116
+ // privileged operation is denied by the owning scope's evaluation…
117
+ const rutger = await host.getScope(w.rutger, w.t1, w.s1);
118
+ await expect(rutger.invoke('workorder/list')).rejects.toThrow(/permission denied/);
119
+ await expect(rutger.invoke('shop/list-customers')).rejects.toThrow(/permission denied/);
120
+ await expect(rutger.invoke('invoicing/list')).rejects.toThrow(/permission denied/);
121
+ // …the control: the per-entity portal walk resolves for him too, and returns
122
+ // exactly nothing — an open door onto an empty room, not a denial.
123
+ await expect(rutger.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toEqual([]);
124
+ });
125
+
126
+ it('5. priced completion: the half-hour minimum bills, internal dropped, math exact', async () => {
127
+ const result = await greta.invoke<{ billable: BillableLine[]; total: { amount: string } }>(
128
+ 'shop/complete-repair',
129
+ { orderId: repairId },
130
+ );
131
+
132
+ // Expected totals are computed with the SAME money helpers the operation
133
+ // uses — never hand-derived. 0.35h reported (0.25 + 0.1) < 0.5 min → the
134
+ // minimum bills; shop-supplies is internal and dropped.
135
+ const laborUnit = moneyOf('495', 'SEK');
136
+ const expectedLabor = mulMoney('0.5', laborUnit); // 0.5 × 495
137
+ const expectedTube = mulMoney('1', moneyOf('89', 'SEK'));
138
+ const expectedTotal = addMoney(expectedLabor, expectedTube);
139
+
140
+ expect(result.billable).toHaveLength(2);
141
+ const labor = result.billable.find((b) => b.article === 'labor')!;
142
+ expect(labor.qty).toBe('0.5');
143
+ expect(labor.lineTotal.amount).toBe(expectedLabor.amount);
144
+ const tube = result.billable.find((b) => b.article === 'tube-28')!;
145
+ expect(tube.lineTotal.amount).toBe(expectedTube.amount);
146
+ expect(result.total.amount).toBe(expectedTotal.amount);
147
+ expect(expectedTotal.amount).toBe('336.5'); // and the human-checkable number
148
+
149
+ // Completed is immutable: the engine refuses a further report.
150
+ await expect(
151
+ greta.invoke('workorder/report-time', { orderId: repairId, hours: '1' }),
152
+ ).rejects.toThrow(/invalid transition/);
153
+ });
154
+
155
+ it('6. star topology: the invoicing engine consumed workorder.completed', async () => {
156
+ const underlag = await greta.invoke<{ id: string; status: string; total: string }[]>(
157
+ 'invoicing/list',
158
+ );
159
+ expect(underlag).toHaveLength(1);
160
+ expect(underlag[0]!.status).toBe('open');
161
+ expect(underlag[0]!.total).toBe('336.5');
162
+
163
+ const detail = await greta.invoke<{ lines: { source_id: string; source_type: string }[] }>(
164
+ 'invoicing/get',
165
+ { underlagId: underlag[0]!.id },
166
+ );
167
+ expect(detail.lines).toHaveLength(2);
168
+ expect(detail.lines.every((l) => l.source_type === 'workorder' && l.source_id === repairId)).toBe(
169
+ true,
170
+ );
171
+ });
172
+
173
+ it('7. portal isolation: Lisbeth sees her repair, Otto sees nothing', async () => {
174
+ const lisbeth = await host.getScope(w.lisbeth, w.t1, w.s1);
175
+ const otto = await host.getScope(w.otto, w.t1, w.s1);
176
+
177
+ const hers = await lisbeth.invoke<WorkOrder[]>('shop/portal-repairs');
178
+ expect(hers.map((o) => o.id)).toEqual([repairId]);
179
+ await expect(otto.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toEqual([]);
180
+
181
+ // Lisbeth reads her repair's timeline via the same entity walk…
182
+ await expect(
183
+ lisbeth.invoke<{ type: string }[]>('shop/timeline', {
184
+ entityType: 'workorder',
185
+ entityId: repairId,
186
+ }),
187
+ ).resolves.toBeTruthy();
188
+ // …but invoicing is not a portal concern: denied.
189
+ await expect(lisbeth.invoke('invoicing/list')).rejects.toThrow(/permission denied/);
190
+ });
191
+
192
+ it('8. export makes the underlag immutable; the next completion opens a new one', async () => {
193
+ const [underlag] = await greta.invoke<{ id: string }[]>('invoicing/list');
194
+ await greta.invoke('invoicing/export', { underlagId: underlag!.id });
195
+ await expect(greta.invoke('invoicing/export', { underlagId: underlag!.id })).rejects.toThrow(
196
+ /immutable/,
197
+ );
198
+
199
+ const repair2 = await greta.invoke<WorkOrder>('shop/create-repair', {
200
+ bikeId: w.crescentId,
201
+ kind: 'service',
202
+ title: 'Annual service, new chain',
203
+ });
204
+ await greta.invoke('workorder/start', { orderId: repair2.id });
205
+ await greta.invoke('workorder/report-time', { orderId: repair2.id, hours: '1' });
206
+ await greta.invoke('workorder/report-material', {
207
+ orderId: repair2.id,
208
+ article: 'chain-9s',
209
+ qty: '1',
210
+ });
211
+ await greta.invoke('shop/complete-repair', { orderId: repair2.id });
212
+
213
+ const all = await greta.invoke<{ status: string; total: string }[]>('invoicing/list');
214
+ expect(all).toHaveLength(2);
215
+ expect(all.filter((u) => u.status === 'open')).toHaveLength(1);
216
+ expect(all.filter((u) => u.status === 'exported')).toHaveLength(1);
217
+ // 1h ≥ 0.5 min → 1 × 495 + chain 249 = 744.
218
+ const expectedTotal = addMoney(mulMoney('1', moneyOf('495', 'SEK')), mulMoney('1', moneyOf('249', 'SEK')));
219
+ expect(all.find((u) => u.status === 'open')!.total).toBe(expectedTotal.amount);
220
+ expect(expectedTotal.amount).toBe('744');
221
+ });
222
+
223
+ it('9. the engine state machine cannot be skipped', async () => {
224
+ const repair = await greta.invoke<WorkOrder>('shop/create-repair', {
225
+ bikeId: w.bianchiId,
226
+ kind: 'service',
227
+ title: 'Brake adjustment',
228
+ });
229
+ // planned → completed is not a legal step (needs in_progress)…
230
+ await expect(greta.invoke('shop/complete-repair', { orderId: repair.id })).rejects.toThrow(
231
+ /invalid transition/,
232
+ );
233
+ // …nor planned → closed (needs completed).
234
+ await expect(greta.invoke('shop/close-repair', { orderId: repair.id })).rejects.toThrow(
235
+ /invalid transition/,
236
+ );
237
+
238
+ // The control: walked one legal step at a time, the same doors open.
239
+ await greta.invoke('workorder/start', { orderId: repair.id });
240
+ await greta.invoke('workorder/report-time', { orderId: repair.id, hours: '0.5' });
241
+ await greta.invoke('shop/complete-repair', { orderId: repair.id });
242
+ const closed = await greta.invoke<WorkOrder>('shop/close-repair', { orderId: repair.id });
243
+ expect(closed.status).toBe('closed');
244
+ // And once closed, the machine refuses to close again.
245
+ await expect(greta.invoke('shop/close-repair', { orderId: repair.id })).rejects.toThrow(
246
+ /invalid transition/,
247
+ );
248
+ });
249
+ });