@substrat-run/contract-tests 0.5.0 → 0.7.0

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
- import { moduleManifest, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
2
+ import { moduleManifest, orgId, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
3
3
  import { ulid } from '@substrat-run/kernel';
4
4
  import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
5
5
  /**
@@ -35,12 +35,19 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
35
35
  for (const reg of contractTestInitialModules) {
36
36
  host.registerModule(reg);
37
37
  }
38
+ // The connector seam's out-of-band half (K-22 §4.2). Host code, not module
39
+ // code: it holds platform authority, which is exactly what a module must
40
+ // never have. Idempotent because delivery is at-least-once.
41
+ host.registerExecutor('member-adder', 'member.add-requested', async (admin, event) => {
42
+ const p = event.payload;
43
+ await admin.addMember(staff, p.tenantId, p.principal, p.orgId);
44
+ });
38
45
  // A scope requires an existing active tenant (§4.1) — create then provision.
39
46
  await host.admin.createTenant(staff, { id: t1, slug: 'tenant-one', name: 'Tenant One' });
40
47
  await host.admin.createTenant(staff, { id: t2, slug: 'tenant-two', name: 'Tenant Two' });
41
48
  // Entitlements are default-deny (§4.3): t1 invokes these modules' operations,
42
49
  // so it must hold their SKU flags. (t2 only exercises bare, ungated ops.)
43
- for (const key of ['testmod', 'flow', 'guarded', 'victim', 'late']) {
50
+ for (const key of ['testmod', 'flow', 'guarded', 'victim', 'late', 'connector']) {
44
51
  await host.admin.grantEntitlement(staff, t1, key);
45
52
  }
46
53
  await host.provisionScope(staff, { tenantId: t1, scopeId: s1, jurisdiction: 'eu' });
@@ -133,6 +140,317 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
133
140
  const journal = await stub.invoke('testmod/read-journal');
134
141
  expect(journal).toContainEqual({ module_id: '@test/late', version: '0001-init' });
135
142
  });
143
+ // -- the connector seam (K-22 §4.2) --------------------------------------
144
+ // A module asks, inside its transaction, for an effect it cannot perform:
145
+ // membership is tenant-wide and lives in the directory, outside this scope's
146
+ // serialization domain. A privileged executor effects it through HostAdmin.
147
+ it('effects a module\'s request through an executor, and correlates the trail', async () => {
148
+ const org = orgId.parse(ulid());
149
+ const joiner = principalId.parse(ulid());
150
+ await host.admin.createOrg(staff, { id: org, tenantId: t1, slug: 'seam', name: 'Seam' });
151
+ const stub = await host.getScope(alice, t1, s1);
152
+ await stub.invoke('connector/request-member', { principal: joiner, orgId: org });
153
+ // Prompt dispatch: effected by the time the request returns, not on a timer.
154
+ const members = await host.admin.listMembers(staff, t1, org);
155
+ expect(members.map((m) => m.principal)).toEqual([joiner]);
156
+ // The two halves join. The executor's admin row carries the id of the event
157
+ // that caused it, which is what stops the split trail being unreadable —
158
+ // control-plane.md §3 named that as the main cost of this pattern.
159
+ const added = (await host.admin.auditLog(staff, { tenantId: t1 })).find((e) => e.action === 'addMember' && JSON.stringify(e.after).includes(joiner));
160
+ expect(added?.causedBy).toEqual(expect.any(String));
161
+ // A staff member acting directly caused nothing but themselves.
162
+ const orgRow = (await host.admin.auditLog(staff, { tenantId: t1 })).find((e) => e.action === 'createOrg');
163
+ expect(orgRow?.causedBy).toBeNull();
164
+ });
165
+ it('effects nothing when the emitting transaction rolls back', async () => {
166
+ // The property the connector is chosen FOR. `ctx.emit` commits with the
167
+ // domain write, so a rollback leaves no event and nothing to effect. An
168
+ // in-scope cross-DO write could not offer this: it could land in the
169
+ // directory and then be orphaned by the scope's rollback.
170
+ const org = orgId.parse(ulid());
171
+ const ghost = principalId.parse(ulid());
172
+ await host.admin.createOrg(staff, { id: org, tenantId: t1, slug: 'ghost', name: 'Ghost' });
173
+ const stub = await host.getScope(alice, t1, s1);
174
+ await expect(stub.invoke('connector/request-and-throw', { principal: ghost, orgId: org })).rejects.toThrow(/deliberate failure/);
175
+ expect(await host.admin.listMembers(staff, t1, org)).toEqual([]);
176
+ });
177
+ it('delivers each event to an executor exactly once', async () => {
178
+ // At-least-once with a journal, so a second dispatch pass must not re-effect.
179
+ // Membership is idempotent anyway; the audit trail is what would show a
180
+ // double-run, and it must not.
181
+ const org = orgId.parse(ulid());
182
+ const once = principalId.parse(ulid());
183
+ await host.admin.createOrg(staff, { id: org, tenantId: t1, slug: 'once', name: 'Once' });
184
+ const stub = await host.getScope(alice, t1, s1);
185
+ await stub.invoke('connector/request-member', { principal: once, orgId: org });
186
+ // Another operation on the same scope drains the outbox again.
187
+ await stub.invoke('connector/requests');
188
+ const rows = (await host.admin.auditLog(staff, { tenantId: t1 })).filter((e) => e.action === 'addMember' && JSON.stringify(e.after).includes(once));
189
+ expect(rows).toHaveLength(1);
190
+ });
191
+ // -- vertical + version registry (#31) -----------------------------------
192
+ // A scope binds to a VERSION, so dev/staging/prod are the same vertical pinned
193
+ // differently. The invariant that earns the registry its keep is that a push
194
+ // is not a deploy.
195
+ it('publishes a version as pending, and refuses to bind it until admitted', async () => {
196
+ const versionId = ulid();
197
+ await host.admin.registerVertical(staff, {
198
+ slug: 'callout',
199
+ name: 'Callout',
200
+ source: 'builtin',
201
+ });
202
+ await host.admin.publishVersion(staff, {
203
+ id: versionId,
204
+ verticalSlug: 'callout',
205
+ version: '1.0.0',
206
+ manifestDigest: 'm1',
207
+ permissionDigest: 'p1',
208
+ migrationDigest: 'g1',
209
+ deploymentRef: null,
210
+ });
211
+ const [published] = await host.admin.listVersions(staff, 'callout');
212
+ expect(published?.admission).toBe('pending');
213
+ // The refusal the registry exists for. Without it "a push lands pending" is
214
+ // a convention, and D-30's lockstep-upgrade argument is that conventions are
215
+ // what we cannot afford here.
216
+ await expect(host.admin.bindScopeVersion(staff, t1, s1, versionId)).rejects.toThrow(/pending, not admitted/);
217
+ await host.admin.admitVersion(staff, versionId);
218
+ await host.admin.bindScopeVersion(staff, t1, s1, versionId);
219
+ const scope = await host.admin.getScopeRecord(staff, t1, s1);
220
+ expect(scope?.verticalVersionId).toBe(versionId);
221
+ // The slug is denormalized alongside, so the console and the audit target
222
+ // keep reading a label that survives the version being superseded.
223
+ expect(scope?.vertical).toBe('callout');
224
+ });
225
+ it('refuses to bind a rejected version, and rejection is terminal', async () => {
226
+ const versionId = ulid();
227
+ await host.admin.publishVersion(staff, {
228
+ id: versionId,
229
+ verticalSlug: 'callout',
230
+ version: '1.1.0-bad',
231
+ manifestDigest: 'm2',
232
+ permissionDigest: 'p2',
233
+ migrationDigest: 'g2',
234
+ deploymentRef: null,
235
+ });
236
+ await host.admin.rejectVersion(staff, versionId, 'permission diff widened a role');
237
+ await expect(host.admin.bindScopeVersion(staff, t1, s1, versionId)).rejects.toThrow(/rejected, not admitted/);
238
+ // Terminal: a rejected version is not resurrected, a new one is published.
239
+ await expect(host.admin.admitVersion(staff, versionId)).rejects.toThrow(/was rejected/);
240
+ const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
241
+ expect(rejected?.admissionNote).toContain('widened a role');
242
+ });
243
+ it('carries the digests promotion compares', async () => {
244
+ // "Has the permission surface changed between what is in prod and what I am
245
+ // promoting?" is a string comparison here. Today it is a person remembering
246
+ // to look, and a checkpoint that can be skipped is not a checkpoint.
247
+ const versions = await host.admin.listVersions(staff, 'callout');
248
+ for (const v of versions) {
249
+ expect(v.manifestDigest).toEqual(expect.any(String));
250
+ expect(v.permissionDigest).toEqual(expect.any(String));
251
+ expect(v.migrationDigest).toEqual(expect.any(String));
252
+ }
253
+ });
254
+ it('refuses a version for a vertical nobody registered', async () => {
255
+ await expect(host.admin.publishVersion(staff, {
256
+ id: ulid(),
257
+ verticalSlug: 'ghost',
258
+ version: '1.0.0',
259
+ manifestDigest: 'm',
260
+ permissionDigest: 'p',
261
+ migrationDigest: 'g',
262
+ deploymentRef: null,
263
+ })).rejects.toThrow(/unknown vertical/);
264
+ });
265
+ // -- channels and promotion-time checkpoints (#31 step 2) ----------------
266
+ // Promotion is the moment a change reaches anyone, so it is where §4's two
267
+ // human checkpoints belong. Today they are a merge-time convention: CI renders
268
+ // the diffs and a human is expected to look, but nothing ties that looking to
269
+ // the moment of exposure.
270
+ const publish = async (version, digests) => {
271
+ const id = ulid();
272
+ await host.admin.publishVersion(staff, {
273
+ id,
274
+ verticalSlug: 'callout',
275
+ version,
276
+ manifestDigest: `man-${version}`,
277
+ permissionDigest: digests.perm,
278
+ migrationDigest: digests.mig,
279
+ deploymentRef: null,
280
+ });
281
+ await host.admin.admitVersion(staff, id);
282
+ return id;
283
+ };
284
+ it('promotes a first version with nothing to acknowledge', async () => {
285
+ // The gate is about CHANGE, not existence. A first promotion has no
286
+ // predecessor to diff against, so demanding an acknowledgement would be
287
+ // ceremony rather than review.
288
+ const v1 = await publish('2.0.0', { perm: 'pA', mig: 'gA' });
289
+ await host.admin.promoteVersion(staff, 'callout', 'prod', v1);
290
+ const channels = await host.admin.listChannels(staff, 'callout');
291
+ expect(channels.find((c) => c.channel === 'prod')?.versionId).toBe(v1);
292
+ });
293
+ it('refuses a promotion that changes permissions until it is acknowledged', async () => {
294
+ const v2 = await publish('2.1.0', { perm: 'pB', mig: 'gA' }); // permissions moved
295
+ await expect(host.admin.promoteVersion(staff, 'callout', 'prod', v2)).rejects.toThrow(/changes the permission surface/);
296
+ // Still pointing at the old one — a refused promotion changes nothing.
297
+ const before = await host.admin.listChannels(staff, 'callout');
298
+ expect(before.find((c) => c.channel === 'prod')?.versionId).not.toBe(v2);
299
+ await host.admin.promoteVersion(staff, 'callout', 'prod', v2, { permissionChange: true });
300
+ const after = await host.admin.listChannels(staff, 'callout');
301
+ expect(after.find((c) => c.channel === 'prod')?.versionId).toBe(v2);
302
+ });
303
+ it('refuses a promotion that changes migrations until it is acknowledged', async () => {
304
+ const v3 = await publish('2.2.0', { perm: 'pB', mig: 'gB' }); // migrations moved
305
+ await expect(host.admin.promoteVersion(staff, 'callout', 'prod', v3)).rejects.toThrow(/changes migrations/);
306
+ await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true });
307
+ expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')
308
+ ?.versionId).toBe(v3);
309
+ });
310
+ it('records the acknowledgement, so review is evidence rather than a claim', async () => {
311
+ const promotions = (await host.admin.auditLog(staff, {})).filter((e) => e.action === 'promoteVersion');
312
+ const acknowledged = promotions.filter((e) => JSON.stringify(e.after).includes('"permissionChange":true'));
313
+ // D-32's compliance product has to produce evidence that a control operated.
314
+ // "Someone reviewed the permission change" is exactly such a control, and
315
+ // this row is what makes it checkable after the fact.
316
+ expect(acknowledged.length).toBeGreaterThan(0);
317
+ expect(acknowledged[0].actor).toBe(staff);
318
+ });
319
+ it('promotes per channel — dev moving does not move prod', async () => {
320
+ // The whole point of channels: the same vertical at different versions.
321
+ const preview = await publish('2.3.0-preview', { perm: 'pC', mig: 'gC' });
322
+ const prodBefore = (await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')?.versionId;
323
+ await host.admin.promoteVersion(staff, 'callout', 'dev', preview);
324
+ const channels = await host.admin.listChannels(staff, 'callout');
325
+ expect(channels.find((c) => c.channel === 'dev')?.versionId).toBe(preview);
326
+ expect(channels.find((c) => c.channel === 'prod')?.versionId).toBe(prodBefore);
327
+ });
328
+ it('refuses to promote a version that was never admitted', async () => {
329
+ const pending = ulid();
330
+ await host.admin.publishVersion(staff, {
331
+ id: pending,
332
+ verticalSlug: 'callout',
333
+ version: '9.9.9',
334
+ manifestDigest: 'm',
335
+ permissionDigest: 'p',
336
+ migrationDigest: 'g',
337
+ deploymentRef: null,
338
+ });
339
+ await expect(host.admin.promoteVersion(staff, 'callout', 'dev', pending)).rejects.toThrow(/pending, not admitted/);
340
+ });
341
+ // -- the hostname map (K-26) ---------------------------------------------
342
+ // §4.2 provisions a scope; this is what finally gives it a URL. The router
343
+ // resolves against these rows before dispatching to a vertical's worker.
344
+ it('binds a hostname as pending, and resolves nothing until it is active', async () => {
345
+ await host.admin.bindHostname(staff, {
346
+ hostname: 'acme.example.com',
347
+ tenantId: t1,
348
+ scopeId: s1,
349
+ surface: 'app',
350
+ region: null,
351
+ canonical: true,
352
+ });
353
+ // A custom domain is DNS validation and cert issuance, not a string somebody
354
+ // sets — so it does not serve until those finish.
355
+ expect(await host.admin.resolveHostname('acme.example.com')).toBeUndefined();
356
+ await host.admin.setHostnameStatus(staff, 'acme.example.com', 'verifying');
357
+ expect(await host.admin.resolveHostname('acme.example.com')).toBeUndefined();
358
+ await host.admin.setHostnameStatus(staff, 'acme.example.com', 'active');
359
+ expect(await host.admin.resolveHostname('acme.example.com')).toMatchObject({
360
+ tenantId: t1,
361
+ scopeId: s1,
362
+ surface: 'app',
363
+ });
364
+ });
365
+ it('routes two surfaces of ONE scope to different hostnames', async () => {
366
+ // The reason §5.5's one-hostname-per-scope was not enough: the shop fronts a
367
+ // storefront and a back office from the same data, and RallyPoint a player
368
+ // app and a manager console.
369
+ for (const [hostname, surface] of [
370
+ ['shop.example.com', 'storefront'],
371
+ ['admin.shop.example.com', 'back-office'],
372
+ ]) {
373
+ await host.admin.bindHostname(staff, {
374
+ hostname: hostname,
375
+ tenantId: t1,
376
+ scopeId: s1,
377
+ surface: surface,
378
+ region: null,
379
+ canonical: true,
380
+ });
381
+ await host.admin.setHostnameStatus(staff, hostname, 'active');
382
+ }
383
+ expect((await host.admin.resolveHostname('shop.example.com'))?.surface).toBe('storefront');
384
+ expect((await host.admin.resolveHostname('admin.shop.example.com'))?.surface).toBe('back-office');
385
+ });
386
+ it('keeps exactly one canonical hostname per surface', async () => {
387
+ // "Which one do certs and redirects use" has to have one answer, so a new
388
+ // canonical demotes the old rather than producing two.
389
+ await host.admin.bindHostname(staff, {
390
+ hostname: 'alias.example.com',
391
+ tenantId: t1,
392
+ scopeId: s1,
393
+ surface: 'app',
394
+ region: null,
395
+ canonical: true,
396
+ });
397
+ const forApp = (await host.admin.listHostnames(staff, { scopeId: s1 })).filter((h) => h.surface === 'app');
398
+ expect(forApp.filter((h) => h.canonical).map((h) => h.hostname)).toEqual([
399
+ 'alias.example.com',
400
+ ]);
401
+ // The demoted one is still bound — an alias, not a deletion.
402
+ expect(forApp.map((h) => h.hostname)).toContain('acme.example.com');
403
+ });
404
+ it('treats hostname case as insignificant, because DNS does', async () => {
405
+ // Otherwise two scopes could each hold "the same" name and a request would
406
+ // resolve to whichever casing it arrived in.
407
+ await host.admin.bindHostname(staff, {
408
+ hostname: 'MiXeD.Example.COM',
409
+ tenantId: t1,
410
+ scopeId: s1,
411
+ surface: 'app',
412
+ region: null,
413
+ canonical: false,
414
+ });
415
+ await host.admin.setHostnameStatus(staff, 'mixed.EXAMPLE.com', 'active');
416
+ expect((await host.admin.resolveHostname('MIXED.example.com'))?.scopeId).toBe(s1);
417
+ expect((await host.admin.listHostnames(staff, { scopeId: s1 })).map((h) => h.hostname)).toContain('mixed.example.com');
418
+ });
419
+ it('refuses to move a hostname to another scope', async () => {
420
+ // A hostname routes to exactly one place. Silently rebinding would move
421
+ // another tenant's traffic.
422
+ await expect(host.admin.bindHostname(staff, {
423
+ hostname: 'acme.example.com',
424
+ tenantId: t2,
425
+ scopeId: s2,
426
+ surface: 'app',
427
+ region: null,
428
+ canonical: false,
429
+ })).rejects.toThrow(/already bound to another scope/);
430
+ });
431
+ it('carries the region, which is what Regional Services is set from', async () => {
432
+ // Residency is per hostname, which is why it lives here rather than in a
433
+ // router deployed per jurisdiction (K-26).
434
+ await host.admin.bindHostname(staff, {
435
+ hostname: 'eu.example.com',
436
+ tenantId: t1,
437
+ scopeId: s1,
438
+ surface: 'app',
439
+ region: 'eu',
440
+ canonical: false,
441
+ });
442
+ await host.admin.setHostnameStatus(staff, 'eu.example.com', 'active');
443
+ expect((await host.admin.resolveHostname('eu.example.com'))?.region).toBe('eu');
444
+ });
445
+ it('records a failed hostname with the reason, rather than losing it', async () => {
446
+ await host.admin.setHostnameStatus(staff, 'eu.example.com', 'failed', 'DNS validation timed out');
447
+ const row = (await host.admin.listHostnames(staff, { scopeId: s1 })).find((h) => h.hostname === 'eu.example.com');
448
+ expect(row?.status).toBe('failed');
449
+ expect(row?.statusNote).toContain('DNS validation');
450
+ // And it stops serving — "broken" and "not yet working" are different states,
451
+ // but neither of them routes traffic.
452
+ expect(await host.admin.resolveHostname('eu.example.com')).toBeUndefined();
453
+ });
136
454
  it('rejects duplicate module registration', () => {
137
455
  expect(() => host.registerModule({ manifest: testModManifest })).toThrow(/already registered/);
138
456
  });
@@ -228,14 +546,14 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
228
546
  it('creates a tenant record, idempotently; only real creates are audited', async () => {
229
547
  await host.admin.createTenant(staff, { id: t3, slug: 'acme-co', name: 'Acme Co' });
230
548
  await host.admin.createTenant(staff, { id: t3, slug: 'acme-co', name: 'Acme Co' }); // no-op
231
- expect(await host.admin.getTenant(t3)).toMatchObject({
549
+ expect(await host.admin.getTenant(staff, t3)).toMatchObject({
232
550
  id: t3,
233
551
  slug: 'acme-co',
234
552
  name: 'Acme Co',
235
553
  status: 'active',
236
554
  });
237
- expect((await host.admin.listTenants()).filter((x) => x.id === t3)).toHaveLength(1);
238
- const creates = (await host.admin.auditLog({ tenantId: t3 })).filter((r) => r.action === 'createTenant');
555
+ expect((await host.admin.listTenants(staff)).filter((x) => x.id === t3)).toHaveLength(1);
556
+ const creates = (await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'createTenant');
239
557
  expect(creates).toHaveLength(1); // the idempotent no-op left no row
240
558
  expect(creates[0].actor).toBe(staff);
241
559
  });
@@ -248,7 +566,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
248
566
  await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
249
567
  });
250
568
  it('records setTenantStatus with before/after status', async () => {
251
- const transitions = (await host.admin.auditLog({ tenantId: t3 })).filter((r) => r.action === 'setTenantStatus');
569
+ const transitions = (await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'setTenantStatus');
252
570
  expect(transitions.length).toBeGreaterThanOrEqual(2);
253
571
  const suspend = transitions.find((r) => r.after.status === 'suspended');
254
572
  expect(suspend.before.status).toBe('active');
@@ -274,7 +592,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
274
592
  await expect(host.getScope(alice, t3, s3)).rejects.toThrow(/scope not active/);
275
593
  await host.admin.unarchiveScope(staff, t3, s3);
276
594
  await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
277
- const transitions = (await host.admin.auditLog({ tenantId: t3 })).filter((r) => r.action === 'archiveScope' || r.action === 'unarchiveScope');
595
+ const transitions = (await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'archiveScope' || r.action === 'unarchiveScope');
278
596
  expect(transitions.map((r) => r.action)).toEqual(expect.arrayContaining(['archiveScope', 'unarchiveScope']));
279
597
  for (const r of transitions) {
280
598
  expect(r.scopeId).toBe(s3);
@@ -299,22 +617,22 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
299
617
  // Granting the flag loads the module for this tenant.
300
618
  await host.admin.grantEntitlement(staff, t4, 'billed');
301
619
  await expect(stub.invoke('billed/act')).resolves.toBe('ran');
302
- expect(await host.admin.listEntitlements(t4)).toContain('billed');
620
+ expect(await host.admin.listEntitlements(staff, t4)).toContain('billed');
303
621
  // Revoking it takes the operation away again — as if never registered.
304
622
  await host.admin.revokeEntitlement(staff, t4, 'billed');
305
623
  await expect(stub.invoke('billed/act')).rejects.toThrow(/not entitled/);
306
- expect(await host.admin.listEntitlements(t4)).not.toContain('billed');
624
+ expect(await host.admin.listEntitlements(staff, t4)).not.toContain('billed');
307
625
  });
308
626
  it('audits grant/revoke idempotently and records the SKU flag', async () => {
309
627
  // Re-grant twice: only the first is a real change, so only one row.
310
628
  await host.admin.grantEntitlement(staff, t4, 'audited-sku');
311
629
  await host.admin.grantEntitlement(staff, t4, 'audited-sku');
312
- const grants = (await host.admin.auditLog({ tenantId: t4 })).filter((r) => r.action === 'grantEntitlement' &&
630
+ const grants = (await host.admin.auditLog(staff, { tenantId: t4 })).filter((r) => r.action === 'grantEntitlement' &&
313
631
  r.after.entitlementKey === 'audited-sku');
314
632
  expect(grants).toHaveLength(1);
315
633
  expect(grants[0].actor).toBe(staff);
316
634
  await host.admin.revokeEntitlement(staff, t4, 'audited-sku');
317
- const revokes = (await host.admin.auditLog({ tenantId: t4 })).filter((r) => r.action === 'revokeEntitlement');
635
+ const revokes = (await host.admin.auditLog(staff, { tenantId: t4 })).filter((r) => r.action === 'revokeEntitlement');
318
636
  expect(revokes.length).toBeGreaterThanOrEqual(1);
319
637
  expect(revokes[revokes.length - 1].before.entitlementKey).toBe('audited-sku');
320
638
  });
@@ -332,7 +650,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
332
650
  await host.admin.createTenant(staff, { id: t5, slug: 'directory-co', name: 'Directory Co' });
333
651
  const bare = scopeId.parse(ulid());
334
652
  await host.provisionScope(staff, { tenantId: t5, scopeId: bare });
335
- const rec = await host.admin.getScopeRecord(t5, bare);
653
+ const rec = await host.admin.getScopeRecord(staff, t5, bare);
336
654
  // A ULID lowercases into a valid slug, so the placeholder is unique by
337
655
  // construction — every pre-existing caller provisions without naming.
338
656
  expect(rec).toMatchObject({
@@ -358,7 +676,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
358
676
  vertical: 'housing',
359
677
  jurisdiction: 'eu',
360
678
  });
361
- expect(await host.admin.getScopeRecord(t5, named)).toMatchObject({
679
+ expect(await host.admin.getScopeRecord(staff, t5, named)).toMatchObject({
362
680
  slug: 'brf-vasastan',
363
681
  kind: 'brf',
364
682
  name: 'Brf Vasastan',
@@ -371,7 +689,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
371
689
  await expect(host.provisionScope(staff, { tenantId: t5, scopeId: other, slug: 'brf-vasastan' })).rejects.toThrow(/already taken/);
372
690
  // Idempotency is keyed on the scope id, so re-provisioning the SAME scope
373
691
  // must not collide with its own slug.
374
- const named = (await host.admin.listScopes({ tenantId: t5 })).find((s) => s.slug === 'brf-vasastan');
692
+ const named = (await host.admin.listScopes(staff, { tenantId: t5 })).find((s) => s.slug === 'brf-vasastan');
375
693
  await expect(host.provisionScope(staff, { tenantId: t5, scopeId: named.id, slug: 'brf-vasastan' })).resolves.toBeUndefined();
376
694
  });
377
695
  it('scopes slug uniqueness to the tenant, not the fleet (§3.2)', async () => {
@@ -390,108 +708,108 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
390
708
  })).rejects.toThrow(/already taken/);
391
709
  });
392
710
  it('enumerates the scopes under a tenant, and filters by status (§4.5)', async () => {
393
- const all = await host.admin.listScopes({ tenantId: t5 });
711
+ const all = await host.admin.listScopes(staff, { tenantId: t5 });
394
712
  expect(all.length).toBeGreaterThanOrEqual(2);
395
713
  expect(all.every((s) => s.tenantId === t5)).toBe(true);
396
714
  // Ordered by scope id — ULID order is chronological.
397
715
  expect(all.map((s) => s.id)).toEqual([...all.map((s) => s.id)].sort());
398
716
  const target = all[0];
399
717
  await host.admin.suspendScope(staff, t5, target.id);
400
- const suspended = await host.admin.listScopes({ tenantId: t5, status: 'suspended' });
718
+ const suspended = await host.admin.listScopes(staff, { tenantId: t5, status: 'suspended' });
401
719
  expect(suspended.map((s) => s.id)).toEqual([target.id]);
402
720
  // Several statuses at once — the console's All / Suspended / Archived tabs.
403
- const both = await host.admin.listScopes({
721
+ const both = await host.admin.listScopes(staff, {
404
722
  tenantId: t5,
405
723
  status: ['active', 'suspended'],
406
724
  });
407
725
  expect(both.length).toBe(all.length);
408
726
  // An empty status list means "no status is acceptable" — it must match
409
727
  // nothing, never degenerate into an unfiltered read of the whole fleet.
410
- expect(await host.admin.listScopes({ tenantId: t5, status: [] })).toEqual([]);
728
+ expect(await host.admin.listScopes(staff, { tenantId: t5, status: [] })).toEqual([]);
411
729
  await host.admin.unsuspendScope(staff, t5, target.id);
412
730
  });
413
731
  it('lists the whole fleet across tenants when unfiltered (§4.5)', async () => {
414
- const fleet = await host.admin.listScopes();
732
+ const fleet = await host.admin.listScopes(staff);
415
733
  const tenants = new Set(fleet.map((s) => s.tenantId));
416
734
  expect(tenants.size).toBeGreaterThan(1);
417
- expect(fleet.length).toBeGreaterThanOrEqual((await host.admin.listScopes({ tenantId: t5 })).length);
735
+ expect(fleet.length).toBeGreaterThanOrEqual((await host.admin.listScopes(staff, { tenantId: t5 })).length);
418
736
  });
419
737
  it('filters the fleet by vertical (§4.5)', async () => {
420
- const housing = await host.admin.listScopes({ vertical: 'housing' });
738
+ const housing = await host.admin.listScopes(staff, { vertical: 'housing' });
421
739
  expect(housing.length).toBeGreaterThanOrEqual(1);
422
740
  expect(housing.every((s) => s.vertical === 'housing')).toBe(true);
423
741
  });
424
742
  it('fails closed reading a scope record on a mismatched pair (K-3)', async () => {
425
- const [any] = await host.admin.listScopes({ tenantId: t5 });
743
+ const [any] = await host.admin.listScopes(staff, { tenantId: t5 });
426
744
  // The scope exists — but not under t1. It must read as absent, never as
427
745
  // itself: the same rule getScope applies when minting a stub.
428
- expect(await host.admin.getScopeRecord(t1, any.id)).toBeUndefined();
429
- expect(await host.admin.getScopeRecord(t5, scopeId.parse(ulid()))).toBeUndefined();
746
+ expect(await host.admin.getScopeRecord(staff, t1, any.id)).toBeUndefined();
747
+ expect(await host.admin.getScopeRecord(staff, t5, scopeId.parse(ulid()))).toBeUndefined();
430
748
  });
431
749
  it('projects the applied-migration count into the directory (§5.4)', async () => {
432
750
  // schema_version shipped as a column and was written by nothing — always
433
751
  // '0'. Registered modules carry migrations, so a provisioned scope must
434
752
  // report a count, which is what makes "which scopes are behind" answerable
435
753
  // from the index without fanning out.
436
- const [any] = await host.admin.listScopes({ tenantId: t5 });
754
+ const [any] = await host.admin.listScopes(staff, { tenantId: t5 });
437
755
  expect(Number(any.schemaVersion)).toBeGreaterThan(0);
438
756
  });
439
757
  it('stamps the audit target with the scope vertical for lifecycle actions (§4.4)', async () => {
440
758
  // `vertical` was plumbed end-to-end and passed by no call site — every row
441
759
  // was null. A lifecycle action on a scope that names one must carry it.
442
- const named = (await host.admin.listScopes({ tenantId: t5 })).find((s) => s.vertical === 'housing');
760
+ const named = (await host.admin.listScopes(staff, { tenantId: t5 })).find((s) => s.vertical === 'housing');
443
761
  await host.admin.suspendScope(staff, t5, named.id);
444
- const rows = (await host.admin.auditLog({ tenantId: t5, scopeId: named.id })).filter((r) => r.action === 'suspendScope');
762
+ const rows = (await host.admin.auditLog(staff, { tenantId: t5, scopeId: named.id })).filter((r) => r.action === 'suspendScope');
445
763
  expect(rows).toHaveLength(1);
446
764
  expect(rows[0].vertical).toBe('housing');
447
765
  await host.admin.unsuspendScope(staff, t5, named.id);
448
766
  });
449
767
  // -- the admin audit log, read side (control-plane.md §4.4/§4.5) ----------
450
768
  it('narrows the audit log by scope, actor and action (§4.5)', async () => {
451
- const [any] = await host.admin.listScopes({ tenantId: t5 });
452
- const byScope = await host.admin.auditLog({ tenantId: t5, scopeId: any.id });
769
+ const [any] = await host.admin.listScopes(staff, { tenantId: t5 });
770
+ const byScope = await host.admin.auditLog(staff, { tenantId: t5, scopeId: any.id });
453
771
  expect(byScope.length).toBeGreaterThan(0);
454
772
  expect(byScope.every((r) => r.scopeId === any.id)).toBe(true);
455
- const byActor = await host.admin.auditLog({ tenantId: t5, actor: staff });
773
+ const byActor = await host.admin.auditLog(staff, { tenantId: t5, actor: staff });
456
774
  expect(byActor.length).toBeGreaterThan(0);
457
775
  expect(byActor.every((r) => r.actor === staff)).toBe(true);
458
776
  // A different actor shares the log and must not see these rows.
459
- expect(await host.admin.auditLog({ actor: platformActorId.parse(ulid()) })).toEqual([]);
460
- const lifecycle = await host.admin.auditLog({
777
+ expect(await host.admin.auditLog(staff, { actor: platformActorId.parse(ulid()) })).toEqual([]);
778
+ const lifecycle = await host.admin.auditLog(staff, {
461
779
  tenantId: t5,
462
780
  action: ['suspendScope', 'unsuspendScope'],
463
781
  });
464
782
  expect(lifecycle.length).toBeGreaterThan(0);
465
783
  expect(lifecycle.every((r) => r.action === 'suspendScope' || r.action === 'unsuspendScope')).toBe(true);
466
784
  // Single action, not an array — both spellings are accepted.
467
- const provisions = await host.admin.auditLog({ tenantId: t5, action: 'provisionScope' });
785
+ const provisions = await host.admin.auditLog(staff, { tenantId: t5, action: 'provisionScope' });
468
786
  expect(provisions.every((r) => r.action === 'provisionScope')).toBe(true);
469
787
  // Empty action list matches nothing rather than everything.
470
- expect(await host.admin.auditLog({ tenantId: t5, action: [] })).toEqual([]);
788
+ expect(await host.admin.auditLog(staff, { tenantId: t5, action: [] })).toEqual([]);
471
789
  });
472
790
  it('orders oldest-first by default and newest-first on request (§4.5)', async () => {
473
- const asc = await host.admin.auditLog({ tenantId: t5 });
474
- const desc = await host.admin.auditLog({ tenantId: t5, order: 'desc' });
791
+ const asc = await host.admin.auditLog(staff, { tenantId: t5 });
792
+ const desc = await host.admin.auditLog(staff, { tenantId: t5, order: 'desc' });
475
793
  expect(asc.length).toBe(desc.length);
476
794
  expect(asc.length).toBeGreaterThan(1);
477
795
  // The default preserves the ordering the log shipped with; the console reads desc.
478
796
  expect(desc.map((r) => r.id)).toEqual([...asc.map((r) => r.id)].reverse());
479
797
  });
480
798
  it('limits and pages the audit log by cursor (§4.5)', async () => {
481
- const all = await host.admin.auditLog({ tenantId: t5 });
799
+ const all = await host.admin.auditLog(staff, { tenantId: t5 });
482
800
  expect(all.length).toBeGreaterThan(2);
483
- const first = await host.admin.auditLog({ tenantId: t5, limit: 2 });
801
+ const first = await host.admin.auditLog(staff, { tenantId: t5, limit: 2 });
484
802
  expect(first.map((r) => r.id)).toEqual(all.slice(0, 2).map((r) => r.id));
485
803
  // The cursor IS the last entry's id — ULID order is chronological, so no
486
804
  // separate encoding is needed. Paging forward resumes strictly after it.
487
- const next = await host.admin.auditLog({
805
+ const next = await host.admin.auditLog(staff, {
488
806
  tenantId: t5,
489
807
  limit: 2,
490
808
  cursor: first[first.length - 1].id,
491
809
  });
492
810
  expect(next.map((r) => r.id)).toEqual(all.slice(2, 4).map((r) => r.id));
493
811
  // Descending pages backward from the cursor.
494
- const descPage = await host.admin.auditLog({
812
+ const descPage = await host.admin.auditLog(staff, {
495
813
  tenantId: t5,
496
814
  order: 'desc',
497
815
  limit: 2,
@@ -503,13 +821,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
503
821
  .reverse());
504
822
  });
505
823
  it('bounds the audit log by time (§4.5)', async () => {
506
- const all = await host.admin.auditLog({ tenantId: t5 });
824
+ const all = await host.admin.auditLog(staff, { tenantId: t5 });
507
825
  const pivot = all[1].at;
508
826
  // `since` is inclusive, `until` exclusive.
509
- const since = await host.admin.auditLog({ tenantId: t5, since: pivot });
827
+ const since = await host.admin.auditLog(staff, { tenantId: t5, since: pivot });
510
828
  expect(since.every((r) => r.at >= pivot)).toBe(true);
511
829
  expect(since.some((r) => r.id === all[1].id)).toBe(true);
512
- const until = await host.admin.auditLog({ tenantId: t5, until: pivot });
830
+ const until = await host.admin.auditLog(staff, { tenantId: t5, until: pivot });
513
831
  expect(until.every((r) => r.at < pivot)).toBe(true);
514
832
  });
515
833
  });