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,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
+ });