@bongos/core 1.19.773 → 1.19.774

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.
Files changed (90) hide show
  1. package/.bongos-core.json +189 -74
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  8. package/docs/adr/0177-goal-membership-kind.md +26 -2
  9. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  10. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  11. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  12. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  13. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  14. package/docs/adr/README.md +3 -0
  15. package/docs/api/openapi.json +102 -11
  16. package/docs/api-reference.md +5 -4
  17. package/docs/copy-inventory.md +205 -190
  18. package/docs/copy-registry.json +435 -269
  19. package/docs/file-map.md +1 -0
  20. package/docs/module-api-changelog.md +2 -0
  21. package/docs/modules-contract.md +1 -1
  22. package/modules/builder-settings/render-prefs.js +281 -33
  23. package/modules/builder-settings/routes/settings.js +20 -12
  24. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  25. package/modules/dev-box/module.json +4 -0
  26. package/modules/hall-ui/public/settings-render.js +138 -0
  27. package/modules/hall-ui/public/settings.html +16 -9
  28. package/modules/hall-ui/public/settings.js +3 -70
  29. package/modules/lifecycle/ship-card.js +49 -13
  30. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  31. package/modules/platform-identity/platform-identity.js +8 -19
  32. package/modules/platform-identity/project-catalog.js +24 -0
  33. package/modules/platform-identity/routes/sso.js +11 -14
  34. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  35. package/modules/provisioning/capacity-gate.js +54 -0
  36. package/modules/provisioning/catalog-bridge.js +2 -0
  37. package/modules/provisioning/disconnect.js +35 -0
  38. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  39. package/modules/provisioning/module.json +1 -1
  40. package/modules/provisioning/provisioning.js +3 -3
  41. package/modules/provisioning/routes/disconnect.js +63 -0
  42. package/modules/provisioning/routes/provisioning.js +6 -6
  43. package/modules/public-landing/public/projects.html +116 -11
  44. package/modules/public-landing/public/projects.probes.json +3 -0
  45. package/modules/ui-design/kit/serve.js +3 -2
  46. package/modules/ui-design/module.json +1 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/scripts/gds/address-park-sweep.sh +78 -0
  50. package/scripts/gds/cli-lib.js +37 -0
  51. package/scripts/gds/fitness-migration-contraction.js +50 -0
  52. package/scripts/gds/fitness.js +18 -0
  53. package/scripts/gds/land-watch.js +8 -2
  54. package/scripts/gds/provision-config.js +4 -4
  55. package/scripts/gds/provision-disconnect.js +90 -0
  56. package/scripts/gds/provision-teardown.js +108 -0
  57. package/scripts/gds/provision.js +6 -65
  58. package/scripts/gds/seam-wiring-guard.js +273 -0
  59. package/scripts/gds/ship-finish.js +17 -10
  60. package/scripts/gds/ship-flow.js +5 -5
  61. package/scripts/gds/start.js +13 -7
  62. package/src/bongos/auth-admission.js +7 -8
  63. package/src/bongos/auth-config.js +3 -2
  64. package/src/bongos/goal-scope-check.js +23 -8
  65. package/src/bongos/module-scope-map.js +40 -0
  66. package/src/module-api.js +1 -1
  67. package/src/module-loader/manifest-schema.js +79 -1
  68. package/tests/catalog_only_client.mjs +8 -5
  69. package/tests/fitness.mjs +22 -0
  70. package/tests/goal_scope_check.mjs +63 -1
  71. package/tests/hall_settings_world.mjs +57 -1
  72. package/tests/hub_disconnect_ui.mjs +105 -0
  73. package/tests/land_watch.mjs +44 -0
  74. package/tests/membership_kind_lockstep.mjs +86 -27
  75. package/tests/module-scope-map.mjs +39 -0
  76. package/tests/module_exports_unique_keys.mjs +70 -0
  77. package/tests/module_manifest.mjs +85 -0
  78. package/tests/project_catalog_forget.mjs +83 -0
  79. package/tests/projects_hub.mjs +10 -4
  80. package/tests/projects_hub_pre_uat.mjs +5 -3
  81. package/tests/provision_disconnect.mjs +231 -0
  82. package/tests/provision_teardown_address.mjs +230 -0
  83. package/tests/provisioning_capacity_gate.mjs +192 -0
  84. package/tests/provisioning_disconnect.mjs +231 -0
  85. package/tests/provisioning_restart.mjs +5 -5
  86. package/tests/public_landing_projects.mjs +4 -3
  87. package/tests/render_prefs.mjs +152 -0
  88. package/tests/seam_wiring_guard.mjs +234 -0
  89. package/tests/ship_card.mjs +67 -1
  90. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
@@ -177,16 +177,16 @@ function loadDeps() {
177
177
  // (fitness Check 16b / task 2135). A vanilla core that neither ships nor enables the
178
178
  // module never resolves it → no MODULE_NOT_FOUND on a fresh boot. Null here → federation
179
179
  // dormant (shouldFederate skips) → the instance owns its auth, byte-identical to today.
180
- let platformIdentity = null, hubKeys = null;
180
+ let platformIdentity = null, hubKeys = null, projectCatalog = null;
181
181
  const PI_KEY = 'platform-identity';
182
182
  let piEnabled = false;
183
183
  try { piEnabled = require(path.join(REPO_ROOT, 'src/modules')).isModuleEnabled(PI_KEY); } catch { piEnabled = false; }
184
184
  if (piEnabled) {
185
185
  const piBase = path.join(REPO_ROOT, 'modules', PI_KEY);
186
- try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); }
187
- catch { platformIdentity = null; hubKeys = null; }
186
+ try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); projectCatalog = require(path.join(piBase, 'project-catalog')); }
187
+ catch { platformIdentity = null; hubKeys = null; projectCatalog = null; }
188
188
  }
189
- _deps = { pool, provisioning, DoApi, platformIdentity, hubKeys };
189
+ _deps = { pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog };
190
190
  return _deps;
191
191
  }
192
192
 
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+ // provision-disconnect.js — "Disconnect from Bongos entirely": the runner leg (task 1003249,
3
+ // ADR 0290).
4
+ //
5
+ // WHY. A teardown deliberately KEEPS a project's database, dumps and address (ADR 0181), so
6
+ // taking a project offline is always reversible. The owner asked for a separate, truer delete.
7
+ // This is it, and it is the exception ADR 0181 names: owner-initiated only, behind a typed-name
8
+ // confirm the route checks server-side (modules/provisioning/routes/disconnect.js).
9
+ //
10
+ // ORDER IS THE SAFETY PROPERTY. The teardown runs first (units stopped, Caddy block gone, DNS
11
+ // parked, deploy key revoked). Then the erase. Then the catalog, membership and client rows
12
+ // leave the map. The provisioning row is deleted LAST, and only when everything before it
13
+ // succeeded: the row is what lets the runner retry, so a failure anywhere leaves the row intact
14
+ // (an erase or forget failure returns a retryable { ok:false }; a teardown throw propagates).
15
+ // Deleting the row is what releases the name (slug UNIQUE), the port (allocatePort reads every
16
+ // row) and the address (the next standup UPSERTs it).
17
+ //
18
+ // The GitHub repository is never touched, and neither is the GitHub App the owner registered for
19
+ // sign-in — GitHub offers no API to delete an App; the manage page tells the owner where it is.
20
+
21
+ const path = require('node:path');
22
+ const { CONFIG } = require('./provision-config.js');
23
+ const { dbName, instanceDbRole, instanceStateDir, instanceUser, standaloneRoot } = require('./provision-repo.js');
24
+ const { webEnvPath } = require('./provision-units.js');
25
+ const { teardownInstance } = require('./provision-teardown.js');
26
+ const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
27
+ const { catalogOriginForInstance, ownerGithubIdForInstance } = require('../../modules/provisioning/catalog-bridge.js');
28
+ const { deleteInstance } = require('../../modules/provisioning/disconnect.js');
29
+
30
+ // The dump filename db-backup-nightly.sh writes — <db>-YYYY-MM-DDTHHMMSSZ.sql.gz — matched
31
+ // EXACTLY. The nightly prune's own `<db>-*.sql.gz` would let a project named `test` erase
32
+ // `test-nk`'s dumps, which is harmless for a 30-day prune and unacceptable for an erase. PURE.
33
+ function dumpGlob(inst) {
34
+ const d = '[0-9]';
35
+ return `${dbName(inst)}-${d.repeat(4)}-${d.repeat(2)}-${d.repeat(2)}T${d.repeat(6)}Z.sql.gz`;
36
+ }
37
+
38
+ // The erase, in order: the database before the role that owns it; the dumps; the env dir and
39
+ // state dir; the per-project checkout (standalone only — a co-tenant runs from the shared one);
40
+ // the unix account last, idempotently. Every path derives from a slug the caller has already
41
+ // validated. PURE.
42
+ function eraseCommands(inst, { privileged = false } = {}) {
43
+ const sudo = privileged ? 'sudo ' : '';
44
+ const asPg = (cmd) => (privileged ? `sudo -u postgres ${cmd}` : cmd);
45
+ const user = instanceUser(inst);
46
+ const cmds = [
47
+ asPg(`dropdb --if-exists --force ${dbName(inst)}`),
48
+ asPg(`psql -v ON_ERROR_STOP=1 -d postgres -c 'DROP ROLE IF EXISTS "${instanceDbRole(inst)}"'`),
49
+ `${sudo}find ${CONFIG.backupDir} -maxdepth 1 -type f -name '${dumpGlob(inst)}' -delete`,
50
+ `${sudo}rm -rf ${path.posix.dirname(webEnvPath(inst))} ${instanceStateDir(inst)}`,
51
+ ];
52
+ if (inst.hosting_shape === 'standalone') cmds.push(`${sudo}rm -rf ${standaloneRoot(inst)}`);
53
+ cmds.push(`if id -u ${user} >/dev/null 2>&1; then ${sudo}userdel ${user}; fi`);
54
+ return cmds;
55
+ }
56
+
57
+ async function disconnectInstance(inst, deps) {
58
+ const { db, exec, log, apply, privileged } = deps;
59
+ // Every rm -rf below is built from the slug, so it is re-validated here rather than trusted.
60
+ if (!inst || !isValidSlug(inst.slug)) return { ok: false, error: `refusing to disconnect: invalid slug ${JSON.stringify(inst && inst.slug)}` };
61
+ log(`disconnect ${inst.slug} (status=${inst.status})`);
62
+ // A teardown returns ok or throws; a throw ends the run here, before anything is erased.
63
+ await teardownInstance(inst, deps);
64
+
65
+ if (inst.hosting_shape !== 'dedicated') {
66
+ const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
67
+ for (const cmd of eraseCommands(inst, { privileged })) {
68
+ try { boxExec(cmd); }
69
+ catch (e) { return { ok: false, error: `erase step failed (${cmd.split(' ').slice(0, 3).join(' ')}): ${(e && e.message) || e}` }; }
70
+ }
71
+ }
72
+
73
+ const origin = catalogOriginForInstance(inst);
74
+ if (origin && deps.projectCatalog) {
75
+ if (apply) {
76
+ try {
77
+ const ownerGithubId = await ownerGithubIdForInstance(db, inst);
78
+ const gone = await deps.projectCatalog.forgetOwnedProject({ clientId: inst.slug, origin, ownerGithubId }, { pool: db });
79
+ log(` catalog: removed ${gone.project} project row, ${gone.client} client, ${gone.memberships} membership(s)`);
80
+ } catch (e) { return { ok: false, error: `catalog forget failed: ${(e && e.message) || e}` }; }
81
+ } else log(` would remove ${origin} from the catalog`);
82
+ }
83
+
84
+ if (!apply) { log(` would delete the provisioning row for ${inst.slug}`); return { ok: true }; }
85
+ await deleteInstance(db, inst.id);
86
+ log(` ✓ ${inst.slug} disconnected — row deleted; name, port and address released`);
87
+ return { ok: true };
88
+ }
89
+
90
+ module.exports = { disconnectInstance, eraseCommands, dumpGlob };
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ // provision-teardown.js — reverse the provisioning runbook for one instance
3
+ // (idempotent). Split out of provision.js (task 1002897), which sat at the size
4
+ // ratchet; provision.js re-exports teardownInstance, so its callers are unchanged.
5
+ //
6
+ // What a teardown takes down: the service + backup units, the per-instance Caddy
7
+ // snippet, the DNS record (parked at the blackhole), and the deploy key. What it
8
+ // deliberately LEAVES (ADR 0181): the database, its dumps and the checkout (data is
9
+ // precious), and the row's `domain` + `port` — a revive re-uses exactly that address,
10
+ // and step 5 of the next standup UPSERTs it straight back.
11
+
12
+ const path = require('node:path');
13
+ const { CONFIG, REPO_ROOT } = require('./provision-config.js');
14
+ const { dbName, deployKeyPath, deployKeyTitle, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo.js');
15
+ const { backupServicePath, backupTimerPath, backupUnitName, serviceUnitPath } = require('./provision-units.js');
16
+ const { caddySnippetPath, dnsReleaseEnv } = require('./provision-net.js');
17
+ const apexMod = require('./instance-apex.js');
18
+ const { isReservedHostname } = require('../../modules/provisioning/provisioning.js');
19
+
20
+ async function teardownInstance(inst, deps) {
21
+ const { db, provisioning, exec, log, apply, privileged } = deps;
22
+ const controlExec = deps.controlExec || exec;
23
+ // Tear down on the box the instance actually RAN on: a standalone ran control-plane-
24
+ // local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
25
+ // box, where it was never installed (that would allowFail-noop and leave the real
26
+ // control-plane unit running). Co-tenant + dedicated are unchanged.
27
+ const boxExec = inst.hosting_shape === 'standalone' ? controlExec : exec;
28
+ const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
29
+ log(`teardown ${inst.slug} (status=${inst.status})`);
30
+ if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
31
+ if (inst.hosting_shape !== 'dedicated') {
32
+ boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
33
+ boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
34
+ // Stop + remove the nightly backup units too (task 2213; the dir + dumps stay — precious data, like the DB below), then reset-failed (task 1003940): a removed unit's FAILED state outlives its file, so `systemctl --failed` listed torn-down ghosts for weeks.
35
+ boxExec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
36
+ boxExec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
37
+ boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
38
+ boxExec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
39
+ // The site snippet standup step 6 wrote: while it stays, Caddy proxies the hostname to
40
+ // a port nothing listens on and every visitor gets a 502 (task 1002897). Not precious.
41
+ boxExec(`${sudoP}rm -f ${caddySnippetPath(inst)}`, { allowFail: true });
42
+ boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
43
+ // Left for an operator (data is precious), naming ONLY what this row created —
44
+ // teardown now takes 'requested'/'error'. `port` + `domain` stay held: ADR 0181.
45
+ if (inst.db_name) log(` (left for the operator: dropdb ${dbName(inst)}, prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz)`);
46
+ } else if (apply && deps.doApi && inst.droplet_id) {
47
+ await deps.doApi.deleteDroplet(inst.droplet_id);
48
+ }
49
+ parkAddress(inst, { controlExec, log, apply });
50
+ // Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
51
+ // checkout itself is deliberately left in place (data is precious), so the key
52
+ // files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
53
+ // silently keeping durable read access to the owner's private repo is the exact
54
+ // shape a teardown exists to end. A missing token / API hiccup never blocks it.
55
+ if (inst.hosting_shape === 'standalone' && inst.target_ref) {
56
+ const repo = parseTargetRef(inst.target_ref);
57
+ if (repo) {
58
+ try {
59
+ const token = await resolveOwnerGithubToken(inst, deps, { tag: '[teardown]' });
60
+ if (token) {
61
+ const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
62
+ if (apply) {
63
+ const keys = await ghRepos.listDeployKeys(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
64
+ const ours = keys.find((k) => k.title === deployKeyTitle(inst));
65
+ if (ours) {
66
+ await ghRepos.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
67
+ log(` revoked the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name}`);
68
+ }
69
+ } else log(` would revoke the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name} if present`);
70
+ } else {
71
+ log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
72
+ }
73
+ } catch (e) { log(` (deploy-key revoke skipped: ${e && e.message})`); }
74
+ boxExec(`rm -f ${deployKeyPath(inst)} ${deployKeyPath(inst)}.pub`, { allowFail: true });
75
+ }
76
+ }
77
+ if (apply) {
78
+ await provisioning.setInstanceStatus(db, inst.id, 'torn_down', { torn_down_at: new Date().toISOString() });
79
+ await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'torn_down', detail: 'torn down by runner', actor: 'runner' });
80
+ }
81
+ log(` ✓ ${inst.slug} torn down`);
82
+ return { ok: true };
83
+ }
84
+
85
+ // Park the row's hostname at the RFC 5737 blackhole — the release a re-point already
86
+ // uses (ADR 0200 §2: parked, not deleted, so no NXDOMAIN window gets negative-cached).
87
+ // Runs on the CONTROL PLANE for every shape: the hook needs the Cloudflare token, which
88
+ // never leaves it. BEST-EFFORT: a failure is logged and never strands the row. A
89
+ // platform-reserved hostname is refused outright — standup would never have been given
90
+ // one, and blackholing it would take the platform's own record down.
91
+ function parkAddress(inst, { controlExec, log, apply }) {
92
+ if (!inst.domain) return;
93
+ if (isReservedHostname(inst.domain, apexMod.instanceApex())) {
94
+ log(` [dns] NOT parking ${inst.domain}: a platform-reserved hostname is never blackholed by a teardown`);
95
+ return;
96
+ }
97
+ if (!CONFIG.dnsHook) {
98
+ log(` [dns] cannot park ${inst.domain} (BOX_DNS_HOOK unset) — point its A record at 192.0.2.1 by hand`);
99
+ return;
100
+ }
101
+ const env = dnsReleaseEnv(inst.domain);
102
+ log(` [dns] park ${inst.domain} -> ${env.BOX_IP} (blackhole, zone ${env.CLOUDFLARE_ZONE}) via ${CONFIG.dnsHook}`);
103
+ if (!apply) return;
104
+ try { controlExec(CONFIG.dnsHook, { env }); }
105
+ catch (e) { log(` ⚠ could not park ${inst.domain}: ${(e && e.message) || e} — its A record still points at the platform`); }
106
+ }
107
+
108
+ module.exports = { teardownInstance };
@@ -54,6 +54,8 @@ const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbN
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
56
  const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
57
+ const { teardownInstance } = require('./provision-teardown.js');
58
+ const { disconnectInstance } = require('./provision-disconnect.js');
57
59
 
58
60
  function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
59
61
  // `shown` (task 1003140): what to LOG in place of the command text — for a command
@@ -1017,69 +1019,6 @@ async function restartInstance(inst, deps) {
1017
1019
  return { ok: true, healthy };
1018
1020
  }
1019
1021
 
1020
- // ---------------------------------------------------------------------------
1021
- // Teardown one instance (reverse the runbook; idempotent).
1022
- // ---------------------------------------------------------------------------
1023
- async function teardownInstance(inst, deps) {
1024
- const { db, provisioning, exec, log, apply, privileged } = deps;
1025
- // Tear down on the box the instance actually RAN on: a standalone ran control-plane-
1026
- // local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
1027
- // box, where it was never installed (that would allowFail-noop and leave the real
1028
- // control-plane unit running). Co-tenant + dedicated are unchanged.
1029
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
1030
- const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
1031
- log(`teardown ${inst.slug} (status=${inst.status})`);
1032
- if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
1033
- if (inst.hosting_shape !== 'dedicated') {
1034
- boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
1035
- boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
1036
- // Stop + remove the nightly backup units too (task 2213; the dir + dumps stay — precious data, like the DB below), then reset-failed (task 1003940): a removed unit's FAILED state outlives its file, so `systemctl --failed` listed torn-down ghosts for weeks.
1037
- boxExec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
1038
- boxExec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
1039
- boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
1040
- boxExec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
1041
- // Left for an operator (data is precious), naming ONLY what this row created —
1042
- // teardown now takes 'requested'/'error'. `port` + `domain` stay held: ADR 0181.
1043
- const dumps = inst.db_name ? [`dropdb ${dbName(inst)}`, `prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz`] : [];
1044
- log(` (left for the operator: ${dumps.concat([`remove the Caddy block for ${inst.slug} if one was written`]).join(', ')})`);
1045
- } else if (apply && deps.doApi && inst.droplet_id) {
1046
- await deps.doApi.deleteDroplet(inst.droplet_id);
1047
- }
1048
- // Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
1049
- // checkout itself is deliberately left in place (data is precious), so the key
1050
- // files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
1051
- // silently keeping durable read access to the owner's private repo is the exact
1052
- // shape a teardown exists to end. A missing token / API hiccup never blocks it.
1053
- if (inst.hosting_shape === 'standalone' && inst.target_ref) {
1054
- const repo = parseTargetRef(inst.target_ref);
1055
- if (repo) {
1056
- try {
1057
- const token = await resolveOwnerGithubToken(inst, deps, { tag: '[teardown]' });
1058
- if (token) {
1059
- const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
1060
- if (apply) {
1061
- const keys = await ghRepos.listDeployKeys(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
1062
- const ours = keys.find((k) => k.title === deployKeyTitle(inst));
1063
- if (ours) {
1064
- await ghRepos.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
1065
- log(` revoked the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name}`);
1066
- }
1067
- } else log(` would revoke the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name} if present`);
1068
- } else {
1069
- log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
1070
- }
1071
- } catch (e) { log(` (deploy-key revoke skipped: ${e && e.message})`); }
1072
- boxExec(`rm -f ${deployKeyPath(inst)} ${deployKeyPath(inst)}.pub`, { allowFail: true });
1073
- }
1074
- }
1075
- if (apply) {
1076
- await provisioning.setInstanceStatus(db, inst.id, 'torn_down', { torn_down_at: new Date().toISOString() });
1077
- await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'torn_down', detail: 'torn down by runner', actor: 'runner' });
1078
- }
1079
- log(` ✓ ${inst.slug} torn down`);
1080
- return { ok: true };
1081
- }
1082
-
1083
1022
  // ---------------------------------------------------------------------------
1084
1023
  // GitHub App Manifest conversion + cred placement (task 2080, ADR 0133).
1085
1024
  // The web tier stored GitHub's temporary `code` on a provisioning_oauth_manifests
@@ -1298,6 +1237,7 @@ async function cmdRunIntents(deps) {
1298
1237
  else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
1299
1238
  else if (intent.action === 'settings-apply') result = await settingsApplyInstance(inst, deps);
1300
1239
  else if (intent.action === 'restart') result = await restartInstance(inst, deps);
1240
+ else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
1301
1241
  else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
1302
1242
  if (result && result.ok === false) {
1303
1243
  if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
@@ -1425,8 +1365,8 @@ async function main() {
1425
1365
  const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
1426
1366
  if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
1427
1367
 
1428
- let pool, provisioning, DoApi, platformIdentity, hubKeys;
1429
- try { ({ pool, provisioning, DoApi, platformIdentity, hubKeys } = loadDeps()); }
1368
+ let pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog;
1369
+ try { ({ pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog } = loadDeps()); }
1430
1370
  catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
1431
1371
 
1432
1372
  // Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
@@ -1446,6 +1386,7 @@ async function main() {
1446
1386
  // Cross-site identity (ADR 0141 §4). Null on a non-hub control plane → federation
1447
1387
  // dormant; shouldFederate() then skips and the instance owns its auth (as today).
1448
1388
  platformIdentity, hubKeys,
1389
+ projectCatalog, // the disconnect leg's catalog forget (task 1003249); null off the hub
1449
1390
  };
1450
1391
 
1451
1392
  let result;
@@ -0,0 +1,273 @@
1
+ // scripts/gds/seam-wiring-guard.js — Check 33: a declared contributes.<seam> that
2
+ // nothing consumes (task 1003819; successor to abandoned task 1002464).
3
+ //
4
+ // THE GAP THIS CLOSES. A module declares what it contributes in module.json.
5
+ // src/module-seams.js `verifyPortsSatisfied` checks the OPPOSITE direction — every
6
+ // `consumes` port has exactly one provider among the enabled modules — and
7
+ // src/module-loader/manifest-schema.js `validateManifest` checks the SHAPE of
8
+ // `contributes` (a closed key set, so a typo'd KEY errors). Neither asks whether a
9
+ // declared entry is actually WIRED. So a module can declare `contributes.pollers:
10
+ // ["ghost"]` with no modules/<key>/pollers/ghost.js and boot stays silent: the
11
+ // loader's startModulePollers treats a missing poller file as declarative-only and
12
+ // skips it, hallWidgetScripts skips a uiSection with no hall-widget.js, and
13
+ // moduleWebSurfaces skips a surface whose dir was never shipped. Every one of those
14
+ // skips is correct behaviour for a REAL declarative-only entry, and indistinguishable
15
+ // from a typo or a rename that left the manifest behind.
16
+ //
17
+ // WHAT "CONSUMED" MEANS. Each seam has one consumer in the tree, and each consumer
18
+ // looks for one artifact at a fixed path. SEAM_CONSUMERS below records that pairing —
19
+ // seam → the artifact its consumer resolves — so this guard asks the same question
20
+ // the consumer asks rather than inventing a second convention that could drift from
21
+ // it. A seam with NO consumer anywhere carries `consumer: null`, and every
22
+ // declaration of it is reported: that is the strongest form of the defect.
23
+ //
24
+ // WHY A CI GATE AND NOT A BOOT REFUSAL (ADR 0289). `verifyEnabledPorts` throws at
25
+ // boot because an unprovided port otherwise becomes a runtime throw on a request
26
+ // path, in a process that reported healthy — refusing early is strictly safer. For
27
+ // every seam BUT `routes`, an unconsumed contribution is not that shape: the consumer
28
+ // skips it gracefully, nothing crashes later, and the instance runs correctly minus
29
+ // one feature. Adding a production start-up failure for a manifest-hygiene defect
30
+ // would trade a real outage risk for a cosmetic one.
31
+ //
32
+ // `routes` is the exception and it argues the SAME way: mountModuleRoutes `require`s
33
+ // the factory with no try/catch, so a missing route file already takes the boot down
34
+ // today. There the guard is not a new gate at all — it moves an existing crash from
35
+ // production start-up to the build, which is strictly earlier and strictly cheaper.
36
+ // So CI covers both cases: the seams that fail silently get a voice, and the one that
37
+ // fails loudly gets to fail before it reaches a server.
38
+ //
39
+ // Exported for scripts/gds/fitness.js (checkSeamWiring) and for the unit suite, which
40
+ // drives scanSeamWiring() over synthetic manifests with an injected `exists` so every
41
+ // case is hermetic — the scanScopeMapSource / scanDeterministicBody precedent, where
42
+ // the pure scanner is the tested surface.
43
+
44
+ 'use strict';
45
+
46
+ const fs = require('node:fs');
47
+ const path = require('node:path');
48
+ // The SAME enumerator + labeller validateManifest uses for the exemption cross-check,
49
+ // imported rather than re-declared: two copies would drift, and the drift would land
50
+ // on whether an exemption is honoured (task 1003819).
51
+ const { declaredEntries, entryLabel } = require('../../src/module-loader/manifest-schema.js');
52
+
53
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
54
+
55
+ // Is `child` inside `parent`? Both are repo-relative-or-absolute; normalised before
56
+ // comparing so a '..' segment cannot pass by spelling.
57
+ function within(parent, child) {
58
+ const a = path.resolve(REPO_ROOT, parent);
59
+ const b = path.resolve(REPO_ROOT, child);
60
+ const r = path.relative(a, b);
61
+ return r !== '' && !r.startsWith('..') && !path.isAbsolute(r);
62
+ }
63
+
64
+ // A repo-relative, forward-slashed path for an error message.
65
+ function rel(p) {
66
+ if (!p) return '(no artifact path)';
67
+ const r = path.relative(REPO_ROOT, path.isAbsolute(p) ? p : path.join(REPO_ROOT, p));
68
+ return r.split(path.sep).join('/');
69
+ }
70
+
71
+ // seam → how its consumer resolves a declared entry to an on-disk artifact.
72
+ // consumer the function that actually reads it, named so a reader can go check
73
+ // kind 'file' | 'dir' | 'registry' — what must exist
74
+ // artifact (moduleDir, entry) → path, or null when the seam is per-MODULE rather
75
+ // than per-entry (uiSections ships ONE widget however many it names)
76
+ // alternates optional extra paths that also count as wired
77
+ const SEAM_CONSUMERS = {
78
+ routes: {
79
+ consumer: 'src/module-loader/loader.js mountModuleRoutes',
80
+ kind: 'file',
81
+ artifact: (dir, entry) => path.join(dir, 'routes', `${entry}.js`),
82
+ },
83
+ pollers: {
84
+ consumer: 'src/module-loader/loader.js startModulePollers',
85
+ kind: 'file',
86
+ artifact: (dir, entry) => path.join(dir, 'pollers', `${entry}.js`),
87
+ },
88
+ rooms: {
89
+ // Allowed by the manifest schema since the doorway, but nothing in core reads
90
+ // contributes.rooms — the realtime seam was never wired to the loader. No module
91
+ // declares it today; if one ever does, this guard says so rather than letting the
92
+ // declaration read as though it did something.
93
+ consumer: null,
94
+ kind: 'file',
95
+ artifact: (dir, entry) => path.join(dir, 'rooms', `${entry}.js`),
96
+ },
97
+ uiSections: {
98
+ // Per-MODULE, not per-entry: hallWidgetScripts looks for one hall-widget.js
99
+ // however many sections the module names.
100
+ consumer: 'src/module-loader/loader.js hallWidgetScripts',
101
+ kind: 'file',
102
+ artifact: (dir) => path.join(dir, 'public', 'hall-widget.js'),
103
+ },
104
+ skills: {
105
+ // moduleSkillSources resolves a declared name against the module's OWN tree or
106
+ // its vendor/ subdir. A name that exists only in the core .claude/skills/ is
107
+ // skipped by it (`if (!src) continue`), so it is unconsumed BY THE MANIFEST —
108
+ // a legitimate declarative-only case, and what ui-design's exemption records.
109
+ //
110
+ // The artifact is the SKILL.md, not the directory: moduleSkillSources tests
111
+ // `fs.existsSync(path.join(p, 'SKILL.md'))`, so a skills/<name>/ dir without one
112
+ // is NOT a skill to it. Probing the bare directory would call such an entry wired
113
+ // while the real consumer skipped it — this guard's own defect, on the seam with
114
+ // the most declared entries.
115
+ consumer: 'scripts/gds/claude-materialize.js moduleSkillSources',
116
+ kind: 'file',
117
+ artifact: (dir, entry) => path.join(dir, 'skills', entry, 'SKILL.md'),
118
+ alternates: (dir, entry) => [path.join(dir, 'skills', 'vendor', entry, 'SKILL.md')],
119
+ },
120
+ disciplines: {
121
+ consumer: 'scripts/gds/discipline-modes.json (read by src/modules.js + claim.js)',
122
+ kind: 'registry',
123
+ artifact: () => null,
124
+ },
125
+ webSurfaces: {
126
+ consumer: 'src/module-loader/loader.js moduleWebSurfaces',
127
+ kind: 'dir',
128
+ // `entry` is the surface OBJECT here, not a string.
129
+ artifact: (dir, entry) => (entry && typeof entry.dir === 'string' ? path.join(dir, entry.dir) : null),
130
+ },
131
+ migrations: {
132
+ consumer: 'scripts/migrate.sh (the module-migration runner)',
133
+ kind: 'dir',
134
+ artifact: (dir) => path.join(dir, 'migrations'),
135
+ },
136
+ };
137
+
138
+ // Default existence probe — `kind` decides file-vs-directory. A relative path is
139
+ // resolved against the repo root so a manifest's `dir` may be either.
140
+ function fsExists(p, kind) {
141
+ try {
142
+ const st = fs.statSync(path.isAbsolute(p) ? p : path.join(REPO_ROOT, p));
143
+ return kind === 'dir' ? st.isDirectory() : st.isFile();
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ // THE SCANNER. Pure over its inputs (the only I/O is the injected `exists`), so the
150
+ // unit suite drives every case with synthetic manifests and no tmp dirs.
151
+ //
152
+ // modules [{ key, dir, manifest }] — the loader's `discovered().loaded` shape
153
+ // exists (path, kind) => boolean — defaults to the real filesystem
154
+ // registryKeys Set of valid `disciplines` entries, or null to skip that seam
155
+ //
156
+ // Returns { ok, findings[], wired[], declarative[] }. `wired` and `declarative` are
157
+ // populated on the SUCCESS path too, deliberately: a guard that reports nothing when
158
+ // it passes leaves no evidence it ran, so the caller prints the counts either way.
159
+ function scanSeamWiring(modules = [], { exists = fsExists, registryKeys = null } = {}) {
160
+ const findings = [];
161
+ const wired = [];
162
+ const declarative = [];
163
+
164
+ for (const { key, dir, manifest } of modules) {
165
+ const contributes = (manifest && manifest.contributes) || {};
166
+ const exempt = (manifest && manifest.declarativeSeams) || {};
167
+ const isExempt = (seam, label) => Array.isArray(exempt[seam]) && exempt[seam].includes(label);
168
+
169
+ for (const [seam, value] of Object.entries(contributes)) {
170
+ const spec = SEAM_CONSUMERS[seam];
171
+ if (!spec) continue; // an unknown seam key is validateManifest's error, not ours
172
+
173
+ for (const entry of declaredEntries(seam, value)) {
174
+ const label = entryLabel(seam, entry);
175
+
176
+ if (spec.consumer === null) {
177
+ findings.push(`module "${key}" declares contributes.${seam}: "${label}", but NOTHING in the tree consumes the ${seam} seam at all — the declaration does nothing`);
178
+ continue;
179
+ }
180
+
181
+ // A 'registry' seam is satisfied by membership in a roster, not by a file.
182
+ if (spec.kind === 'registry') {
183
+ if (registryKeys && !registryKeys.has(label)) {
184
+ findings.push(`module "${key}" declares contributes.${seam}: "${label}", but ${spec.consumer} has no such entry`);
185
+ } else {
186
+ wired.push({ key, seam, entry: label, via: spec.consumer });
187
+ }
188
+ continue;
189
+ }
190
+
191
+ const primary = spec.artifact(dir, entry);
192
+ const candidates = [primary, ...(spec.alternates ? spec.alternates(dir, entry) : [])].filter(Boolean);
193
+ // An entry carrying path separators or '..' would aim the probe outside
194
+ // modules/<key>/. It is only an existence test over repo-committed content, so
195
+ // the blast radius is an on/off answer — but a declaration that reaches out of
196
+ // its own module is a finding in its own right, not something to resolve.
197
+ const escapes = candidates.find((p) => !within(dir, p));
198
+ if (escapes) {
199
+ findings.push(`module "${key}" declares contributes.${seam}: "${label}", which resolves to ${rel(escapes)} — outside the module's own directory. A contributes entry names something the module ships, so it may not contain a path separator or ".."`);
200
+ continue;
201
+ }
202
+ const found = candidates.some((p) => exists(p, spec.kind));
203
+
204
+ if (found) {
205
+ // A STALE exemption is the same rot in the other direction: the artifact
206
+ // exists now, so the entry IS wired and the exemption reads as though a
207
+ // real gap were sanctioned. Fail it rather than let the list quietly lie.
208
+ if (isExempt(seam, label)) {
209
+ findings.push(`module "${key}" exempts ${seam} "${label}" as declarative-only, but ${rel(primary)} exists and ${spec.consumer} wires it — drop the stale declarativeSeams.${seam} entry`);
210
+ } else {
211
+ wired.push({ key, seam, entry: label, via: spec.consumer });
212
+ }
213
+ continue;
214
+ }
215
+
216
+ if (isExempt(seam, label)) {
217
+ declarative.push({ key, seam, entry: label });
218
+ continue;
219
+ }
220
+
221
+ findings.push(`module "${key}" declares contributes.${seam}: "${label}", but ${rel(primary)} does not exist — ${spec.consumer} silently skips it. Ship the artifact, drop the declaration, or record it in declarativeSeams.${seam} if it is declarative-only on purpose`);
222
+ }
223
+ }
224
+ }
225
+
226
+ return { ok: findings.length === 0, findings, wired, declarative };
227
+ }
228
+
229
+ // The roster `contributes.disciplines` is checked against. Returns null when the file
230
+ // is unreadable, which makes the disciplines seam UNCHECKED rather than failed — a
231
+ // missing registry is its own check's problem (fitness.js role packs), not this one's.
232
+ function disciplineKeys() {
233
+ try {
234
+ const raw = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'discipline-modes.json'), 'utf8'));
235
+ return new Set(Object.keys(raw.modes || {}));
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ // The fitness-check wrapper. Discovers every module.json through the loader — the same
242
+ // discovery the running server uses, so the guard cannot check a different module set
243
+ // than the one that boots.
244
+ function checkSeamWiring() {
245
+ const name = 'declared module seams are consumed (task 1003819)';
246
+ let modules = [];
247
+ try {
248
+ modules = require('../../src/module-loader/loader.js').discoverModules().loaded;
249
+ } catch (e) {
250
+ return {
251
+ name,
252
+ ok: true,
253
+ hardFail: false,
254
+ violations: [],
255
+ warnings: [`module discovery failed: ${e && e.message ? e.message : e}`],
256
+ note: 'module discovery unavailable — skipped.',
257
+ };
258
+ }
259
+
260
+ const { ok, findings, wired, declarative } = scanSeamWiring(modules, { registryKeys: disciplineKeys() });
261
+ const seams = [...new Set(wired.map((w) => w.seam))].sort();
262
+ return {
263
+ name,
264
+ ok,
265
+ hardFail: !ok,
266
+ violations: findings,
267
+ warnings: [],
268
+ // Printed on the PASS path too — the wired set is the evidence the guard ran.
269
+ note: `${modules.length} module(s): ${wired.length} declared seam entr(ies) WIRED across ${seams.length} seam(s) (${seams.join(', ') || 'none'}), ${declarative.length} recorded declarative-only. A declared contributes.<seam> whose consumer's artifact is absent is a silent no-op (ADR 0289).`,
270
+ };
271
+ }
272
+
273
+ module.exports = { checkSeamWiring, scanSeamWiring, SEAM_CONSUMERS, declaredEntries, entryLabel, disciplineKeys };