create-substrat 0.8.1 → 0.8.3
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 +5 -5
- package/package.json +1 -1
- package/template/.substrat/playbook.md +1 -0
- package/template/src/module.ts +85 -29
- package/template/src/worker.ts +5 -1
- package/template/test/scenario.test.ts +23 -0
package/index.js
CHANGED
|
@@ -36,11 +36,11 @@ const TEMPLATE = join(HERE, 'template');
|
|
|
36
36
|
// The runtime packages release together off one version line (the changesets `fixed`
|
|
37
37
|
// group), so one constant is right for all of them. Engines do NOT share a line —
|
|
38
38
|
// each versions on its own, so one pin per engine, deliberately.
|
|
39
|
-
const SUBSTRAT = '^0.
|
|
40
|
-
const ENGINE_WORKORDER = '^0.
|
|
41
|
-
const ENGINE_INVOICING = '^0.9.
|
|
42
|
-
const BOUNDARY_LINT = '^0.2.
|
|
43
|
-
const DEV_ISSUER = '^0.1.
|
|
39
|
+
const SUBSTRAT = '^0.94.0';
|
|
40
|
+
const ENGINE_WORKORDER = '^0.10.2';
|
|
41
|
+
const ENGINE_INVOICING = '^0.9.10';
|
|
42
|
+
const BOUNDARY_LINT = '^0.2.1';
|
|
43
|
+
const DEV_ISSUER = '^0.1.8';
|
|
44
44
|
|
|
45
45
|
const DOCS = 'https://substrat.net';
|
|
46
46
|
|
package/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<!-- Adapted by hand from the substrat skill (.claude/skills/substrat/SKILL.md in substrat-run/substrat) — NOT emitted from it. The two diverge on purpose; tools/playbook-sync.mts fails CI when the skill moves so a human ports what belongs here. Edit this file directly. -->
|
|
1
2
|
# Playbook — build a vertical on Substrat
|
|
2
3
|
|
|
3
4
|
The always-on rules live in [`AGENTS.md`](../AGENTS.md); read them first. This playbook is
|
package/template/src/module.ts
CHANGED
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
compareDecimal,
|
|
4
4
|
moneyOf,
|
|
5
5
|
mulMoney,
|
|
6
|
+
operationInputsOf,
|
|
6
7
|
pageVisible,
|
|
7
8
|
z,
|
|
8
|
-
type EntityRef,
|
|
9
9
|
type Money,
|
|
10
10
|
type Page,
|
|
11
11
|
} from '@substrat-run/contracts';
|
|
@@ -66,10 +66,20 @@ export interface PriceRow {
|
|
|
66
66
|
internal: number;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
// Every operation that takes an input declares it as a Zod object here, and the
|
|
70
|
+
// handler's input type is `z.infer` of that object — one description of the
|
|
71
|
+
// shape, so the schema and the type cannot drift apart. `bikeShopOperations` at
|
|
72
|
+
// the bottom of this file hands the whole set to the host.
|
|
73
|
+
const createCustomerInput = z.object({
|
|
74
|
+
number: z.string().min(1),
|
|
75
|
+
name: z.string().min(1),
|
|
76
|
+
phone: z.string().min(1).optional(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const createCustomerOp: OperationHandler<z.infer<typeof createCustomerInput>, CustomerRow> = async (
|
|
80
|
+
ctx,
|
|
81
|
+
input,
|
|
82
|
+
) => {
|
|
73
83
|
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
74
84
|
const id = ulid();
|
|
75
85
|
ctx.sql.exec(
|
|
@@ -92,10 +102,16 @@ const listCustomersOp: OperationHandler<undefined, (CustomerRow & { bikes: BikeR
|
|
|
92
102
|
}));
|
|
93
103
|
};
|
|
94
104
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
const registerBikeInput = z.object({
|
|
106
|
+
customerId: z.string().min(1),
|
|
107
|
+
label: z.string().min(1),
|
|
108
|
+
frameNo: z.string().min(1).optional(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const registerBikeOp: OperationHandler<z.infer<typeof registerBikeInput>, BikeRow> = async (
|
|
112
|
+
ctx,
|
|
113
|
+
input,
|
|
114
|
+
) => {
|
|
99
115
|
assertAllowed(await ctx.check(SHOP_PERM.bikeManage));
|
|
100
116
|
const customer = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [
|
|
101
117
|
input.customerId,
|
|
@@ -112,18 +128,20 @@ const registerBikeOp: OperationHandler<
|
|
|
112
128
|
return ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [id])[0]!;
|
|
113
129
|
};
|
|
114
130
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
const upsertPriceInput = z.object({
|
|
132
|
+
article: z.string().min(1),
|
|
133
|
+
description: z.string().min(1),
|
|
134
|
+
unit: z.string().min(1),
|
|
135
|
+
priceAmount: z.string().min(1),
|
|
136
|
+
currency: z.string().min(1).optional(),
|
|
137
|
+
minQty: z.string().min(1).optional(),
|
|
138
|
+
internal: z.boolean().optional(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const upsertPriceOp: OperationHandler<z.infer<typeof upsertPriceInput>, PriceRow> = async (
|
|
142
|
+
ctx,
|
|
143
|
+
input,
|
|
144
|
+
) => {
|
|
127
145
|
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
128
146
|
ctx.sql.exec(
|
|
129
147
|
`INSERT OR REPLACE INTO shop_price_list
|
|
@@ -154,10 +172,17 @@ const priceListOp: OperationHandler<undefined, PriceRow[]> = async (ctx) => {
|
|
|
154
172
|
* The vertical resolves its own vocabulary (a bike, its owner) into the engine's
|
|
155
173
|
* `facility`/`customer` refs; the engine owns the number, the state, the event.
|
|
156
174
|
*/
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
const createRepairInput = z.object({
|
|
176
|
+
bikeId: z.string().min(1),
|
|
177
|
+
kind: z.string().min(1),
|
|
178
|
+
title: z.string().min(1),
|
|
179
|
+
description: z.string().optional(),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const createRepairOp: OperationHandler<z.infer<typeof createRepairInput>, WorkOrder> = async (
|
|
183
|
+
ctx,
|
|
184
|
+
input,
|
|
185
|
+
) => {
|
|
161
186
|
assertAllowed(await ctx.check(WO.create));
|
|
162
187
|
const bike = ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [input.bikeId])[0];
|
|
163
188
|
if (!bike) throw new Error(`bike not found: ${input.bikeId}`);
|
|
@@ -179,8 +204,10 @@ const createRepairOp: OperationHandler<
|
|
|
179
204
|
* The engine's `workorder.completed` event carries these lines, and the
|
|
180
205
|
* invoicing engine consumes it — no import between the two.
|
|
181
206
|
*/
|
|
207
|
+
const repairIdInput = z.object({ orderId: z.string().min(1) });
|
|
208
|
+
|
|
182
209
|
const completeRepairOp: OperationHandler<
|
|
183
|
-
|
|
210
|
+
z.infer<typeof repairIdInput>,
|
|
184
211
|
{ order: WorkOrder; billable: BillableLine[]; total: Money }
|
|
185
212
|
> = async (ctx, input) => {
|
|
186
213
|
assertAllowed(await ctx.check(WO.complete));
|
|
@@ -237,7 +264,10 @@ const completeRepairOp: OperationHandler<
|
|
|
237
264
|
* engine's in-scope `closeWorkOrder`; the vertical owns the vocabulary
|
|
238
265
|
* ("pickup"), the engine owns the transition.
|
|
239
266
|
*/
|
|
240
|
-
const closeRepairOp: OperationHandler<
|
|
267
|
+
const closeRepairOp: OperationHandler<z.infer<typeof repairIdInput>, WorkOrder> = async (
|
|
268
|
+
ctx,
|
|
269
|
+
input,
|
|
270
|
+
) => {
|
|
241
271
|
assertAllowed(await ctx.check(WO.close));
|
|
242
272
|
return closeWorkOrder(ctx, { orderId: input.orderId });
|
|
243
273
|
};
|
|
@@ -276,12 +306,14 @@ const timelineInput = z.object({
|
|
|
276
306
|
* An entity's event timeline, read straight off the spine (a read of `_substrat_*`
|
|
277
307
|
* for a projection is allowed; writing it is not). Gated by a per-entity
|
|
278
308
|
* `workorder:read` check, so it obeys the same walk as the portal.
|
|
309
|
+
*
|
|
310
|
+
* No `.parse` in here: the host already parsed `entity` against
|
|
311
|
+
* `timelineInput` before this line ran, on whichever path the call came in by.
|
|
279
312
|
*/
|
|
280
313
|
const timelineOp: OperationHandler<
|
|
281
314
|
z.infer<typeof timelineInput>,
|
|
282
315
|
{ type: string; occurred_at: string; actor: string }[]
|
|
283
|
-
> = async (ctx,
|
|
284
|
-
const entity: EntityRef = timelineInput.parse(rawInput);
|
|
316
|
+
> = async (ctx, entity) => {
|
|
285
317
|
assertAllowed(await ctx.check(WO.read, entity));
|
|
286
318
|
// Append order is authoritative — rowid, not ULID (ids minted in the same
|
|
287
319
|
// millisecond are not mutually ordered).
|
|
@@ -292,9 +324,33 @@ const timelineOp: OperationHandler<
|
|
|
292
324
|
);
|
|
293
325
|
};
|
|
294
326
|
|
|
327
|
+
/**
|
|
328
|
+
* What each operation accepts. A complete census of the ten below: an entry with
|
|
329
|
+
* no `input` takes nothing, and `paged: true` is how the portal read says the
|
|
330
|
+
* platform supplies the page trio (`limit`/`cursor`/`order`/`sort`) — declaring
|
|
331
|
+
* those four by hand is how the two descriptions of one page come to disagree.
|
|
332
|
+
*/
|
|
333
|
+
const bikeShopOperations = {
|
|
334
|
+
'shop/create-customer': { input: createCustomerInput },
|
|
335
|
+
'shop/list-customers': {},
|
|
336
|
+
'shop/register-bike': { input: registerBikeInput },
|
|
337
|
+
'shop/upsert-price': { input: upsertPriceInput },
|
|
338
|
+
'shop/price-list': {},
|
|
339
|
+
'shop/create-repair': { input: createRepairInput },
|
|
340
|
+
'shop/complete-repair': { input: repairIdInput },
|
|
341
|
+
'shop/close-repair': { input: repairIdInput },
|
|
342
|
+
'shop/portal-repairs': { paged: true },
|
|
343
|
+
'shop/timeline': { input: timelineInput },
|
|
344
|
+
};
|
|
345
|
+
|
|
295
346
|
export const bikeShopModule: ModuleRegistration = {
|
|
296
347
|
manifest: bikeShopManifest,
|
|
297
348
|
migrations: bikeShopMigrations,
|
|
349
|
+
// The host parses every invocation against these before the guards, the
|
|
350
|
+
// permission check and the handler — so "parse, don't trust" holds on every
|
|
351
|
+
// path in (HTTP, test, seed, schedule) rather than in the handlers that
|
|
352
|
+
// remembered to do it themselves.
|
|
353
|
+
operationInputs: operationInputsOf(bikeShopOperations),
|
|
298
354
|
operations: {
|
|
299
355
|
'shop/create-customer': createCustomerOp as never,
|
|
300
356
|
'shop/list-customers': listCustomersOp as never,
|
package/template/src/worker.ts
CHANGED
|
@@ -121,7 +121,11 @@ interface Env {
|
|
|
121
121
|
function nodeFor(req: Request, env: Env): Node {
|
|
122
122
|
let routed;
|
|
123
123
|
try {
|
|
124
|
-
routed = readRoutedNode(req.headers, {
|
|
124
|
+
routed = readRoutedNode(req.headers, {
|
|
125
|
+
expectedSecret: env.ROUTER_SECRET,
|
|
126
|
+
// #966: an unsigned assertion is refused unless this is an un-routed dev instance.
|
|
127
|
+
allowUnsigned: env.ALLOW_DEV_NODE === 'true',
|
|
128
|
+
});
|
|
125
129
|
} catch (e) {
|
|
126
130
|
if (e instanceof RouterAssertionError) throw new HTTPException(400, { message: e.message });
|
|
127
131
|
throw e;
|
|
@@ -257,4 +257,27 @@ describe('bike-shop scenario', () => {
|
|
|
257
257
|
/invalid transition/,
|
|
258
258
|
);
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
// #953: the module hands the host `operationInputs`, so a malformed call is
|
|
262
|
+
// refused at the scope door — none of these handlers parses anything itself.
|
|
263
|
+
//
|
|
264
|
+
// A principal with NO permission is what makes the assertion mean something:
|
|
265
|
+
// the host parses BEFORE the permission check, so the refusal is about the
|
|
266
|
+
// shape. Drop `operationInputs` and the same call comes back "permission
|
|
267
|
+
// denied" — the handler was reached, and a permitted caller would have been
|
|
268
|
+
// handed the unparsed value.
|
|
269
|
+
it('10. the HOST parses an invocation, before the permission check', async () => {
|
|
270
|
+
const rutger = await host.getScope(w.rutger, w.t1, w.s1);
|
|
271
|
+
await expect(rutger.invoke('shop/create-customer', { number: 42, name: 'x' })).rejects.toThrow(
|
|
272
|
+
/invalid|expected/i,
|
|
273
|
+
);
|
|
274
|
+
await expect(rutger.invoke('shop/create-customer', { name: 'x' })).rejects.toThrow(
|
|
275
|
+
/invalid|required|expected/i,
|
|
276
|
+
);
|
|
277
|
+
// The control: a WELL-FORMED call from the same principal is refused for the
|
|
278
|
+
// reason it should be, so the two refusals are telling us different things.
|
|
279
|
+
await expect(
|
|
280
|
+
rutger.invoke('shop/create-customer', { number: '9001', name: 'x' }),
|
|
281
|
+
).rejects.toThrow(/permission denied/);
|
|
282
|
+
});
|
|
260
283
|
});
|