create-substrat 0.7.1 → 0.7.2
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 +4 -4
- package/package.json +1 -1
- package/template/src/module.ts +21 -8
- package/template/test/scenario.test.ts +18 -11
package/index.js
CHANGED
|
@@ -35,10 +35,10 @@ const TEMPLATE = join(HERE, 'template');
|
|
|
35
35
|
// The runtime packages release together off one version line (the changesets `fixed`
|
|
36
36
|
// group), so one constant is right for all of them. Engines do NOT share a line —
|
|
37
37
|
// each versions on its own, so one pin per engine, deliberately.
|
|
38
|
-
const SUBSTRAT = '^0.
|
|
39
|
-
const ENGINE_WORKORDER = '^0.8.
|
|
40
|
-
const ENGINE_INVOICING = '^0.9.
|
|
41
|
-
const BOUNDARY_LINT = '^0.1.
|
|
38
|
+
const SUBSTRAT = '^0.85.0';
|
|
39
|
+
const ENGINE_WORKORDER = '^0.8.1';
|
|
40
|
+
const ENGINE_INVOICING = '^0.9.1';
|
|
41
|
+
const BOUNDARY_LINT = '^0.1.1';
|
|
42
42
|
|
|
43
43
|
const DOCS = 'https://substrat.net';
|
|
44
44
|
|
package/package.json
CHANGED
package/template/src/module.ts
CHANGED
|
@@ -3,15 +3,18 @@ import {
|
|
|
3
3
|
compareDecimal,
|
|
4
4
|
moneyOf,
|
|
5
5
|
mulMoney,
|
|
6
|
+
pageVisible,
|
|
6
7
|
z,
|
|
7
8
|
type EntityRef,
|
|
8
9
|
type Money,
|
|
10
|
+
type Page,
|
|
9
11
|
} from '@substrat-run/contracts';
|
|
10
12
|
import {
|
|
11
13
|
assertAllowed,
|
|
12
14
|
ulid,
|
|
13
15
|
type ModuleRegistration,
|
|
14
16
|
type OperationHandler,
|
|
17
|
+
type PageParams,
|
|
15
18
|
} from '@substrat-run/kernel';
|
|
16
19
|
import {
|
|
17
20
|
closeWorkOrder,
|
|
@@ -244,15 +247,25 @@ const closeRepairOp: OperationHandler<{ orderId: string }, WorkOrder> = async (c
|
|
|
244
247
|
* PER-ENTITY check per repair. A portal customer holds an entity-narrowed grant
|
|
245
248
|
* on their own customer record, so the walk workorder → bike → customer lets
|
|
246
249
|
* them through for their own repairs and no one else's.
|
|
250
|
+
*
|
|
251
|
+
* Paged by OVER-FETCHING, which is what a permission-filtered walk needs: a page
|
|
252
|
+
* of 20 rows read from the table can leave 3 standing after the proof walk, so the
|
|
253
|
+
* fetch size and the page size are not the same number and cannot be made the same
|
|
254
|
+
* number. `pageVisible` does the over-fetch and advances the cursor by the last row
|
|
255
|
+
* EXAMINED — advancing by the last row RETURNED would re-examine every rejected row
|
|
256
|
+
* on the next request, and a page the walk rejects entirely would never advance at
|
|
257
|
+
* all. So a SHORT page does not end this walk; only a null `nextCursor` does.
|
|
247
258
|
*/
|
|
248
|
-
const portalRepairsOp: OperationHandler<undefined, WorkOrder
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
259
|
+
const portalRepairsOp: OperationHandler<PageParams | undefined, Page<WorkOrder>> = async (
|
|
260
|
+
ctx,
|
|
261
|
+
input,
|
|
262
|
+
) =>
|
|
263
|
+
pageVisible(
|
|
264
|
+
(p) => listOrders(ctx, { ...input, ...p }),
|
|
265
|
+
input,
|
|
266
|
+
async (order) =>
|
|
267
|
+
(await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id })).allowed,
|
|
268
|
+
);
|
|
256
269
|
|
|
257
270
|
const timelineInput = z.object({
|
|
258
271
|
entityType: z.string().min(1),
|
|
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
6
|
-
import { addMoney, moneyOf, mulMoney } from '@substrat-run/contracts';
|
|
6
|
+
import { addMoney, moneyOf, mulMoney, type Page } from '@substrat-run/contracts';
|
|
7
7
|
import type { ScopeStub } from '@substrat-run/kernel';
|
|
8
8
|
import type { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
|
|
9
9
|
import type { WorkOrder, BillableLine } from '@substrat-run/engine-workorder';
|
|
@@ -105,7 +105,8 @@ describe('bike-shop scenario', () => {
|
|
|
105
105
|
lisbeth.invoke('workorder/report-time', { orderId: repairId, hours: '1' }),
|
|
106
106
|
).rejects.toThrow(/permission denied/);
|
|
107
107
|
// …but she CAN see her own repair through the portal walk.
|
|
108
|
-
await
|
|
108
|
+
const lisbethSees = await lisbeth.invoke<Page<WorkOrder>>('shop/portal-repairs');
|
|
109
|
+
expect(lisbethSees.entries).toHaveLength(1);
|
|
109
110
|
|
|
110
111
|
// The cross-tenant attacker: claiming t1's scope under his OWN tenant fails
|
|
111
112
|
// the (tenant, scope) pair check…
|
|
@@ -120,7 +121,8 @@ describe('bike-shop scenario', () => {
|
|
|
120
121
|
await expect(rutger.invoke('invoicing/list')).rejects.toThrow(/permission denied/);
|
|
121
122
|
// …the control: the per-entity portal walk resolves for him too, and returns
|
|
122
123
|
// exactly nothing — an open door onto an empty room, not a denial.
|
|
123
|
-
await
|
|
124
|
+
const rutgerSees = await rutger.invoke<Page<WorkOrder>>('shop/portal-repairs');
|
|
125
|
+
expect(rutgerSees.entries).toEqual([]);
|
|
124
126
|
});
|
|
125
127
|
|
|
126
128
|
it('5. priced completion: the half-hour minimum bills, internal dropped, math exact', async () => {
|
|
@@ -153,9 +155,9 @@ describe('bike-shop scenario', () => {
|
|
|
153
155
|
});
|
|
154
156
|
|
|
155
157
|
it('6. star topology: the invoicing engine consumed workorder.completed', async () => {
|
|
156
|
-
const underlag = await greta.invoke<
|
|
157
|
-
|
|
158
|
-
);
|
|
158
|
+
const { entries: underlag } = await greta.invoke<
|
|
159
|
+
Page<{ id: string; status: string; total: string }>
|
|
160
|
+
>('invoicing/list');
|
|
159
161
|
expect(underlag).toHaveLength(1);
|
|
160
162
|
expect(underlag[0]!.status).toBe('open');
|
|
161
163
|
expect(underlag[0]!.total).toBe('336.5');
|
|
@@ -178,9 +180,10 @@ describe('bike-shop scenario', () => {
|
|
|
178
180
|
const lisbeth = await host.getScope(w.lisbeth, w.t1, w.s1);
|
|
179
181
|
const otto = await host.getScope(w.otto, w.t1, w.s1);
|
|
180
182
|
|
|
181
|
-
const hers = await lisbeth.invoke<WorkOrder
|
|
182
|
-
expect(hers.map((o) => o.id)).toEqual([repairId]);
|
|
183
|
-
await
|
|
183
|
+
const hers = await lisbeth.invoke<Page<WorkOrder>>('shop/portal-repairs');
|
|
184
|
+
expect(hers.entries.map((o) => o.id)).toEqual([repairId]);
|
|
185
|
+
const ottos = await otto.invoke<Page<WorkOrder>>('shop/portal-repairs');
|
|
186
|
+
expect(ottos.entries).toEqual([]);
|
|
184
187
|
|
|
185
188
|
// Lisbeth reads her repair's timeline via the same entity walk…
|
|
186
189
|
await expect(
|
|
@@ -194,7 +197,9 @@ describe('bike-shop scenario', () => {
|
|
|
194
197
|
});
|
|
195
198
|
|
|
196
199
|
it('8. export makes the underlag immutable; the next completion opens a new one', async () => {
|
|
197
|
-
const
|
|
200
|
+
const {
|
|
201
|
+
entries: [underlag],
|
|
202
|
+
} = await greta.invoke<Page<{ id: string }>>('invoicing/list');
|
|
198
203
|
await greta.invoke('invoicing/export', { underlagId: underlag!.id });
|
|
199
204
|
await expect(greta.invoke('invoicing/export', { underlagId: underlag!.id })).rejects.toThrow(
|
|
200
205
|
/immutable/,
|
|
@@ -214,7 +219,9 @@ describe('bike-shop scenario', () => {
|
|
|
214
219
|
});
|
|
215
220
|
await greta.invoke('shop/complete-repair', { orderId: repair2.id });
|
|
216
221
|
|
|
217
|
-
const all = await greta.invoke<{ status: string; total: string }
|
|
222
|
+
const { entries: all } = await greta.invoke<Page<{ status: string; total: string }>>(
|
|
223
|
+
'invoicing/list',
|
|
224
|
+
);
|
|
218
225
|
expect(all).toHaveLength(2);
|
|
219
226
|
expect(all.filter((u) => u.status === 'open')).toHaveLength(1);
|
|
220
227
|
expect(all.filter((u) => u.status === 'exported')).toHaveLength(1);
|