create-substrat 0.10.16 → 0.11.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 +10 -11
- package/package.json +1 -1
- package/project-files.js +8 -0
- package/template/AGENTS.md +5 -0
- package/template/src/module.ts +6 -6
- package/template/src/provision.ts +12 -2
- package/template/src/routes.ts +4 -2
- package/template/src/seed.ts +33 -18
- package/template/test/invoke-route.test.ts +47 -0
- package/template/test/portal-shape.test.ts +75 -0
- package/template/vitest.ui.config.ts +9 -21
package/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
|
|
19
19
|
import { DEV_SERVERS } from './dev-servers.js';
|
|
20
|
-
import { TSCONFIG, VITEST_CONFIG } from './project-files.js';
|
|
20
|
+
import { TEST_RUNNER, TSCONFIG, VITEST_CONFIG } from './project-files.js';
|
|
21
21
|
|
|
22
22
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
23
|
const TEMPLATE = join(HERE, 'template');
|
|
@@ -36,12 +36,12 @@ 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.12.
|
|
41
|
-
const ENGINE_INVOICING = '^0.11.
|
|
42
|
-
const BOUNDARY_LINT = '^0.6.
|
|
43
|
-
const DEV_ISSUER = '^0.2.
|
|
44
|
-
const ATTACHMENT_EXTRACTORS = '^0.2.
|
|
39
|
+
const SUBSTRAT = '^0.141.0';
|
|
40
|
+
const ENGINE_WORKORDER = '^0.12.25';
|
|
41
|
+
const ENGINE_INVOICING = '^0.11.25';
|
|
42
|
+
const BOUNDARY_LINT = '^0.6.2';
|
|
43
|
+
const DEV_ISSUER = '^0.2.23';
|
|
44
|
+
const ATTACHMENT_EXTRACTORS = '^0.2.2';
|
|
45
45
|
|
|
46
46
|
const DOCS = 'https://substrat.net';
|
|
47
47
|
|
|
@@ -114,8 +114,8 @@ function packageJson(name) {
|
|
|
114
114
|
issuer: 'substrat-dev-issuer --personas src/personas.ts',
|
|
115
115
|
server: 'tsx src/server.ts',
|
|
116
116
|
test: 'vitest run',
|
|
117
|
-
// The live test dashboard
|
|
118
|
-
// .
|
|
117
|
+
// The live test dashboard: the `tests` entry of .claude/launch.json, opened at the
|
|
118
|
+
// tokenised URL vitest prints. `--watch` is explicit, since vitest runs once and exits without a TTY.
|
|
119
119
|
'test:ui': 'vitest --watch --ui --open=false -c vitest.ui.config.ts',
|
|
120
120
|
typecheck: 'tsc --noEmit && tsc -p tsconfig.worker.json --noEmit',
|
|
121
121
|
'lint:boundaries': 'substrat-boundary-lint',
|
|
@@ -144,8 +144,7 @@ function packageJson(name) {
|
|
|
144
144
|
concurrently: '^9.0.0',
|
|
145
145
|
tsx: '^4.19.0',
|
|
146
146
|
typescript: '^5.6.0',
|
|
147
|
-
|
|
148
|
-
'@vitest/ui': '^3.0.0',
|
|
147
|
+
...TEST_RUNNER,
|
|
149
148
|
},
|
|
150
149
|
// Do NOT add `zod` here — import `z` from `@substrat-run/contracts` (AGENTS.md, rule 10).
|
|
151
150
|
// better-sqlite3 >=13 ships prebuilt binaries for every supported platform and
|
package/package.json
CHANGED
package/project-files.js
CHANGED
|
@@ -52,3 +52,11 @@ export default defineConfig({
|
|
|
52
52
|
},
|
|
53
53
|
});
|
|
54
54
|
`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The test runner a scaffold installs. Here rather than inline in `index.js`'s `packageJson()`
|
|
58
|
+
* so `tools/template-sync.mjs` can hold it to the vitest the workspace runs the template on
|
|
59
|
+
* (#2129): `packages/template-check` tests the template on the catalog's vitest, and a scaffold
|
|
60
|
+
* on a different major is a project nothing on a PR has ever run.
|
|
61
|
+
*/
|
|
62
|
+
export const TEST_RUNNER = { vitest: '^5.0.0', '@vitest/ui': '^5.0.0' };
|
package/template/AGENTS.md
CHANGED
|
@@ -336,6 +336,11 @@ The page worth opening first on a vertical that serves the Scalar API reference
|
|
|
336
336
|
vertical's own origin, so a try-it request carries the session cookie you already have and
|
|
337
337
|
a 403 in the playground is the permission system working rather than a broken page.
|
|
338
338
|
|
|
339
|
+
The `tests` entry is the one whose bare origin shows nothing: the vitest dashboard serves
|
|
340
|
+
only the URL it prints when it starts (`UI started at …/__vitest__/?token=…`) and answers a
|
|
341
|
+
403 without the token, which guards an API that can write files and run code. Take that URL
|
|
342
|
+
from the server's output; never work around the token.
|
|
343
|
+
|
|
339
344
|
## When it breaks — symptom → fix
|
|
340
345
|
|
|
341
346
|
Six failures that have each cost someone a day. What makes them expensive is that none of
|
package/template/src/module.ts
CHANGED
|
@@ -254,13 +254,12 @@ const closeRepairOp: Op<'shop/close-repair'> = async (ctx, input) => {
|
|
|
254
254
|
* on their own customer record, so the walk workorder → bike → customer lets
|
|
255
255
|
* them through for their own repairs and no one else's.
|
|
256
256
|
*
|
|
257
|
-
* Paged
|
|
257
|
+
* Paged with `pageVisible`, which is what a permission-filtered walk needs: a page
|
|
258
258
|
* of 20 rows read from the table can leave 3 standing after the proof walk, so the
|
|
259
|
-
* fetch size and the page size are not the same number
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* all. So a SHORT page does not end this walk; only a null `nextCursor` does.
|
|
259
|
+
* fetch size and the page size are not the same number. `pageVisible` reads on past
|
|
260
|
+
* refused rows until the page is full or the budget is spent. It seals the
|
|
261
|
+
* continuation, including on a short page at a budget stop, so a refused row's
|
|
262
|
+
* position never reaches the caller (#2074).
|
|
264
263
|
*/
|
|
265
264
|
const portalRepairsOp: Op<'shop/portal-repairs'> = async (ctx, input) =>
|
|
266
265
|
pageVisible(
|
|
@@ -268,6 +267,7 @@ const portalRepairsOp: Op<'shop/portal-repairs'> = async (ctx, input) =>
|
|
|
268
267
|
input,
|
|
269
268
|
async (order) =>
|
|
270
269
|
(await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id })).allowed,
|
|
270
|
+
{ continuation: ctx.pageContinuation('workorder:portal-repairs', input) },
|
|
271
271
|
);
|
|
272
272
|
|
|
273
273
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
definePermissions,
|
|
3
|
+
type EntityGrantShape,
|
|
3
4
|
type PermissionKey,
|
|
4
5
|
type RoleDefinition,
|
|
5
6
|
} from '@substrat-run/contracts';
|
|
@@ -65,9 +66,18 @@ export const portalPerms: PermissionKey[] = [WO.read];
|
|
|
65
66
|
* Entity-narrowed grant SHAPES. The grants themselves are per-principal and
|
|
66
67
|
* minted at runtime, so they can never be a build artifact; their shape is what
|
|
67
68
|
* tells a reviewer which keys are reachable outside the role table.
|
|
69
|
+
*
|
|
70
|
+
* `customer` is a BOOTSTRAP shape: a person linked to a customer record is
|
|
71
|
+
* given all of it at once (`grantEntityShape`, never key by key), and when you
|
|
72
|
+
* add a key here, the platform's next reconcile gives it to everyone who
|
|
73
|
+
* already holds the shape. A customer record names no principal and can have
|
|
74
|
+
* several people, so its holder is `'grantee'`: whoever holds a key of it
|
|
75
|
+
* there. That stays true because `ctx.grant` cannot give these keys on a
|
|
76
|
+
* customer. A shape people reach by sharing (`ctx.grant`) is declared without
|
|
77
|
+
* `bootstrap`, and is never topped up.
|
|
68
78
|
*/
|
|
69
|
-
export const ENTITY_GRANTS:
|
|
70
|
-
{ entityType: 'customer', permissions: portalPerms },
|
|
79
|
+
export const ENTITY_GRANTS: EntityGrantShape[] = [
|
|
80
|
+
{ entityType: 'customer', permissions: portalPerms, bootstrap: true, holder: 'grantee' },
|
|
71
81
|
];
|
|
72
82
|
|
|
73
83
|
/**
|
package/template/src/routes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context, Hono } from 'hono';
|
|
2
|
-
import { mountOperations, problemResponse, type ResolveStub } from '@substrat-run/vertical-host';
|
|
2
|
+
import { externalInput, externalJson, mountOperations, problemResponse, type ResolveStub } from '@substrat-run/vertical-host';
|
|
3
3
|
import { bikeShopEngineRoutes, bikeShopInvoicingRoutes, bikeShopOperations } from './operations.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -76,7 +76,9 @@ export function mountApi(
|
|
|
76
76
|
// `shop/complete-repair` wraps) is still callable here, on BOTH hosts.
|
|
77
77
|
app.post('/api/invoke', async (c) => {
|
|
78
78
|
const { op, input } = await c.req.json<{ op: string; input?: unknown }>();
|
|
79
|
-
|
|
79
|
+
// Through the platform's one door (#2073), as the derived routes are: a caller cannot ask
|
|
80
|
+
// for a page's row cursors, and none ever leave in a response.
|
|
81
|
+
return externalJson(c, (await (await resolveStub(c)).invoke(op, externalInput(input))) ?? null);
|
|
80
82
|
});
|
|
81
83
|
|
|
82
84
|
// Returns what it mounted, in registration order — a test pins the complete
|
package/template/src/seed.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { ulid } from '@substrat-run/kernel';
|
|
13
13
|
import { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
|
|
14
14
|
import { defaultAttachmentExtractors } from '@substrat-run/attachment-extractors';
|
|
15
|
-
import { ENTITLEMENT_KEYS, MODULES, OWNER_ROLE_KEY, portalPerms, ROLES } from './provision.js';
|
|
15
|
+
import { ENTITLEMENT_KEYS, ENTITY_GRANTS, MODULES, OWNER_ROLE_KEY, portalPerms, ROLES } from './provision.js';
|
|
16
16
|
|
|
17
17
|
// The provisioning surface (modules, roles, grant shapes) lives in
|
|
18
18
|
// provision.ts — node-free so the worker bundles it and `substrat push` reads
|
|
@@ -86,13 +86,14 @@ async function provisionShop(
|
|
|
86
86
|
* Idempotent seed. Everything that mutates the control plane runs only on a
|
|
87
87
|
* FRESH data dir (guarded by cast.json); on restart the tenants, roles, grants
|
|
88
88
|
* and entities are already in the SQLite files, so we just rebuild the handle
|
|
89
|
-
* object
|
|
89
|
+
* object — and re-apply the portal grants, which are idempotent. Safe to call
|
|
90
|
+
* on every server start and on every test.
|
|
90
91
|
*/
|
|
91
92
|
export async function seedBikeShop(host: SqliteScopeHost, dir: string): Promise<BikeShopWorld> {
|
|
92
93
|
const castPath = join(dir, 'cast.json');
|
|
93
94
|
if (existsSync(castPath)) {
|
|
94
95
|
const raw = JSON.parse(readFileSync(castPath, 'utf8')) as Record<string, string>;
|
|
95
|
-
|
|
96
|
+
const world: BikeShopWorld = {
|
|
96
97
|
t1: tenantId.parse(raw.t1),
|
|
97
98
|
s1: scopeId.parse(raw.s1),
|
|
98
99
|
t2: tenantId.parse(raw.t2),
|
|
@@ -107,6 +108,8 @@ export async function seedBikeShop(host: SqliteScopeHost, dir: string): Promise<
|
|
|
107
108
|
crescentId: raw.crescentId!,
|
|
108
109
|
bianchiId: raw.bianchiId!,
|
|
109
110
|
};
|
|
111
|
+
await grantPortals(host, world);
|
|
112
|
+
return world;
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
const staff = platformActorId.parse(ulid());
|
|
@@ -202,26 +205,38 @@ export async function seedBikeShop(host: SqliteScopeHost, dir: string): Promise<
|
|
|
202
205
|
world.crescentId = crescent.id;
|
|
203
206
|
world.bianchiId = bianchi.id;
|
|
204
207
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
+
await grantPortals(host, world);
|
|
209
|
+
|
|
210
|
+
writeFileSync(castPath, JSON.stringify(world, null, 2));
|
|
211
|
+
return world;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Portal grants: the `customer` shape (ENTITY_GRANTS) on each customer's own
|
|
216
|
+
* record. Lisbeth and Otto each hold workorder:read on their OWN customer
|
|
217
|
+
* record only — the walk workorder → bike → customer does the rest.
|
|
218
|
+
*
|
|
219
|
+
* Given as the SHAPE, so a key you add to `portalPerms` later reaches them,
|
|
220
|
+
* and then topped up to the shape as it is now, which is what the platform's
|
|
221
|
+
* reconcile does for a deployed install. Both are idempotent, so this runs on
|
|
222
|
+
* every boot, and a world seeded before you added a key receives it.
|
|
223
|
+
*/
|
|
224
|
+
async function grantPortals(host: SqliteScopeHost, world: BikeShopWorld): Promise<void> {
|
|
225
|
+
const staff = platformActorId.parse(ulid());
|
|
226
|
+
const node = { tenantId: world.t1, scopeId: world.s1 };
|
|
208
227
|
for (const [principal, customerId] of [
|
|
209
228
|
[world.lisbeth, world.lisbethId],
|
|
210
229
|
[world.otto, world.ottoId],
|
|
211
230
|
] as const) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
});
|
|
220
|
-
}
|
|
231
|
+
await host.admin.grantEntityShape(staff, {
|
|
232
|
+
principalId: principal,
|
|
233
|
+
node,
|
|
234
|
+
entity: { entityType: 'customer', entityId: customerId },
|
|
235
|
+
permissions: portalPerms,
|
|
236
|
+
grantedBy: world.greta,
|
|
237
|
+
});
|
|
221
238
|
}
|
|
222
|
-
|
|
223
|
-
writeFileSync(castPath, JSON.stringify(world, null, 2));
|
|
224
|
-
return world;
|
|
239
|
+
await host.admin.reconcileEntityGrantShapes(staff, node, ENTITY_GRANTS);
|
|
225
240
|
}
|
|
226
241
|
|
|
227
242
|
/**
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic `/api/invoke` route goes through the platform's one door (#2073).
|
|
3
|
+
*
|
|
4
|
+
* A page's `rowCursors` are each row's own cursor: an in-process answer for a walk that stops
|
|
5
|
+
* partway through a page. A handler that filtered its entries after the read would leave them
|
|
6
|
+
* naming the rows it dropped, so they never leave in a response — and a caller cannot ask for
|
|
7
|
+
* them. This route is hand-written, not derived, so it is held to that here.
|
|
8
|
+
*/
|
|
9
|
+
import { Hono } from 'hono';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import type { ScopeStub } from '@substrat-run/kernel';
|
|
12
|
+
import { mountApi } from '../src/routes.js';
|
|
13
|
+
|
|
14
|
+
function harness(result: unknown) {
|
|
15
|
+
const got: { input?: unknown } = {};
|
|
16
|
+
const app = new Hono();
|
|
17
|
+
mountApi(app, async () => ({ invoke: async (_op: string, input: unknown) => ((got.input = input), result) }) as unknown as ScopeStub);
|
|
18
|
+
const call = (input: unknown) =>
|
|
19
|
+
app.request('/api/invoke', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ op: 'shop/anything', input }),
|
|
23
|
+
});
|
|
24
|
+
return { call, got };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('/api/invoke — the one hand-written route (#2073)', () => {
|
|
28
|
+
it("never answers with a page's rowCursors, at the top or nested", async () => {
|
|
29
|
+
const page = { entries: [{ id: 'a' }], nextCursor: 'a', rowCursors: ['a', 'HIDDEN'] };
|
|
30
|
+
for (const result of [page, { nested: page, again: page }]) {
|
|
31
|
+
const text = await (await harness(result).call({})).text();
|
|
32
|
+
expect(text).toContain('"a"');
|
|
33
|
+
expect(text).not.toContain('HIDDEN');
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps a rowCursors field on something that is not a page', async () => {
|
|
38
|
+
const res = await harness({ id: 'a', rowCursors: ['kept'] }).call({});
|
|
39
|
+
expect(await res.json()).toEqual({ id: 'a', rowCursors: ['kept'] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('a caller cannot ask for rowCursors: the operation never sees the flag', async () => {
|
|
43
|
+
const { call, got } = harness(null);
|
|
44
|
+
await call({ q: 'x', rowCursors: true });
|
|
45
|
+
expect(got.input).toEqual({ q: 'x' });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
5
|
+
import { platformActorId, principalId, type Page } from '@substrat-run/contracts';
|
|
6
|
+
import { ulid } from '@substrat-run/kernel';
|
|
7
|
+
import type { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
|
|
8
|
+
import { INVOICING_PERM as INV } from '@substrat-run/engine-invoicing';
|
|
9
|
+
import { buildBikeShopHost, ENTITY_GRANTS, seedBikeShop, type BikeShopWorld } from '../src/seed.js';
|
|
10
|
+
import { portalPerms } from '../src/provision.js';
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// The portal grant is a declared SHAPE (`ENTITY_GRANTS`), so a key you add to
|
|
14
|
+
// `portalPerms` reaches the customers who already hold it — including ones a
|
|
15
|
+
// release before the shape grant gave it key by key.
|
|
16
|
+
//
|
|
17
|
+
// The second shop (t2) stands for one an older release left behind: never
|
|
18
|
+
// reconciled, one repair, and Kerstin given one key of an older shape on her
|
|
19
|
+
// customer's record, with no holder marker. The reconcile carries the shape
|
|
20
|
+
// grown by the key she lacks (`workorder:read`, the key `shop/portal-repairs`
|
|
21
|
+
// walks). Before it she sees nothing; after it she sees her repair.
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
describe('a key the portal shape gains reaches a customer granted before markers', () => {
|
|
25
|
+
let dir: string;
|
|
26
|
+
let host: SqliteScopeHost;
|
|
27
|
+
let w: BikeShopWorld;
|
|
28
|
+
let repairId: string;
|
|
29
|
+
const staff = platformActorId.parse(ulid());
|
|
30
|
+
const kerstin = principalId.parse(ulid());
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
dir = mkdtempSync(join(tmpdir(), 'bike-shop-portal-shape-'));
|
|
34
|
+
host = buildBikeShopHost(dir);
|
|
35
|
+
w = await seedBikeShop(host, dir);
|
|
36
|
+
const shop = await host.getScope(w.rutger, w.t2, w.s2);
|
|
37
|
+
const customer = await shop.invoke<{ id: string }>('shop/create-customer', { number: '1', name: 'Kerstin' });
|
|
38
|
+
const bike = await shop.invoke<{ id: string }>('shop/register-bike', { customerId: customer.id, label: 'Crescent' });
|
|
39
|
+
repairId = (await shop.invoke<{ id: string }>('shop/create-repair', { bikeId: bike.id, kind: 'punktering', title: 'Punktering' })).id;
|
|
40
|
+
await host.admin.grant(staff, {
|
|
41
|
+
principalId: kerstin,
|
|
42
|
+
permission: INV.read,
|
|
43
|
+
node: { tenantId: w.t2, scopeId: w.s2 },
|
|
44
|
+
entity: { entityType: 'customer', entityId: customer.id },
|
|
45
|
+
grantedBy: w.rutger,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
await host.close();
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const sees = async () =>
|
|
55
|
+
(await (await host.getScope(kerstin, w.t2, w.s2)).invoke<Page<{ id: string }>>('shop/portal-repairs')).entries.map((o) => o.id);
|
|
56
|
+
|
|
57
|
+
it('the seeded customers each receive one audited shape grant', async () => {
|
|
58
|
+
const grants = await host.admin.auditLog(staff, { tenantId: w.t1, scopeId: w.s1, action: 'grantEntityShape' });
|
|
59
|
+
expect(grants.map((entry) => (entry.after as { principalId: string }).principalId).sort()).toEqual([w.lisbeth, w.otto].sort());
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('the shape is declared bootstrap, and its holder is whoever holds a key of it', () => {
|
|
63
|
+
expect(ENTITY_GRANTS).toEqual([{ entityType: 'customer', permissions: portalPerms, bootstrap: true, holder: 'grantee' }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('before the reconcile she sees nothing', async () => {
|
|
67
|
+
expect(await sees()).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('after it, she sees her repair', async () => {
|
|
71
|
+
const grown = ENTITY_GRANTS.map((g) => ({ ...g, permissions: [INV.read, ...g.permissions] }));
|
|
72
|
+
await host.admin.reconcileEntityGrantShapes(staff, { tenantId: w.t2, scopeId: w.s2 }, grown);
|
|
73
|
+
expect(await sees()).toEqual([repairId]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -1,32 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The config behind `pnpm test:ui` — your own `vitest.config.ts`, plus the live test
|
|
3
|
-
* dashboard
|
|
4
|
-
* the `tests` entry).
|
|
3
|
+
* dashboard on a fixed port (`.claude/launch.json`, the `tests` entry).
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Open it at the URL vitest prints when it starts (`UI started at
|
|
6
|
+
* http://127.0.0.1:5290/__vitest__/?token=…`). The dashboard refuses any request without that
|
|
7
|
+
* token, because its API can re-run your tests, write files and run code; the token is what
|
|
8
|
+
* keeps another page in your browser from driving it. So nothing here redirects the bare
|
|
9
|
+
* origin to it. `VITEST_UI_PORT` moves the port; it binds 127.0.0.1 only. The port is strict:
|
|
10
|
+
* a busy one fails the start rather than moving the dashboard somewhere the launch entry does
|
|
11
|
+
* not point.
|
|
11
12
|
*/
|
|
12
|
-
import { mergeConfig
|
|
13
|
+
import { mergeConfig } from 'vitest/config';
|
|
13
14
|
|
|
14
15
|
import base from './vitest.config.ts';
|
|
15
16
|
|
|
16
|
-
const redirectRootToUi: Plugin = {
|
|
17
|
-
name: 'substrat:vitest-ui-root',
|
|
18
|
-
configureServer(server) {
|
|
19
|
-
server.middlewares.use((req, res, next) => {
|
|
20
|
-
if (req.url !== '/') return next();
|
|
21
|
-
res.statusCode = 302;
|
|
22
|
-
res.setHeader('Location', '/__vitest__/');
|
|
23
|
-
res.end();
|
|
24
|
-
});
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
|
|
28
17
|
export default mergeConfig(base, {
|
|
29
|
-
plugins: [redirectRootToUi],
|
|
30
18
|
test: {
|
|
31
19
|
api: {
|
|
32
20
|
host: '127.0.0.1',
|