create-substrat 0.8.2 → 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 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.93.0';
40
- const ENGINE_WORKORDER = '^0.10.1';
41
- const ENGINE_INVOICING = '^0.9.9';
42
- const BOUNDARY_LINT = '^0.2.0';
43
- const DEV_ISSUER = '^0.1.7';
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,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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
- const createCustomerOp: OperationHandler<
70
- { number: string; name: string; phone?: string },
71
- CustomerRow
72
- > = async (ctx, input) => {
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 registerBikeOp: OperationHandler<
96
- { customerId: string; label: string; frameNo?: string },
97
- BikeRow
98
- > = async (ctx, input) => {
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 upsertPriceOp: OperationHandler<
116
- {
117
- article: string;
118
- description: string;
119
- unit: string;
120
- priceAmount: string;
121
- currency?: string;
122
- minQty?: string;
123
- internal?: boolean;
124
- },
125
- PriceRow
126
- > = async (ctx, input) => {
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 createRepairOp: OperationHandler<
158
- { bikeId: string; kind: string; title: string; description?: string },
159
- WorkOrder
160
- > = async (ctx, input) => {
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
- { orderId: string },
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<{ orderId: string }, WorkOrder> = async (ctx, input) => {
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, rawInput) => {
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
  });