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.
- package/index.js +214 -33
- package/package.json +4 -3
- 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 +386 -0
- package/template/AGENTS.md +117 -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,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
|
+
}
|