create-substrat 0.8.2 → 0.8.4
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/AGENTS.md +8 -1
- package/template/src/module.ts +85 -29
- 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.10.
|
|
41
|
-
const ENGINE_INVOICING = '^0.9.
|
|
42
|
-
const BOUNDARY_LINT = '^0.
|
|
43
|
-
const DEV_ISSUER = '^0.1.
|
|
39
|
+
const SUBSTRAT = '^0.95.0';
|
|
40
|
+
const ENGINE_WORKORDER = '^0.10.3';
|
|
41
|
+
const ENGINE_INVOICING = '^0.9.11';
|
|
42
|
+
const BOUNDARY_LINT = '^0.3.0';
|
|
43
|
+
const DEV_ISSUER = '^0.1.9';
|
|
44
44
|
|
|
45
45
|
const DOCS = 'https://substrat.net';
|
|
46
46
|
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -108,7 +108,14 @@ consumers). Rules 1–5 are enforced mechanically by `boundary-lint`.
|
|
|
108
108
|
handler that runs outside the transaction. An integration is never impossible because
|
|
109
109
|
of this rule — it has an answer.
|
|
110
110
|
3. **Never write `_substrat_*` tables.** Reads are fine (timelines are projections);
|
|
111
|
-
writes forge the audit spine.
|
|
111
|
+
writes forge the audit spine. Do the read with `readTimeline` / `readHistory` from
|
|
112
|
+
`@substrat-run/kernel` rather than a `SELECT` of your own: both take an `EntityRef`,
|
|
113
|
+
page like a list read, and decode the envelope for you. `readHistory` also returns the
|
|
114
|
+
payload, the authorization chain (which checks the operation passed, and under which
|
|
115
|
+
grant), the impersonation stamp and the PII class. On the first three, a `null` is a
|
|
116
|
+
*fact* rather than data you failed to fetch — the payload was erased, the row predates
|
|
117
|
+
authorization recording, nobody was impersonating — so render it as that. Neither
|
|
118
|
+
helper checks a permission; you do, before you call it.
|
|
112
119
|
4. **Another module's tables are private.** Never `SELECT` from `workorder_*` etc. — use
|
|
113
120
|
the engine's exported in-scope functions. This is the rule with no runtime equivalent:
|
|
114
121
|
the shortcut *works* and silently welds you to an engine's private schema forever. Need
|
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,
|
|
@@ -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
|
});
|