create-substrat 0.0.1 → 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.
- package/index.js +209 -32
- package/package.json +3 -2
- package/template/.claude/skills/substrat/SKILL.md +13 -0
- package/template/.cursor/commands/new-vertical.md +7 -0
- package/template/.cursor/rules/substrat.mdc +11 -0
- package/template/.opencode/command/new-vertical.md +9 -0
- package/template/.substrat/playbook.md +310 -0
- package/template/AGENTS.md +116 -0
- package/template/CLAUDE.md +8 -0
- package/template/src/manifest.ts +48 -0
- package/template/src/migrations.ts +39 -0
- package/template/src/module.ts +297 -0
- package/template/src/seed.ts +265 -0
- package/template/src/server.ts +145 -0
- package/template/test/scenario.test.ts +249 -0
|
@@ -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
|
+
});
|