@substrat-run/contract-tests 0.13.0 → 0.16.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,7 +1,7 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
3
- import { connectionId, moduleManifest, orgId, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
4
- import { ulid } from '@substrat-run/kernel';
3
+ import { connectionId, moduleManifest, orgId, AUTO_ADMISSION_NOTE, platformActorId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
4
+ import { runPlatformSweep, ulid } from '@substrat-run/kernel';
5
5
  import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
6
6
  /**
7
7
  * The scope-host contract suite (design doc §11). Every adapter — pure SQLite,
@@ -285,6 +285,365 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
285
285
  const after = await host.executorDeadLetters(t1, s1);
286
286
  expect(after).toHaveLength(before.length);
287
287
  });
288
+ // -- scope data introspection: the §5.4 admin-query RPC --------------------
289
+ //
290
+ // A read-only window into a scope's OWN database (the console/dashboard Data
291
+ // view). The properties that matter: it sees the vertical's tables AND the
292
+ // spine (flagged apart), pages, refuses an unknown table, and fails closed on
293
+ // a mismatched (tenantId, scopeId) pair exactly as getScope does (K-3).
294
+ describe('scope data introspection (§5.4)', () => {
295
+ it('lists the scope tables, flagging the spine as system', async () => {
296
+ const stub = await host.getScope(alice, t1, s1);
297
+ await stub.invoke('test/write-marker', { v: 'introspect-me' });
298
+ const tables = await host.admin.listScopeTables(staff, t1, s1);
299
+ const byName = new Map(tables.map((t) => [t.name, t]));
300
+ // The vertical's own table: present, not flagged system, with rows.
301
+ expect(byName.get('marker')?.system).toBe(false);
302
+ expect(byName.get('marker').rowCount).toBeGreaterThan(0);
303
+ // The spine: present and flagged system, so the UI can group it apart.
304
+ expect(byName.get('_substrat_outbox')?.system).toBe(true);
305
+ });
306
+ it('reads a bounded page of a table', async () => {
307
+ const stub = await host.getScope(alice, t1, s1);
308
+ await stub.invoke('test/write-marker', { v: 'page-row' });
309
+ const page = await host.admin.readScopeTable(staff, t1, s1, { table: 'marker', limit: 50, offset: 0 });
310
+ expect(page.columns).toContain('v');
311
+ const vCol = page.columns.indexOf('v');
312
+ expect(page.rows.map((r) => r[vCol])).toContain('page-row');
313
+ expect(page.rowCount).toBeGreaterThanOrEqual(page.rows.length);
314
+ });
315
+ it('respects limit (paging)', async () => {
316
+ const page = await host.admin.readScopeTable(staff, t1, s1, { table: 'marker', limit: 1, offset: 0 });
317
+ expect(page.rows.length).toBeLessThanOrEqual(1);
318
+ expect(page.limit).toBe(1);
319
+ });
320
+ it('rejects an unknown table', async () => {
321
+ await expect(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 })).rejects.toThrow(/unknown table/);
322
+ });
323
+ it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
324
+ await expect(host.admin.listScopeTables(staff, t2, s1)).rejects.toThrow();
325
+ await expect(host.admin.readScopeTable(staff, t2, s1, { table: 'marker', limit: 50, offset: 0 })).rejects.toThrow();
326
+ });
327
+ });
328
+ // -- the SQL console: queryScope (#219) -----------------------------------
329
+ //
330
+ // The table reads above are safe by construction; here user SQL DOES reach the
331
+ // scope's DB, so what the contract pins is the enforcement: any write shape is
332
+ // refused AND leaves no trace, results are row-capped, and the K-3 cross-check
333
+ // fails closed exactly as the table reads do.
334
+ describe('scope SQL console: queryScope (#219)', () => {
335
+ it('runs a read-only SELECT, joins included', async () => {
336
+ const stub = await host.getScope(alice, t1, s1);
337
+ await stub.invoke('test/write-marker', { v: 'console-row' });
338
+ const result = await host.admin.queryScope(staff, t1, s1, {
339
+ sql: `SELECT m.v, count(*) AS n FROM marker m WHERE m.v = 'console-row' GROUP BY m.v`,
340
+ });
341
+ expect(result.columns).toEqual(['v', 'n']);
342
+ expect(result.rows.length).toBe(1);
343
+ expect(result.rows[0][0]).toBe('console-row');
344
+ expect(result.truncated).toBe(false);
345
+ });
346
+ it('reads the spine (projections read it; only writes forge it)', async () => {
347
+ const result = await host.admin.queryScope(staff, t1, s1, {
348
+ sql: 'SELECT type FROM _substrat_outbox ORDER BY id LIMIT 5',
349
+ });
350
+ expect(result.columns).toEqual(['type']);
351
+ expect(result.rows.length).toBeGreaterThan(0);
352
+ });
353
+ it("a ';' or a write verb inside a string literal does not trip the gate", async () => {
354
+ const result = await host.admin.queryScope(staff, t1, s1, {
355
+ sql: `SELECT 'update t; drop table x' AS s -- comment with; semicolon`,
356
+ });
357
+ expect(result.rows[0][0]).toBe('update t; drop table x');
358
+ });
359
+ it('caps the result and reports truncation, never an error', async () => {
360
+ const result = await host.admin.queryScope(staff, t1, s1, {
361
+ sql: `WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 1000)
362
+ SELECT x FROM n`,
363
+ });
364
+ expect(result.rows.length).toBe(SCOPE_QUERY_ROW_MAX);
365
+ expect(result.truncated).toBe(true);
366
+ });
367
+ it('rejects every write shape — and the refusal leaves no trace', async () => {
368
+ const writes = [
369
+ `INSERT INTO marker (v) VALUES ('forged')`,
370
+ `UPDATE marker SET v = 'forged'`,
371
+ `DELETE FROM marker`,
372
+ `REPLACE INTO marker (v) VALUES ('forged')`,
373
+ `DROP TABLE marker`,
374
+ `CREATE TABLE forged (id TEXT)`,
375
+ `PRAGMA journal_mode = OFF`,
376
+ `SELECT 1; DELETE FROM marker`,
377
+ // The first-keyword check alone would pass this one — SQLite allows a
378
+ // write behind a CTE, which is exactly why write verbs are refused anywhere.
379
+ `WITH x AS (SELECT v FROM marker) INSERT INTO marker (v) SELECT v FROM x`,
380
+ ];
381
+ for (const sql of writes) {
382
+ // The message prefix is CONTRACT: the transport maps /read-only console/
383
+ // to a 400, so both adapters must refuse with it (errors.ts).
384
+ await expect(host.admin.queryScope(staff, t1, s1, { sql })).rejects.toThrow(/read-only console/);
385
+ }
386
+ // No write happened and nothing was forged: the marker rows are intact.
387
+ const after = await host.admin.queryScope(staff, t1, s1, {
388
+ sql: `SELECT count(*) FROM marker WHERE v = 'forged'`,
389
+ });
390
+ expect(after.rows[0][0]).toBe(0);
391
+ });
392
+ it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
393
+ await expect(host.admin.queryScope(staff, t2, s1, { sql: 'SELECT 1' })).rejects.toThrow();
394
+ });
395
+ });
396
+ // -- scope export (preview-and-snapshots.md §3) ---------------------------
397
+ //
398
+ // The privileged sibling of introspection: not a bounded page but the WHOLE
399
+ // scope — every table, its DDL, every row — the source a fork/pull reads. What
400
+ // matters: it is complete (the vertical's tables AND the spine), it is faithful
401
+ // (rows + schema, not blob-as-null), it drops only the un-recreatable sqlite_*
402
+ // internals, and it fails closed on a mismatched pair exactly as the reads do.
403
+ describe('scope export (§3)', () => {
404
+ it('dumps every table — the vertical\'s own AND the spine — with DDL and rows', async () => {
405
+ const stub = await host.getScope(alice, t1, s1);
406
+ await stub.invoke('test/write-marker', { v: 'export-me' });
407
+ const dump = await host.admin.exportScope(staff, t1, s1);
408
+ expect(dump.tenantId).toBe(t1);
409
+ expect(dump.scopeId).toBe(s1);
410
+ expect(dump.capturedAt).toBeTruthy();
411
+ const byName = new Map(dump.tables.map((t) => [t.name, t]));
412
+ // The vertical's own table: dumped, with its CREATE statement and its rows.
413
+ const marker = byName.get('marker');
414
+ expect(marker).toBeDefined();
415
+ expect(marker.ddl.toLowerCase()).toContain('create table');
416
+ const vCol = marker.columns.indexOf('v');
417
+ expect(vCol).toBeGreaterThanOrEqual(0);
418
+ expect(marker.rows.map((r) => r[vCol])).toContain('export-me');
419
+ // The `_substrat_*` spine is INCLUDED — a fork must carry event/migration state.
420
+ expect(byName.has('_substrat_outbox')).toBe(true);
421
+ expect(byName.has('_substrat_migrations')).toBe(true);
422
+ // SQLite's own internals are NOT dumped (auto-managed, un-recreatable).
423
+ expect(dump.tables.some((t) => t.name.startsWith('sqlite_'))).toBe(false);
424
+ });
425
+ it('is complete — every introspected table (minus sqlite internals) is present', async () => {
426
+ const tables = await host.admin.listScopeTables(staff, t1, s1);
427
+ const dump = await host.admin.exportScope(staff, t1, s1);
428
+ const dumped = new Set(dump.tables.map((t) => t.name));
429
+ for (const t of tables) {
430
+ if (t.name.startsWith('sqlite_'))
431
+ continue;
432
+ expect(dumped.has(t.name)).toBe(true);
433
+ }
434
+ // The vertical table's rows are dumped in full (count matches introspection).
435
+ const marker = dump.tables.find((t) => t.name === 'marker');
436
+ const introMarker = tables.find((t) => t.name === 'marker');
437
+ expect(marker.rows.length).toBe(introMarker.rowCount);
438
+ });
439
+ it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
440
+ await expect(host.admin.exportScope(staff, t2, s1)).rejects.toThrow();
441
+ });
442
+ });
443
+ // -- scope import: the fork round-trip (preview-and-snapshots.md §3) -------
444
+ //
445
+ // The write side of exportScope. `importScope` provisions a NEW scope and loads
446
+ // a dump into it — a faithful, INDEPENDENT copy at the source's frontier. What
447
+ // matters: the copy has the source's tables + rows (fidelity), and the two do not
448
+ // share storage (a later write to one is invisible to the other).
449
+ describe('scope import — fork (§3)', () => {
450
+ it('round-trips: import a dump into a new scope, identical to the source', async () => {
451
+ const stub = await host.getScope(alice, t1, s1);
452
+ await stub.invoke('test/write-marker', { v: 'fork-me' });
453
+ const dump = await host.admin.exportScope(staff, t1, s1);
454
+ const copy = scopeId.parse(ulid());
455
+ await host.importScope(staff, { tenantId: t1, scopeId: copy, jurisdiction: 'eu', vertical: 'connector-vertical' }, dump);
456
+ // Same tables and row counts as the source (the spine came across too).
457
+ const src = (await host.admin.listScopeTables(staff, t1, s1))
458
+ .map((t) => `${t.name}:${t.rowCount}`)
459
+ .sort();
460
+ const dst = (await host.admin.listScopeTables(staff, t1, copy))
461
+ .map((t) => `${t.name}:${t.rowCount}`)
462
+ .sort();
463
+ expect(dst).toEqual(src);
464
+ // The vertical's data is present in the copy.
465
+ const page = await host.admin.readScopeTable(staff, t1, copy, {
466
+ table: 'marker',
467
+ limit: 200,
468
+ offset: 0,
469
+ });
470
+ const vCol = page.columns.indexOf('v');
471
+ expect(page.rows.map((r) => r[vCol])).toContain('fork-me');
472
+ });
473
+ it('is an independent copy — a later write to the source does not reach it', async () => {
474
+ const dump = await host.admin.exportScope(staff, t1, s1);
475
+ const copy = scopeId.parse(ulid());
476
+ await host.importScope(staff, { tenantId: t1, scopeId: copy, jurisdiction: 'eu', vertical: 'connector-vertical' }, dump);
477
+ const countMarker = async (sc) => (await host.admin.listScopeTables(staff, t1, sc)).find((t) => t.name === 'marker')
478
+ ?.rowCount ?? 0;
479
+ const before = await countMarker(copy);
480
+ // Write to the SOURCE after the fork; the copy is frozen at capture time.
481
+ const stub = await host.getScope(alice, t1, s1);
482
+ await stub.invoke('test/write-marker', { v: 'after-fork' });
483
+ expect(await countMarker(copy)).toBe(before);
484
+ });
485
+ it('records fork provenance (forked_from/at + kind) on the new scope', async () => {
486
+ const dump = await host.admin.exportScope(staff, t1, s1);
487
+ const copy = scopeId.parse(ulid());
488
+ await host.importScope(staff, {
489
+ tenantId: t1,
490
+ scopeId: copy,
491
+ jurisdiction: 'eu',
492
+ vertical: 'connector-vertical',
493
+ kind: 'preview',
494
+ }, dump);
495
+ const rec = await host.admin.getScopeRecord(staff, t1, copy);
496
+ expect(rec?.kind).toBe('preview');
497
+ expect(rec?.forkedFrom).toBe(s1); // stamped from the dump's source scope
498
+ expect(rec?.forkedAt).toBe(dump.capturedAt);
499
+ // A normally-provisioned scope carries no provenance — the fields are what
500
+ // set a fork apart.
501
+ const src = await host.admin.getScopeRecord(staff, t1, s1);
502
+ expect(src?.forkedFrom).toBeNull();
503
+ expect(src?.forkedAt).toBeNull();
504
+ });
505
+ });
506
+ // -- snapshot & auto-snapshot on bind (preview-and-snapshots.md §3/§4) -----
507
+ //
508
+ // snapshotScope forks an `archive` copy at the source's frontier. bindScopeVersion,
509
+ // opted in, does it automatically before a MIGRATION-changing rebind — and only
510
+ // then: a code-only rebind (same migration digest) snapshots nothing, and opting
511
+ // out never snapshots.
512
+ describe('scope snapshot (§3/§4)', () => {
513
+ const countForks = async (of) => (await host.admin.listScopes(staff, { tenantId: t1 })).filter((sc) => sc.forkedFrom === of)
514
+ .length;
515
+ it('snapshotScope forks an archive copy at the source frontier', async () => {
516
+ const stub = await host.getScope(alice, t1, s1);
517
+ await stub.invoke('test/write-marker', { v: 'snap-me' });
518
+ const snapId = await host.snapshotScope(staff, t1, s1);
519
+ const rec = await host.admin.getScopeRecord(staff, t1, snapId);
520
+ expect(rec?.kind).toBe('archive');
521
+ expect(rec?.forkedFrom).toBe(s1);
522
+ const page = await host.admin.readScopeTable(staff, t1, snapId, {
523
+ table: 'marker',
524
+ limit: 200,
525
+ offset: 0,
526
+ });
527
+ const vCol = page.columns.indexOf('v');
528
+ expect(page.rows.map((r) => r[vCol])).toContain('snap-me');
529
+ });
530
+ it('bindScopeVersion snapshots a migration-changing rebind, and only that', async () => {
531
+ // A fresh scope + a vertical with two admitted versions of differing migration
532
+ // digest, plus a third that is a code-only change (same migration digest).
533
+ const sb = scopeId.parse(ulid());
534
+ await host.provisionScope(staff, {
535
+ tenantId: t1,
536
+ scopeId: sb,
537
+ jurisdiction: 'eu',
538
+ vertical: 'snaptest',
539
+ });
540
+ await host.admin.activateScope(staff, t1, sb);
541
+ await host.admin.registerVertical(staff, {
542
+ slug: 'snaptest',
543
+ name: 'Snap Test',
544
+ source: 'builtin',
545
+ });
546
+ const publish = async (version, mig) => {
547
+ const id = ulid();
548
+ await host.admin.publishVersion(staff, {
549
+ id,
550
+ verticalSlug: 'snaptest',
551
+ version,
552
+ manifestDigest: `man-${version}`,
553
+ permissionDigest: 'p',
554
+ migrationDigest: mig,
555
+ deploymentRef: null,
556
+ });
557
+ await host.admin.admitVersion(staff, id);
558
+ return id;
559
+ };
560
+ const vA = await publish('1.0.0', 'gA');
561
+ const vB = await publish('2.0.0', 'gB'); // migration change vs vA
562
+ const vBcode = await publish('2.0.1', 'gB'); // code-only vs vB
563
+ const before = await countForks(sb);
564
+ await host.admin.bindScopeVersion(staff, t1, sb, vA); // first bind — no prior version
565
+ await host.admin.bindScopeVersion(staff, t1, sb, vB, { snapshot: true }); // gA→gB — snapshot
566
+ expect(await countForks(sb)).toBe(before + 1);
567
+ // The snapshot is an archive bound to the OUTGOING version (vA) — the frontier
568
+ // it froze at.
569
+ const forks = (await host.admin.listScopes(staff, { tenantId: t1 })).filter((sc) => sc.forkedFrom === sb);
570
+ expect(forks.every((f) => f.kind === 'archive')).toBe(true);
571
+ expect(forks.some((f) => f.verticalVersionId === vA)).toBe(true);
572
+ // A code-only rebind (same migration digest), even opted in, snapshots nothing.
573
+ await host.admin.bindScopeVersion(staff, t1, sb, vBcode, { snapshot: true });
574
+ expect(await countForks(sb)).toBe(before + 1);
575
+ // Opting out never snapshots, even across a migration boundary.
576
+ await host.admin.bindScopeVersion(staff, t1, sb, vA); // gB→gA, no opts
577
+ expect(await countForks(sb)).toBe(before + 1);
578
+ });
579
+ });
580
+ // -- snapshot retention: deleteSnapshot + the GC sweep (§3/§9) -------------
581
+ //
582
+ // The one sanctioned hard delete, kept narrow: only a FORK may be reaped, and the
583
+ // sweep only reaps forks whose creator asked for an expiry. What matters: the
584
+ // delete removes record + hostnames + storage; a primary scope is refused; the
585
+ // sweep takes exactly the expired and leaves the unexpired and the pinned.
586
+ describe('snapshot retention (§3/§9)', () => {
587
+ it('deleteSnapshot removes the fork — record, hostnames, and reachability', async () => {
588
+ const snapId = await host.snapshotScope(staff, t1, s1);
589
+ await host.admin.bindHostname(staff, {
590
+ hostname: `snap-${snapId.toLowerCase()}.test.example`,
591
+ tenantId: t1,
592
+ scopeId: snapId,
593
+ surface: 'app',
594
+ region: null,
595
+ canonical: true,
596
+ });
597
+ expect(await host.admin.listHostnames(staff, { scopeId: snapId })).toHaveLength(1);
598
+ await host.deleteSnapshot(staff, t1, snapId);
599
+ expect(await host.admin.getScopeRecord(staff, t1, snapId)).toBeUndefined();
600
+ expect(await host.admin.listHostnames(staff, { scopeId: snapId })).toHaveLength(0);
601
+ // The storage is unreachable through every read: the directory row is gone,
602
+ // so the K-3 cross-check fails closed.
603
+ await expect(host.admin.exportScope(staff, t1, snapId)).rejects.toThrow();
604
+ });
605
+ it('refuses to delete a primary scope (forkedFrom is null)', async () => {
606
+ await expect(host.deleteSnapshot(staff, t1, s1)).rejects.toThrow(/not a fork/);
607
+ // Still there, still readable.
608
+ expect((await host.admin.getScopeRecord(staff, t1, s1))?.id).toBe(s1);
609
+ });
610
+ it('the GC sweep reaps exactly the expired forks', async () => {
611
+ const past = new Date(Date.now() - 60_000).toISOString();
612
+ const future = new Date(Date.now() + 3_600_000).toISOString();
613
+ const expired = await host.snapshotScope(staff, t1, s1, { expiresAt: past });
614
+ const unexpired = await host.snapshotScope(staff, t1, s1, { expiresAt: future });
615
+ const pinned = await host.snapshotScope(staff, t1, s1); // no expiry — retained
616
+ const report = await runPlatformSweep(host, {
617
+ actor: staff,
618
+ fetch: connectorTestFetch,
619
+ sweepers: {},
620
+ drainRetries: false,
621
+ });
622
+ expect(report.snapshotsReaped).toBeGreaterThanOrEqual(1);
623
+ expect(await host.admin.getScopeRecord(staff, t1, expired)).toBeUndefined();
624
+ expect((await host.admin.getScopeRecord(staff, t1, unexpired))?.id).toBe(unexpired);
625
+ expect((await host.admin.getScopeRecord(staff, t1, pinned))?.id).toBe(pinned);
626
+ // The primary scope was never in danger.
627
+ expect((await host.admin.getScopeRecord(staff, t1, s1))?.id).toBe(s1);
628
+ // Clean up the survivors so later suite state stays predictable.
629
+ await host.deleteSnapshot(staff, t1, unexpired);
630
+ await host.deleteSnapshot(staff, t1, pinned);
631
+ });
632
+ it('gcSnapshots: false skips the phase', async () => {
633
+ const past = new Date(Date.now() - 60_000).toISOString();
634
+ const snapId = await host.snapshotScope(staff, t1, s1, { expiresAt: past });
635
+ const report = await runPlatformSweep(host, {
636
+ actor: staff,
637
+ fetch: connectorTestFetch,
638
+ sweepers: {},
639
+ drainRetries: false,
640
+ gcSnapshots: false,
641
+ });
642
+ expect(report.snapshotsReaped).toBe(0);
643
+ expect((await host.admin.getScopeRecord(staff, t1, snapId))?.id).toBe(snapId);
644
+ await host.deleteSnapshot(staff, t1, snapId);
645
+ });
646
+ });
288
647
  // -- the integrations hub: connections (#101) -----------------------------
289
648
  //
290
649
  // The store exists so a vertical's connector can reach a tenant's provider
@@ -611,6 +970,115 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
611
970
  ownerTenant: t2,
612
971
  })).rejects.toThrow(/owned by the platform/);
613
972
  });
973
+ it('carries registry-driven install metadata (marketplace-publish.md §3) and refreshes it', async () => {
974
+ // ownerGrants/entitlements/provides/requires ride the registry (one install_spec JSON
975
+ // column) so the dashboard installs a vertical without a hardcoded catalog entry.
976
+ await host.admin.registerVertical(staff, {
977
+ slug: 'authy',
978
+ name: 'Authy',
979
+ source: 'cli',
980
+ ownerTenant: t2,
981
+ entitlements: ['authy'],
982
+ ownerGrants: ['content:admin'],
983
+ provides: ['oidc-issuer'],
984
+ });
985
+ const first = (await host.admin.listVerticals(staff)).find((v) => v.slug === 'authy');
986
+ expect(first?.entitlements).toEqual(['authy']);
987
+ expect(first?.ownerGrants).toEqual(['content:admin']);
988
+ expect(first?.provides).toEqual(['oidc-issuer']);
989
+ expect(first?.requires).toBeUndefined();
990
+ // Like envSpec, install metadata evolves with the manifest: an otherwise-identical
991
+ // re-registration refreshes it rather than conflicting.
992
+ await host.admin.registerVertical(staff, {
993
+ slug: 'authy',
994
+ name: 'Authy',
995
+ source: 'cli',
996
+ ownerTenant: t2,
997
+ entitlements: ['authy'],
998
+ ownerGrants: ['content:admin', 'content:publish'],
999
+ requires: ['oidc-issuer'],
1000
+ });
1001
+ const second = (await host.admin.listVerticals(staff)).find((v) => v.slug === 'authy');
1002
+ expect(second?.ownerGrants).toEqual(['content:admin', 'content:publish']);
1003
+ expect(second?.requires).toEqual(['oidc-issuer']);
1004
+ expect(second?.provides).toBeUndefined(); // dropped on refresh — the blob is replaced whole
1005
+ });
1006
+ it('publishes/unpublishes a vertical to the marketplace (setVerticalListed), preserved across re-push', async () => {
1007
+ const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
1008
+ await host.admin.registerVertical(staff, { slug: 'listtest', name: 'ListTest', source: 'cli', ownerTenant: t2 });
1009
+ expect((await at('listtest'))?.listed).toBe(false); // private on push
1010
+ await host.admin.setVerticalListed(staff, 'listtest', true);
1011
+ expect((await at('listtest'))?.listed).toBe(true); // published
1012
+ // The invariant: a re-push does NOT unpublish it — `listed` is its own column, untouched
1013
+ // by the register refresh (publish is a distinct action from push).
1014
+ await host.admin.registerVertical(staff, { slug: 'listtest', name: 'ListTest', source: 'cli', ownerTenant: t2 });
1015
+ expect((await at('listtest'))?.listed).toBe(true);
1016
+ await host.admin.setVerticalListed(staff, 'listtest', true); // idempotent
1017
+ await host.admin.setVerticalListed(staff, 'listtest', false);
1018
+ expect((await at('listtest'))?.listed).toBe(false); // unpublished
1019
+ // A builder's publish REQUEST is recorded, and RESOLVED (cleared) by the staff listing.
1020
+ await host.admin.requestPublish(staff, 'listtest');
1021
+ expect((await at('listtest'))?.publishRequestedAt).toBeTruthy();
1022
+ await host.admin.setVerticalListed(staff, 'listtest', true);
1023
+ expect((await at('listtest'))?.publishRequestedAt).toBeUndefined(); // cleared on review
1024
+ await expect(host.admin.setVerticalListed(staff, 'no-such-vertical', true)).rejects.toThrow(/unknown vertical/);
1025
+ await expect(host.admin.requestPublish(staff, 'no-such-vertical')).rejects.toThrow(/unknown vertical/);
1026
+ });
1027
+ it('blocks new installs (setVerticalInstallsBlocked) — a provisioning gate, not a delete', async () => {
1028
+ const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
1029
+ await host.admin.registerVertical(staff, { slug: 'blocktest', name: 'BlockTest', source: 'cli', ownerTenant: t2 });
1030
+ expect((await at('blocktest'))?.installsBlocked).toBe(false); // installable on push
1031
+ await host.admin.setVerticalInstallsBlocked(staff, 'blocktest', true);
1032
+ expect((await at('blocktest'))?.installsBlocked).toBe(true);
1033
+ await host.admin.setVerticalInstallsBlocked(staff, 'blocktest', true); // idempotent
1034
+ // Orthogonal to `listed` — blocking is not unpublishing.
1035
+ expect((await at('blocktest'))?.listed).toBe(false);
1036
+ await host.admin.setVerticalInstallsBlocked(staff, 'blocktest', false);
1037
+ expect((await at('blocktest'))?.installsBlocked).toBe(false);
1038
+ await expect(host.admin.setVerticalInstallsBlocked(staff, 'no-such-vertical', true)).rejects.toThrow(/unknown vertical/);
1039
+ });
1040
+ it('deletes a vertical — refused while a scope is bound, total once nothing is', async () => {
1041
+ // 'callout' still backs s1 (bound above): the refusal that stops a delete from
1042
+ // stranding a live scope's version pin and routing.
1043
+ await expect(host.admin.deleteVertical(staff, 'callout')).rejects.toThrow(/still backs/);
1044
+ expect((await host.admin.listVerticals(staff)).some((v) => v.slug === 'callout')).toBe(true);
1045
+ // A vertical nothing is bound to deletes totally: row, versions, channels.
1046
+ const versionId = ulid();
1047
+ await host.admin.registerVertical(staff, { slug: 'deletable', name: 'Deletable', source: 'cli', ownerTenant: t2 });
1048
+ await host.admin.publishVersion(staff, {
1049
+ id: versionId,
1050
+ verticalSlug: 'deletable',
1051
+ version: '0.0.1',
1052
+ manifestDigest: 'm',
1053
+ permissionDigest: 'p',
1054
+ migrationDigest: 'g',
1055
+ deploymentRef: null,
1056
+ });
1057
+ await host.admin.admitVersion(staff, versionId);
1058
+ await host.admin.promoteVersion(staff, 'deletable', 'dev', versionId);
1059
+ await host.admin.deleteVertical(staff, 'deletable');
1060
+ expect((await host.admin.listVerticals(staff)).some((v) => v.slug === 'deletable')).toBe(false);
1061
+ expect(await host.admin.listVersions(staff, 'deletable')).toEqual([]);
1062
+ expect(await host.admin.listChannels(staff, 'deletable')).toEqual([]);
1063
+ // The slug is claimable again — a delete is a real removal, not a tombstone.
1064
+ await host.admin.registerVertical(staff, { slug: 'deletable', name: 'Deletable', source: 'cli', ownerTenant: t2 });
1065
+ expect(await host.admin.listVersions(staff, 'deletable')).toEqual([]); // no resurrected versions
1066
+ await host.admin.deleteVertical(staff, 'deletable');
1067
+ await expect(host.admin.deleteVertical(staff, 'no-such-vertical')).rejects.toThrow(/unknown vertical/);
1068
+ });
1069
+ it('refreshes `listed` on builtin re-registration (the catalog re-seed can list a row)', async () => {
1070
+ const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
1071
+ // A builtin first registered UNLISTED (bundled but not yet deployable, or a row
1072
+ // predating the `listed` column) must become listed when the seed says so —
1073
+ // `ensureCatalog` re-registers each boot, and for builtins `listed` is seed metadata.
1074
+ await host.admin.registerVertical(staff, { slug: 'seeded', name: 'Seeded', source: 'builtin' });
1075
+ expect((await at('seeded'))?.listed).toBe(false);
1076
+ await host.admin.registerVertical(staff, { slug: 'seeded', name: 'Seeded', source: 'builtin', listed: true });
1077
+ expect((await at('seeded'))?.listed).toBe(true);
1078
+ // And back: flipping the seed to `connected: false` delists on the next re-seed.
1079
+ await host.admin.registerVertical(staff, { slug: 'seeded', name: 'Seeded', source: 'builtin' });
1080
+ expect((await at('seeded'))?.listed).toBe(false);
1081
+ });
614
1082
  it('refuses to bind a rejected version, and rejection is terminal', async () => {
615
1083
  const versionId = ulid();
616
1084
  await host.admin.publishVersion(staff, {
@@ -727,6 +1195,102 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
727
1195
  });
728
1196
  await expect(host.admin.promoteVersion(staff, 'callout', 'dev', pending)).rejects.toThrow(/pending, not admitted/);
729
1197
  });
1198
+ // -- private verticals: self-serve prod (builder-plane.md §4-revised) ----
1199
+ // A private vertical's blast radius is its own tenant — dev/staging already
1200
+ // run the same bundle in the same sandbox — so its versions self-admit on
1201
+ // push and prod is the owner's to move. The staff seam moves to publish,
1202
+ // where the audience actually widens.
1203
+ const publishPrivate = async (slug, version, digests) => {
1204
+ const id = ulid();
1205
+ await host.admin.publishVersion(staff, {
1206
+ id,
1207
+ verticalSlug: slug,
1208
+ version,
1209
+ manifestDigest: `man-${version}`,
1210
+ permissionDigest: digests?.perm ?? 'pp',
1211
+ migrationDigest: digests?.mig ?? 'gg',
1212
+ deploymentRef: null,
1213
+ });
1214
+ return id;
1215
+ };
1216
+ it('a private vertical version lands ADMITTED on push, noted as auto-admission', async () => {
1217
+ await host.admin.registerVertical(staff, {
1218
+ slug: 'egeryds/crm',
1219
+ name: 'Egeryds CRM',
1220
+ source: 'cli',
1221
+ ownerTenant: t2,
1222
+ });
1223
+ const vid = await publishPrivate('egeryds/crm', '0.1.0');
1224
+ const [v] = await host.admin.listVersions(staff, 'egeryds/crm');
1225
+ expect(v?.admission).toBe('admitted');
1226
+ expect(v?.admissionNote).toBe(AUTO_ADMISSION_NOTE);
1227
+ // Promotable immediately — for a private vertical, push + promote IS the deploy.
1228
+ await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', vid);
1229
+ expect((await host.admin.listChannels(staff, 'egeryds/crm')).find((c) => c.channel === 'prod')?.versionId).toBe(vid);
1230
+ });
1231
+ it('a platform vertical still lands pending — auto-admission is scoped to private ownership', async () => {
1232
+ // 'callout' is platform-owned (ownerTenant null): the 9.9.9 push in the test
1233
+ // above landed pending. The distinction is the whole design: self-admission
1234
+ // exists only where the author and the audience are the same tenant.
1235
+ const versions = await host.admin.listVersions(staff, 'callout');
1236
+ expect(versions.find((v) => v.version === '9.9.9')?.admission).toBe('pending');
1237
+ });
1238
+ it('promotion appends to the channel history — the go-live timeline rollback picks from', async () => {
1239
+ const first = (await host.admin.listChannelHistory(staff, 'egeryds/crm', 'prod'));
1240
+ expect(first).toHaveLength(1);
1241
+ expect(first[0].fromVersionId).toBeNull(); // nothing before the first go-live
1242
+ const v2 = await publishPrivate('egeryds/crm', '0.2.0');
1243
+ await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', v2);
1244
+ // Rollback is a NEW promotion of the older version — the timeline only grows.
1245
+ const v1 = first[0].versionId;
1246
+ await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', v1);
1247
+ const timeline = await host.admin.listChannelHistory(staff, 'egeryds/crm', 'prod');
1248
+ expect(timeline.map((h) => h.versionId)).toEqual([v1, v2, v1]); // newest first
1249
+ expect(timeline[0].fromVersionId).toBe(v2);
1250
+ expect(timeline[1].fromVersionId).toBe(v1);
1251
+ // Each entry records who and exactly when — `at` is the PITR anchor a data
1252
+ // rollback would rewind to (preview-and-snapshots.md §7).
1253
+ expect(timeline[0].actor).toBe(staff);
1254
+ expect(new Date(timeline[0].at).getTime()).toBeGreaterThan(0);
1255
+ // Narrowing by channel works: dev never moved, so its timeline is empty.
1256
+ expect(await host.admin.listChannelHistory(staff, 'egeryds/crm', 'dev')).toHaveLength(0);
1257
+ });
1258
+ it("prod promote of a private vertical re-points the owner's live scopes (merge IS the deploy)", async () => {
1259
+ // A fresh app scope in the owning tenant, bound to the current prod version.
1260
+ // Promoting a new one re-points it without a separate update step — that is
1261
+ // what makes merge-to-main a complete deploy, and a rollback promote reach
1262
+ // the running app.
1263
+ const appScope = scopeId.parse(ulid());
1264
+ await host.provisionScope(staff, { tenantId: t2, scopeId: appScope });
1265
+ await host.admin.activateScope(staff, t2, appScope);
1266
+ const prodNow = (await host.admin.listChannels(staff, 'egeryds/crm')).find((c) => c.channel === 'prod')
1267
+ .versionId;
1268
+ await host.admin.bindScopeVersion(staff, t2, appScope, prodNow);
1269
+ const v3 = await publishPrivate('egeryds/crm', '0.3.0');
1270
+ await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', v3);
1271
+ expect((await host.admin.getScopeRecord(staff, t2, appScope))?.verticalVersionId).toBe(v3);
1272
+ });
1273
+ it('publish refuses an auto-admitted prod version until a staff admit vouches for it', async () => {
1274
+ // Listing is the moment OTHER tenants start trusting this code, so the version
1275
+ // they would install needs a recorded human decision — the auto-admission note
1276
+ // is exactly what marks its absence.
1277
+ await expect(host.admin.setVerticalListed(staff, 'egeryds/crm', true)).rejects.toThrow(/auto-admitted.*staff admit/);
1278
+ // A staff admit of the already-admitted version upgrades it to a manual vouch
1279
+ // (clears the note, audited) — then listing passes.
1280
+ const prodNow = (await host.admin.listChannels(staff, 'egeryds/crm')).find((c) => c.channel === 'prod')
1281
+ .versionId;
1282
+ await host.admin.admitVersion(staff, prodNow);
1283
+ const upgraded = (await host.admin.listVersions(staff, 'egeryds/crm')).find((v) => v.id === prodNow);
1284
+ expect(upgraded?.admission).toBe('admitted');
1285
+ expect(upgraded?.admissionNote).toBeNull();
1286
+ await host.admin.setVerticalListed(staff, 'egeryds/crm', true);
1287
+ // Listed now: the next push lands PENDING — staff admission is back in the
1288
+ // path exactly when the audience widened.
1289
+ const v4 = await publishPrivate('egeryds/crm', '0.4.0');
1290
+ expect((await host.admin.listVersions(staff, 'egeryds/crm')).find((v) => v.id === v4)?.admission).toBe('pending');
1291
+ // Unlist again so later suites see the vertical private (and pushes self-admit).
1292
+ await host.admin.setVerticalListed(staff, 'egeryds/crm', false);
1293
+ });
730
1294
  // -- the provisioning state (K-31) ---------------------------------------
731
1295
  it('provisions a scope as `provisioning`, not `active`', async () => {
732
1296
  // The directory row exists before the vertical has created the scope DO. Born
@@ -883,6 +1447,30 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
883
1447
  // but neither of them routes traffic.
884
1448
  expect(await host.admin.resolveHostname('eu.example.com')).toBeUndefined();
885
1449
  });
1450
+ it('unbinds a hostname: the row is gone, it stops resolving, and the unbind is audited', async () => {
1451
+ // The inverse of bindHostname — what a cleanup pass uses on orphaned rows.
1452
+ // A hard delete, not a tombstone: the row is routing config, and its history
1453
+ // lives in the admin log.
1454
+ await host.admin.unbindHostname(staff, 'EU.example.com'); // case-insensitive, like DNS
1455
+ expect((await host.admin.listHostnames(staff, { scopeId: s1 })).map((h) => h.hostname)).not.toContain('eu.example.com');
1456
+ expect(await host.admin.resolveHostname('eu.example.com')).toBeUndefined();
1457
+ const entries = await host.admin.auditLog(staff, { scopeId: s1 });
1458
+ expect(entries.map((e) => e.action)).toContain('unbindHostname');
1459
+ // Idempotent: unbinding an unknown hostname is a no-op, not an error — a
1460
+ // cleanup pass can re-run over a partial failure.
1461
+ await expect(host.admin.unbindHostname(staff, 'eu.example.com')).resolves.toBeUndefined();
1462
+ // The released name is immediately reclaimable by another scope.
1463
+ await host.admin.bindHostname(staff, {
1464
+ hostname: 'eu.example.com',
1465
+ tenantId: t2,
1466
+ scopeId: s2,
1467
+ surface: 'app',
1468
+ region: null,
1469
+ canonical: false,
1470
+ });
1471
+ const reclaimed = (await host.admin.listHostnames(staff, { scopeId: s2 })).find((h) => h.hostname === 'eu.example.com');
1472
+ expect(reclaimed?.scopeId).toBe(s2);
1473
+ });
886
1474
  it('rejects duplicate module registration', () => {
887
1475
  expect(() => host.registerModule({ manifest: testModManifest })).toThrow(/already registered/);
888
1476
  });