@bongos/core 1.19.772 → 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 (97) hide show
  1. package/.bongos-core.json +204 -79
  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 +4 -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/db-tasks.js +7 -1
  30. package/modules/lifecycle/dead-deps.js +10 -2
  31. package/modules/lifecycle/ship-card.js +49 -13
  32. package/modules/lifecycle/task-list-limits.js +44 -0
  33. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  34. package/modules/platform-identity/platform-identity.js +8 -19
  35. package/modules/platform-identity/project-catalog.js +24 -0
  36. package/modules/platform-identity/routes/sso.js +11 -14
  37. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  38. package/modules/provisioning/capacity-gate.js +54 -0
  39. package/modules/provisioning/catalog-bridge.js +2 -0
  40. package/modules/provisioning/disconnect.js +35 -0
  41. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  42. package/modules/provisioning/module.json +1 -1
  43. package/modules/provisioning/provisioning.js +3 -3
  44. package/modules/provisioning/routes/disconnect.js +63 -0
  45. package/modules/provisioning/routes/provisioning.js +6 -6
  46. package/modules/public-landing/public/projects.html +116 -11
  47. package/modules/public-landing/public/projects.probes.json +3 -0
  48. package/modules/ui-design/kit/serve.js +3 -2
  49. package/modules/ui-design/module.json +1 -0
  50. package/package-lock.json +2 -2
  51. package/package.json +1 -1
  52. package/scripts/gds/address-park-sweep.sh +78 -0
  53. package/scripts/gds/audit-deps.js +38 -4
  54. package/scripts/gds/cli-lib.js +37 -0
  55. package/scripts/gds/fitness-migration-contraction.js +50 -0
  56. package/scripts/gds/fitness.js +18 -0
  57. package/scripts/gds/land-watch.js +8 -2
  58. package/scripts/gds/provision-config.js +4 -4
  59. package/scripts/gds/provision-disconnect.js +90 -0
  60. package/scripts/gds/provision-teardown.js +108 -0
  61. package/scripts/gds/provision.js +6 -65
  62. package/scripts/gds/seam-wiring-guard.js +273 -0
  63. package/scripts/gds/ship-finish.js +17 -10
  64. package/scripts/gds/ship-flow.js +28 -6
  65. package/scripts/gds/ship-resume.js +32 -4
  66. package/scripts/gds/start.js +13 -7
  67. package/src/bongos/auth-admission.js +7 -8
  68. package/src/bongos/auth-config.js +3 -2
  69. package/src/bongos/goal-scope-check.js +23 -8
  70. package/src/bongos/module-scope-map.js +40 -0
  71. package/src/module-api.js +1 -1
  72. package/src/module-loader/manifest-schema.js +79 -1
  73. package/tests/catalog_only_client.mjs +8 -5
  74. package/tests/fitness.mjs +22 -0
  75. package/tests/goal_scope_check.mjs +63 -1
  76. package/tests/hall_settings_world.mjs +57 -1
  77. package/tests/hub_disconnect_ui.mjs +105 -0
  78. package/tests/land_watch.mjs +44 -0
  79. package/tests/membership_kind_lockstep.mjs +86 -27
  80. package/tests/module-scope-map.mjs +39 -0
  81. package/tests/module_exports_unique_keys.mjs +70 -0
  82. package/tests/module_manifest.mjs +85 -0
  83. package/tests/project_catalog_forget.mjs +83 -0
  84. package/tests/projects_hub.mjs +10 -4
  85. package/tests/projects_hub_pre_uat.mjs +5 -3
  86. package/tests/provision_disconnect.mjs +231 -0
  87. package/tests/provision_teardown_address.mjs +230 -0
  88. package/tests/provisioning_capacity_gate.mjs +192 -0
  89. package/tests/provisioning_disconnect.mjs +231 -0
  90. package/tests/provisioning_restart.mjs +5 -5
  91. package/tests/public_landing_projects.mjs +4 -3
  92. package/tests/render_prefs.mjs +152 -0
  93. package/tests/seam_wiring_guard.mjs +234 -0
  94. package/tests/ship_card.mjs +67 -1
  95. package/tests/ship_resume.mjs +86 -12
  96. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
  97. package/tests/task_list_paging.mjs +178 -0
@@ -0,0 +1,35 @@
1
+ // modules/provisioning/disconnect.js — the queue half of "Disconnect from Bongos
2
+ // entirely" (task 1003249, ADR 0290).
3
+ //
4
+ // A teardown keeps the database, backups and address (ADR 0181); a disconnect erases
5
+ // them and deletes the row, so the name, port and address are reusable. The web tier
6
+ // only enqueues it; scripts/gds/provision-disconnect.js is the runner leg that does it.
7
+ //
8
+ // Its own file because provisioning.js sits at the size ratchet. It reaches the queue
9
+ // through the provisioning module OBJECT, so a test can stub getOpenIntent/enqueueIntent.
10
+ 'use strict';
11
+
12
+ const provisioning = require('./provisioning');
13
+
14
+ // Queue a disconnect. Unlike a teardown it SUPERSEDES NOTHING: any other open intent —
15
+ // even a pending one — refuses it with { conflict: 'open_intent' }, because a
16
+ // destructive run must never race a standup or a teardown that is about to start.
17
+ // An open disconnect makes a repeat idempotent. The partial unique index
18
+ // ux_provisioning_intents_open stays the arbiter if two requests interleave.
19
+ async function enqueueDisconnect(db, instanceId, requestedBy) {
20
+ const open = await provisioning.getOpenIntent(db, instanceId);
21
+ if (open) return open.action === 'disconnect' ? { intent: open, created: false } : { intent: open, created: false, conflict: 'open_intent' };
22
+ const r = await provisioning.enqueueIntent(db, instanceId, 'disconnect', requestedBy);
23
+ if (r.created || r.intent.action === 'disconnect') return r;
24
+ return { ...r, conflict: 'open_intent' };
25
+ }
26
+
27
+ // Delete the provisioning row. Its intents, events and OAuth-manifest attempts go with it
28
+ // (ON DELETE CASCADE), including the running disconnect intent itself — which is why the
29
+ // runner resolving that intent afterwards is a harmless no-op. Returns the rows removed.
30
+ async function deleteInstance(db, instanceId) {
31
+ const { rowCount } = await db.query('DELETE FROM provisioning_instances WHERE id = $1', [instanceId]);
32
+ return rowCount;
33
+ }
34
+
35
+ module.exports = { enqueueDisconnect, deleteInstance };
@@ -0,0 +1,21 @@
1
+ -- provisioning_016_disconnect.sql — the disconnect intent action (task 1003249,
2
+ -- ADR 0290: "Disconnect from Bongos entirely").
3
+ --
4
+ -- WHY. A teardown deliberately keeps a project's database, backups and address
5
+ -- (ADR 0181), so "Take it offline" is always reversible. The owner asked for a
6
+ -- separate, owner-initiated true delete: tear down, drop the database and its
7
+ -- dumps, retire the catalog + membership rows, and delete the provisioning row so
8
+ -- the name, port and address are reusable. It is destructive, so it runs where
9
+ -- every destructive leg runs — on the control-plane runner, which remains the sole
10
+ -- token-holder (ADR 0111 §2). The web tier only ENQUEUES it.
11
+ --
12
+ -- Widening a CHECK enum is additive (permits MORE values; no row changes) — the
13
+ -- provisioning_003/005/010/011 precedent. Applied only when the provisioning module
14
+ -- is enabled. Safe to re-run. Keep INTENT_ACTIONS in provisioning.js in lockstep.
15
+
16
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
17
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
18
+ CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect'));
19
+
20
+ INSERT INTO schema_migrations (version) VALUES ('provisioning_016_disconnect')
21
+ ON CONFLICT (version) DO NOTHING;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Off the Boats maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["provisioning"],
13
+ "routes": ["provisioning", "disconnect"],
14
14
  "migrations": true,
15
15
  "pollers": ["liveness-sweep", "catalog-backfill"]
16
16
  },
@@ -58,7 +58,8 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
58
58
  // instance whose liveness probe says it is down — the settings-apply leg minus the
59
59
  // env patch. Its own action because no existing one fits: provision refuses
60
60
  // 'active' and settings-apply requires a value that actually changed.
61
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart'];
61
+ // 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
62
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect'];
62
63
  // Every state a teardown can be asked for — i.e. everything except the one
63
64
  // state where there is provably nothing left to release. DERIVED from
64
65
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
@@ -1489,8 +1490,7 @@ module.exports = {
1489
1490
  listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
1490
1491
  // data access — the owed-push marker (task 1003524)
1491
1492
  recordSettingsPushOwed,
1492
- createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
1493
- createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
1493
+ createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings, updateInstanceDetail,
1494
1494
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
1495
1495
  recordEvent, instanceStateForSlug, planetChoicesForHosts, projectOwnedBy, costLedger,
1496
1496
  // data access — GitHub App Manifest attempts (task 2080)
@@ -0,0 +1,63 @@
1
+ // modules/provisioning/routes/disconnect.js — POST /provisioning/instances/:id/disconnect
2
+ // (task 1003249, ADR 0290: "Disconnect from Bongos entirely").
3
+ //
4
+ // The web tier's half of the owner's true delete. Like every provisioning action it only
5
+ // ENQUEUES; the control-plane runner does the destruction (ADR 0111 §2). Its own routes
6
+ // file (declared in module.json) because routes/provisioning.js sits at the size ratchet.
7
+ 'use strict';
8
+
9
+ const express = require('express');
10
+ const api = require('../../../src/module-api');
11
+ const { pool, validateOrRespond, parseId } = api;
12
+ const provisioning = require('../provisioning');
13
+ const disconnect = require('../disconnect');
14
+
15
+ const log = api.logger('provisioning');
16
+
17
+ module.exports = function buildDisconnectRouter() {
18
+ const router = express.Router();
19
+
20
+ // POST /provisioning/instances/:id/disconnect — rank: any authenticated builder (own
21
+ // resource); an archon may disconnect any project. 404 for anyone else, so another
22
+ // owner's id is never confirmed. The typed name is checked HERE, not only in the page:
23
+ // this erases a database, so a replayed or scripted request must carry the same proof
24
+ // the button does. No status gate — any state may be disconnected; the queue refuses
25
+ // while another run is open (disconnect.enqueueDisconnect).
26
+ router.post('/provisioning/instances/:id/disconnect', api.requireBuilder, async (req, res) => {
27
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
28
+ if (!id) return;
29
+ if (validateOrRespond(req, res, { confirm_slug: { required: true, type: 'string', maxLength: 40 } })) return;
30
+ try {
31
+ const inst = await provisioning.getInstanceById(pool, id);
32
+ const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
33
+ if (!inst || (!isOwner && req.builder.rank !== 'archon')) return res.fail('instance_not_found', 404);
34
+ if (String(req.body.confirm_slug).trim().toLowerCase() !== inst.slug) {
35
+ return res.fail('confirm_mismatch', { status: 400,
36
+ message: `Type the project's name exactly — ${inst.slug} — to disconnect it. Nothing was changed.` });
37
+ }
38
+ const actor = isOwner ? 'api:self' : `api:archon:${req.builder.id}`;
39
+ const r = await disconnect.enqueueDisconnect(pool, id, actor);
40
+ if (r.conflict) {
41
+ return res.fail('intent_conflict', { status: 409,
42
+ message: `A '${r.intent.action}' run is ${r.intent.state} for this project. Disconnecting waits for it to finish — try again once it has.`,
43
+ details: { action: r.intent.action, state: r.intent.state } });
44
+ }
45
+ if (r.created) {
46
+ await provisioning.recordEvent(pool, {
47
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'disconnect',
48
+ detail: isOwner ? 'disconnect requested by owner' : `disconnect requested by archon ${req.builder.github_login || req.builder.id}`,
49
+ actor,
50
+ }).catch(() => {});
51
+ }
52
+ res.json({ ok: true, queued: r.created, action: 'disconnect', status: inst.status,
53
+ message: r.created
54
+ ? 'Queued. The platform takes the project down, erases its database and backups, and releases its name and address. It leaves your list when that is done.'
55
+ : 'A disconnect is already queued for this project.' });
56
+ } catch (err) {
57
+ log.error('[provisioning] POST /provisioning/instances/:id/disconnect', err);
58
+ res.fail('disconnect_failed', { status: 500, message: 'internal error' });
59
+ }
60
+ });
61
+
62
+ return router;
63
+ };
@@ -17,7 +17,7 @@
17
17
  // only shape that bills; ../paid-shape-gate.js.
18
18
  // An `adopt` ALSO passes the credential
19
19
  // preflight — ../credential-preflight.js,
20
- // task 1002909; greenfield is exempt)
20
+ // task 1002909; greenfield is exempt; a full box → 409, ../capacity-gate.js)
21
21
  // GET /provisioning/instances — list the caller's instances
22
22
  // GET /provisioning/instances/:id — read one of the caller's instances
23
23
  // (also SELF-HEALS its catalog projection,
@@ -51,6 +51,7 @@ const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModu
51
51
  const { recommendationsForDetails } = require('../recommendations');
52
52
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
53
53
  const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
54
+ const { requireRoomOnBox } = require('../capacity-gate'); // a full box refused up front (task 1003938)
54
55
  const { adoptCredentialFault } = require('../credential-preflight');
55
56
  const catalogBridge = require('../catalog-bridge');
56
57
  const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
@@ -299,10 +300,9 @@ module.exports = function provisioningRoutes() {
299
300
  });
300
301
 
301
302
  // POST /provisioning/instances — request a new instance. rank: any authenticated
302
- // builder (own resource); a `dedicated` (metered) shape additionally requires
303
- // provisioning.fleet.manage — see requireAuthorityForMeteredShape above.
304
- // ENQUEUES a 'provision' intent + records the request;
305
- // the runner (task P3) does the real standup. Idempotent on slug for the SAME
303
+ // builder (own resource); a `dedicated` (metered) shape also needs provisioning.fleet.manage
304
+ // (../paid-shape-gate.js), and a full shared box answers 409 box_full up front (../capacity-gate.js).
305
+ // ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME
306
306
  // owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
307
307
  // Body: { slug (required), target_ref?, hosting_shape? (co-tenant|dedicated),
308
308
  // domain?, tier?, onboard_mode? (greenfield|adopt) }.
@@ -312,7 +312,7 @@ module.exports = function provisioningRoutes() {
312
312
  // ALREADY has, so the runner must clone + layer onto its history instead of pushing a
313
313
  // fresh tree at `main` (which an existing repo rejects as unrelated history). Absent →
314
314
  // 'greenfield', the safe default.
315
- router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForMeteredShape(auth.requirePermission), async (req, res) => {
315
+ router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForMeteredShape(auth.requirePermission), requireRoomOnBox(pool, log), async (req, res) => {
316
316
  if (validateOrRespond(req, res, {
317
317
  slug: { required: true, type: 'string', maxLength: 40 },
318
318
  target_ref: { type: 'string', maxLength: 200 },
@@ -939,7 +939,21 @@ summary{min-height:24px;padding:3px 0;}
939
939
  than letting the word "Delete" imply it. -->
940
940
  <details class="finePrint" id="mDelWhat">
941
941
  <summary>What this removes — and what it keeps</summary>
942
- <p>Tearing down stops the project’s server and its nightly backups. It does <b>not</b> erase the project: it stays in your list marked <b>torn down</b> so you can bring it back, your repository on GitHub is untouched, and its database and existing backups are kept on purpose. There is no permanent-delete button — clearing a project’s database for good, and freeing its web address for another project, are done by hand today.</p>
942
+ <p>Tearing down stops the project’s server and its nightly backups. It does <b>not</b> erase the project: it stays in your list marked <b>torn down</b> so you can bring it back, your repository on GitHub is untouched, and its database and existing backups are kept on purpose. To erase it for good — its database, its backups and its web address — use <b>Disconnect from Bongos entirely</b> below.</p>
943
+ </details>
944
+ </div>
945
+ <!-- "Disconnect from Bongos entirely" (task 1003249, ADR 0290): the owner's TRUE
946
+ delete, in its own card after the ender so the reversible control never reads
947
+ as the destructive one. The fold is a sibling of the volatile slot, like
948
+ #mDelWhat, so no re-render can drop what it promises. -->
949
+ <div class="card mSec" id="mDisconnect">
950
+ <h2>Disconnect from Bongos entirely</h2>
951
+ <div id="mDisc"><p class="finePrint">Reading the project…</p></div>
952
+ <details class="finePrint" id="mDiscWhat">
953
+ <summary>What this erases — and what it keeps</summary>
954
+ <p><b>Erased for good:</b> the project’s server, its database and every nightly backup of it, its settings, its place on the map and the list of its members. Its name and web address are released, so another project can take them.</p>
955
+ <p><b>Kept:</b> your repository on GitHub, exactly as it is. If a GitHub sign-in app was registered for the project, that stays on your GitHub account too — remove it in your GitHub settings if you no longer want it.</p>
956
+ <p>This cannot be undone. To stop the project and keep everything, use <b>Take it offline</b> above instead.</p>
943
957
  </details>
944
958
  </div>
945
959
  </section>
@@ -5196,21 +5210,19 @@ summary{min-height:24px;padding:3px 0;}
5196
5210
  retention is deliberate on shared boxes; a dedicated machine takes its
5197
5211
  database with it). Copy follows the runner, never the other way around. */
5198
5212
  function teardownTruth(inst) {
5199
- /* the address line is NOT decoration: teardown has no DNS or Caddy leg, so
5200
- the hostname keeps resolving and serves an error page rather than going
5201
- quiet. The old copy promised the address goes silent, which was simply
5202
- untrue — and the rule above is that copy follows the runner. Releasing the
5203
- address is tracked separately (task 1002897); until then "reserved" is
5204
- both the honest word and the accurate one, since a revive re-uses exactly
5205
- this hostname. */
5213
+ /* the address line is NOT decoration. Teardown now parks the hostname at a
5214
+ dead address and removes its proxy block (task 1002897), so nothing answers
5215
+ there — but the row keeps the address, because a revive re-uses exactly
5216
+ this hostname. "Reserved" is the accurate word for that; "gone" would not be,
5217
+ and neither is the old error-page promise, true only before the park. */
5206
5218
  /* pre-standup: at 'requested' nothing has been built, so the shape arms
5207
5219
  below would describe a server and a database that never existed. error
5208
5220
  and provisioning keep the built-world text on purpose — a run that failed
5209
5221
  partway may already have a database, a port and a DNS record. */
5210
5222
  if (inst && inst.status === 'requested') return 'Nothing has been stood up yet — this cancels the standup. Anything already on GitHub is untouched, and the name and web address stay reserved for this project.';
5211
5223
  return inst && inst.hosting_shape === 'dedicated'
5212
- ? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched. Its web address stays reserved for this project and shows an error page until you bring it back. Nothing here erases a project.'
5213
- : 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project and shows an error page until you bring it back. Nothing here erases a project.';
5224
+ ? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched. Its web address stays reserved for this project, and nothing answers there until you bring it back. Taking it offline erases nothing.'
5225
+ : 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project, and nothing answers there until you bring it back. Taking it offline erases nothing.';
5214
5226
  }
5215
5227
 
5216
5228
  /* onManage: the same facts, but the pointers name the controls THIS surface
@@ -5224,7 +5236,7 @@ summary{min-height:24px;padding:3px 0;}
5224
5236
  "nothing more will happen" (task 1003232) */
5225
5237
  if (inst.status === 'torn_down' || inst.status === 'tearing_down') {
5226
5238
  if (!inst.domain) return '';
5227
- return 'Its address, <code>' + esc(inst.domain) + '</code>, stays reserved for this project — it shows an error page until you bring it back.';
5239
+ return 'Its address, <code>' + esc(inst.domain) + '</code>, stays reserved for this project — nothing answers there until you bring it back.';
5228
5240
  }
5229
5241
  if (inst.domain) {
5230
5242
  if (inst.status === 'active') {
@@ -5553,6 +5565,7 @@ summary{min-height:24px;padding:3px 0;}
5553
5565
  it: the delete section reads only status + open_intent, both already in
5554
5566
  hand, and a queued teardown must show up without a reload */
5555
5567
  renderDelete(inst);
5568
+ renderDisconnect(inst);
5556
5569
  /* its address (task 1003355) is fetch-free too — the control it carries
5557
5570
  reads status + domain + open_intent, all already in hand */
5558
5571
  renderAddress(inst);
@@ -7026,6 +7039,12 @@ summary{min-height:24px;padding:3px 0;}
7026
7039
  '<p class="finePrint">This page updates when it starts.</p>');
7027
7040
  return;
7028
7041
  }
7042
+ /* a queued disconnect takes the project offline itself; a teardown ask here would
7043
+ only be refused by the server's open-run check (task 1003249) */
7044
+ if (inst.open_intent && inst.open_intent.action === 'disconnect') {
7045
+ delMsg('<p class="finePrint">A disconnect is queued for this project — taking it offline is part of it.</p>');
7046
+ return;
7047
+ }
7029
7048
  /* a project that never finished standing up still reaches here — that is
7030
7049
  the whole point of the widened gate; teardownTruth() scopes the copy to
7031
7050
  its status */
@@ -7116,6 +7135,92 @@ summary{min-height:24px;padding:3px 0;}
7116
7135
  if (t.hasAttribute('data-del-no')) { mDelAsking = null; if (manageInst) renderDelete(manageInst); }
7117
7136
  });
7118
7137
 
7138
+ /* ── "Disconnect from Bongos entirely" (task 1003249, ADR 0290): the TRUE delete. The
7139
+ ender's shape on purpose — the name typed back (and checked again by the SERVER, which
7140
+ receives it as confirm_slug), a project-scoped guard so the minute tick never repaints
7141
+ over a half-typed name, and every refusal in the server's own words. ── */
7142
+ var mDiscAsking = null;
7143
+
7144
+ function discMsg(html) { $('mDisc').innerHTML = html; }
7145
+
7146
+ function renderDisconnect(inst) {
7147
+ if (mDiscAsking && mDiscAsking === inst.slug) return;
7148
+ var open = inst.open_intent;
7149
+ if (open && open.action === 'disconnect') {
7150
+ discMsg('<div class="addr">This project is being disconnected.</div>' +
7151
+ '<p class="finePrint">When it finishes, the project leaves your list and this page can no longer find it.</p>');
7152
+ return;
7153
+ }
7154
+ if (open) {
7155
+ discMsg('<p class="plain">Disconnecting waits for the current ' + esc(open.action || '') + ' run to finish.</p>');
7156
+ return;
7157
+ }
7158
+ discMsg('<p class="plain">Erase this project from the platform for good: its database and backups, its settings and its place on the map. Your repository on GitHub is kept.</p>' +
7159
+ '<div class="actions"><button type="button" class="btn small danger" data-disc-ask>Disconnect entirely</button></div>');
7160
+ }
7161
+
7162
+ function askDisconnect() {
7163
+ var inst = manageInst;
7164
+ if (!inst) return;
7165
+ mDiscAsking = inst.slug;
7166
+ discMsg('<div class="confirmRow"><span class="plain">Disconnect <b>' + esc(inst.slug) + '</b> from Bongos entirely? ' +
7167
+ 'Its database and backups are erased and this cannot be undone. Type the project’s name to confirm.</span></div>' +
7168
+ '<div class="attach">' +
7169
+ '<input type="text" id="mDiscName" autocomplete="off" spellcheck="false" maxlength="40" ' +
7170
+ 'aria-label="Type the project name to confirm the disconnect" placeholder="' + esc(inst.slug) + '">' +
7171
+ '<button type="button" class="btn small danger" data-disc-yes disabled>Yes, disconnect it</button>' +
7172
+ '<button type="button" class="linklike" data-disc-no>Keep it</button></div>');
7173
+ var field = $('mDiscName');
7174
+ if (field) field.focus();
7175
+ }
7176
+
7177
+ function discTypedMatches() {
7178
+ var field = $('mDiscName');
7179
+ return !!(field && manageInst && field.value.trim() === manageInst.slug);
7180
+ }
7181
+
7182
+ function doDisconnect() {
7183
+ var inst = manageInst;
7184
+ if (!inst || !discTypedMatches()) return;
7185
+ var typed = $('mDiscName').value.trim();
7186
+ mDiscAsking = null;
7187
+ discMsg('<p class="finePrint">Asking…</p>');
7188
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(inst.id) + '/disconnect', {
7189
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
7190
+ body: JSON.stringify({ confirm_slug: typed })
7191
+ }).then(function (res) {
7192
+ return jsonOrEmpty(res).then(function (data) {
7193
+ if (res.ok) {
7194
+ discMsg('<div class="projMsg ok">' + esc(data.message || 'Disconnect requested.') + '</div>');
7195
+ loadManage(true); /* the queued run this page renders just changed */
7196
+ return;
7197
+ }
7198
+ renderDisconnect(inst);
7199
+ var e = apiError(data, res.status);
7200
+ $('mDisc').insertAdjacentHTML('beforeend', '<div class="projMsg err">' +
7201
+ (res.status === 401 ? signInAgain() : esc(e.message || 'The disconnect could not be queued — try again.')) + '</div>');
7202
+ });
7203
+ }).catch(function () {
7204
+ renderDisconnect(inst);
7205
+ $('mDisc').insertAdjacentHTML('beforeend',
7206
+ '<div class="projMsg err">Couldn’t reach the platform — check your connection and try again.</div>');
7207
+ });
7208
+ }
7209
+
7210
+ $('mDisc').addEventListener('input', function (ev) {
7211
+ if (!ev.target || ev.target.id !== 'mDiscName') return;
7212
+ var yes = $('mDisc').querySelector('[data-disc-yes]');
7213
+ if (yes) yes.disabled = !discTypedMatches();
7214
+ });
7215
+
7216
+ $('mDisc').addEventListener('click', function (ev) {
7217
+ var t = ev.target.closest && ev.target.closest('button');
7218
+ if (!t) return;
7219
+ if (t.hasAttribute('data-disc-ask')) return askDisconnect();
7220
+ if (t.hasAttribute('data-disc-yes')) return doDisconnect();
7221
+ if (t.hasAttribute('data-disc-no')) { mDiscAsking = null; if (manageInst) renderDisconnect(manageInst); }
7222
+ });
7223
+
7119
7224
  /* ── restart an unreachable project (task 1003238). Its own route, because
7120
7225
  PROVISION_ENQUEUE_FROM excludes 'active' — re-POSTing the slug the way
7121
7226
  Try again / Bring it back do would be refused on every press. Every
@@ -155,6 +155,9 @@
155
155
  { "name": "manage flows: the ender asks for the name typed back, queues the teardown, and the page re-reads the project as queued", "auth": true, "width": 1440, "url": "/projects?view=manage&id=105",
156
156
  "steps": [["wait",900],["click","[data-del-ask]"],["wait",200],["fill","#mDelName","mooring"],["wait",100],["click","[data-del-yes]"],["wait",900]],
157
157
  "expect": {"text":[["#mDel .addr","A teardown is queued for this project."],["#mName","mooring"]],"hidden":["#mDelName","[data-del-ask]"],"visible":["#mStrip .strip.flowDown"]} },
158
+ { "name": "manage flows: disconnect entirely asks for the name typed back, queues the true delete, and the page re-reads the project as being disconnected (task 1003249)", "auth": true, "width": 1440, "url": "/projects?view=manage&id=104",
159
+ "steps": [["wait",900],["click","[data-disc-ask]"],["wait",200],["fill","#mDiscName","old-lantern"],["wait",100],["click","[data-disc-yes]"],["wait",900]],
160
+ "expect": {"text":[["#mDisc .addr","This project is being disconnected."],["#mName","old-lantern"]],"hidden":["#mDiscName","[data-disc-ask]"]} },
158
161
  { "name": "the wizard driver (task 1003346): name, type, about, home (fresh; the stub grants repo access, so the new repository is named from the project), address (the handle's verdict), the review as a ledger, then the filing — the done panel with the standup rail and the human steps", "auth": true, "width": 1440, "url": "/projects?view=new",
159
162
  "steps": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500]],
160
163
  "expect": {"visible":["#panel-done","#liveProgress .prog","#liveProgress .prog .stages li.now","#humanSteps li"],"hidden":["#panel-review","#panel-name"],"text":[["#done-h","The drums are rolling"]],"attr":[["#panel-done","class","panel on"]]} },
@@ -157,6 +157,7 @@ function stamp(v) {
157
157
  const FLOW_RECEIPTS = {
158
158
  teardown: { queued: true, message: 'Teardown queued.' },
159
159
  restart: { queued: true, message: 'Restart queued.' },
160
+ disconnect: { queued: true, message: 'Queued. The platform takes the project down, erases its database and backups, and releases its name and address. It leaves your list when that is done.' },
160
161
  domain: { recorded: true, queued: true, message: 'Address recorded.' },
161
162
  // the wizard's filing (task 1003346): the receipt names the instance the page then polls — Harbor, the fixture's standing-up row — and the address the page promises is the one the server assigned
162
163
  provision: { queued: true, message: 'Queued. The platform picks it up from here.', instance: { id: 102, domain: 'mercury.cloudbongos.com' } },
@@ -220,10 +221,10 @@ function withPlanet(inst) {
220
221
  }
221
222
  function flowReceipt(rest, method) {
222
223
  if (method === 'POST' && rest === '/provisioning/instances') return FLOW_RECEIPTS.provision;
223
- const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain)$/);
224
+ const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain|disconnect)$/);
224
225
  if (!m) return null;
225
226
  if (method !== 'POST') return null;
226
- if (m[2] === 'teardown' || m[2] === 'restart') ASKED.set(m[1], m[2]);
227
+ if (m[2] === 'teardown' || m[2] === 'restart' || m[2] === 'disconnect') ASKED.set(m[1], m[2]);
227
228
  return FLOW_RECEIPTS[m[2]];
228
229
  }
229
230
  function withAsk(inst) {
@@ -6,6 +6,7 @@
6
6
  "default": true,
7
7
  "maintenance": { "status": "core-maintained" },
8
8
  "contributes": { "skills": ["design", "design-sync", "figma-design-sync", "design-taste-frontend", "design-taste-frontend-v1", "high-end-visual-design", "redesign-existing-projects", "impeccable", "style", "minimalist-ui", "industrial-brutalist-ui", "gpt-taste", "stitch-design-taste", "image-to-code", "imagegen-frontend-web", "imagegen-frontend-mobile", "brandkit"], "disciplines": ["ui"] },
9
+ "declarativeSeams": { "skills": ["design", "design-sync", "figma-design-sync"] },
9
10
  "provides": [],
10
11
  "consumes": []
11
12
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.772",
3
+ "version": "1.19.774",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.772",
9
+ "version": "1.19.774",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.772",
3
+ "version": "1.19.774",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/gds/address-park-sweep.sh — park the addresses of projects torn down BEFORE
3
+ # teardown learned to do it itself (task 1003947, the backfill half of task 1002897).
4
+ #
5
+ # WHY. Task 1002897 made a teardown remove its <slug>.caddy block and park its hostname
6
+ # at the RFC 5737 blackhole. That only applies to teardowns run after the pin carries it,
7
+ # so every earlier row still has its Caddy block proxying to a dead port and its A record
8
+ # pointing at the platform: eleven hostnames answered 502 on 2026-09-16, while the hub
9
+ # copy says "nothing answers there". This is that same teardown step, run once over the
10
+ # ledger's torn-down rows. It does not re-run `provision.js teardown <slug>`, which would
11
+ # rewrite each row's torn_down_at.
12
+ #
13
+ # The reserved-hostname rule, the zone and the apex come from the INSTALLED core (the
14
+ # same helpers a teardown calls), so this sweep cannot disagree with what teardown parks.
15
+ #
16
+ # Run on the control plane as a sudoer:
17
+ # bash scripts/gds/address-park-sweep.sh # survey only — read-only
18
+ # bash scripts/gds/address-park-sweep.sh --apply # remove the snippets, reload Caddy, park the hostnames
19
+ #
20
+ # Left in place on purpose, even with --apply: each project's database, dumps and checkout,
21
+ # and the row's domain + port (ADR 0181 — a revive re-uses that address).
22
+ set -uo pipefail
23
+
24
+ APPLY=0; [ "${1:-}" = "--apply" ] && APPLY=1
25
+ DB="${PGDATABASE:-cloudbongos}"
26
+ INSTANCE_DIR="${INSTANCE_DIR:-/home/lars/cloudbongos-instance}"
27
+ SITES="${PROVISION_CADDY_SNIPPET_DIR:-/etc/caddy/sites}"
28
+ BOX_ENV="${BOX_ENV:-/etc/cloudbongos/box.env}"
29
+
30
+ rows=$(sudo -n -u lars psql -d "$DB" -tAF '|' -c "select slug, coalesce(domain, '') from provisioning_instances where status='torn_down' order by slug" 2>/dev/null) \
31
+ || { echo "cannot read provisioning_instances from $DB as lars"; exit 1; }
32
+
33
+ # slug|domain|zone|reserved|blackhole per row, plus an apex line — from the installed core.
34
+ plan=$(sudo -n -u lars env ROWS="$rows" bash -c 'cd "$1" && exec node -' _ "$INSTANCE_DIR" <<'JS'
35
+ const core = './node_modules/@bongos/core';
36
+ const { isReservedHostname } = require(`${core}/modules/provisioning/provisioning.js`);
37
+ const { dnsReleaseEnv } = require(`${core}/scripts/gds/provision-net.js`);
38
+ const apex = require(`${core}/scripts/gds/instance-apex.js`).instanceApex();
39
+ console.log(`apex|${apex || ''}`);
40
+ for (const line of String(process.env.ROWS || '').split('\n').filter(Boolean)) {
41
+ const [slug, domain] = line.split('|');
42
+ const env = domain ? dnsReleaseEnv(domain) : { CLOUDFLARE_ZONE: '', BOX_IP: '' };
43
+ console.log(['row', slug, domain || '', env.CLOUDFLARE_ZONE, domain && isReservedHostname(domain, apex) ? 1 : 0, env.BOX_IP].join('|'));
44
+ }
45
+ JS
46
+ ) || { echo "cannot load the installed core from $INSTANCE_DIR"; exit 1; }
47
+
48
+ apex=$(printf '%s\n' "$plan" | awk -F'|' '$1 == "apex" {print $2}')
49
+ [ -n "$apex" ] || { echo "the installed core names no apex — refusing: the reserved-hostname guard would be blind"; exit 1; }
50
+ echo "== torn-down rows still holding a Caddy block or an unparked address (apex $apex) =="
51
+
52
+ left=(); reload=0
53
+ while IFS='|' read -r kind slug domain zone reserved blackhole; do
54
+ [ "$kind" = "row" ] || continue
55
+ # The slug grammar provisioning.isValidSlug enforces; anything else never reaches a command.
56
+ [[ "$slug" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || continue
57
+ if [ -e "$SITES/$slug.caddy" ]; then
58
+ printf ' %-22s snippet %s\n' "$slug" "$SITES/$slug.caddy"
59
+ left+=("$slug:snippet")
60
+ if [ "$APPLY" = 1 ]; then sudo -n rm -f "$SITES/$slug.caddy" </dev/null && reload=1; fi
61
+ fi
62
+ [ -n "$domain" ] || continue
63
+ if [ "$reserved" = 1 ]; then printf ' %-22s %s is platform-reserved — never parked\n' "$slug" "$domain"; continue; fi
64
+ now=$(getent ahostsv4 "$domain" 2>/dev/null </dev/null | awk 'NR == 1 {print $1}')
65
+ [ -z "$now" ] || [ "$now" = "$blackhole" ] && continue
66
+ printf ' %-22s %s resolves to %s — park at %s (zone %s)\n' "$slug" "$domain" "$now" "$blackhole" "$zone"
67
+ left+=("$slug:dns")
68
+ [ "$APPLY" = 1 ] || continue
69
+ sudo -n -u lars bash -c 'set -a; . "$1"; set +a; BOX_HOSTNAME="$2" BOX_IP="$3" CLOUDFLARE_ZONE="$4" BOX_PROXIED=0 exec "$BOX_DNS_HOOK"' \
70
+ _ "$BOX_ENV" "$domain" "$blackhole" "$zone" </dev/null || echo " ⚠ could not park $domain — re-run to retry"
71
+ done <<< "$plan"
72
+
73
+ [ "$reload" = 1 ] && { sudo -n systemctl reload caddy || echo " ⚠ caddy reload failed — the removed blocks are still loaded"; }
74
+
75
+ echo "== summary =="
76
+ if [ "$APPLY" = 1 ]; then echo " acted on (${#left[@]}): ${left[*]:-none}"; else echo " left (${#left[@]}): ${left[*]:-none}"; fi
77
+ if [ "$APPLY" = 1 ]; then echo " (applied — re-run without --apply to confirm nothing is left; DNS may take a TTL to settle)";
78
+ else echo " (survey only — re-run with --apply to remove the snippets, reload Caddy and park the hostnames)"; fi
@@ -25,6 +25,8 @@ const { resolveDefaultVersion } = require('./version-select.js');
25
25
  // task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
26
26
  // "abandoned dependency" means one thing across the CLI, the route and this audit.
27
27
  const { findStrandedTasks } = require('../../modules/lifecycle/dead-deps.js');
28
+ // task 1003752 — the route's page bound + the first-page offset rule, declared once.
29
+ const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
28
30
 
29
31
  // Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
30
32
  const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
@@ -81,6 +83,34 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
81
83
  return out;
82
84
  }
83
85
 
86
+ // Read the WHOLE task corpus, one allowed page at a time (task 1003752). This
87
+ // was a single `limit: 3000` read, which GET /tasks caps at TASK_LIST_MAX_LIMIT
88
+ // (1000) — so it 400'd on every run and the detector below never saw a single
89
+ // task. Paging is not a nicety here: the corpus passed one page long ago (3610
90
+ // rows on 2026-09-16), so even a legal `limit: 1000` would silently UNDER-read,
91
+ // which in a detector is the same failure wearing a success exit code.
92
+ //
93
+ // `getPage(query)` is injected rather than closed over so the loop is testable
94
+ // with no API; it returns the { ok, status, data } shape cliClient gives back.
95
+ // Dedupes by id: listTasks orders by `priority NULLS LAST, created_at, id`, so
96
+ // pages are stable — but a detector whose whole job is surfacing rows nobody
97
+ // can see must not be the thing that loses one.
98
+ async function fetchAllTasks(getPage, { query = {}, pageSize = TASK_LIST_MAX_LIMIT, maxPages = 100 } = {}) {
99
+ const byId = new Map();
100
+ let offset = 0;
101
+ for (let page = 0; page < maxPages; page++) {
102
+ const q = buildTaskPageQuery({ ...query, limit: pageSize, offset });
103
+ const r = await getPage(q);
104
+ if (!r || !r.ok) throw new Error(`HTTP ${r && r.status}`);
105
+ const rows = (r.data && r.data.tasks) || [];
106
+ for (const t of rows) byId.set(String(t.id), t);
107
+ // A short page is the end of the table. Compare against the CLAMPED size
108
+ // the route actually honoured, never the size the caller asked for.
109
+ if (rows.length < q.limit) return [...byId.values()];
110
+ offset += q.limit;
111
+ }
112
+ throw new Error(`more than ${maxPages} pages of tasks — refusing to loop forever`);
113
+ }
84
114
  // --stranded (task 1003153): the INVERSE audit. findMissingDeps asks "which edge
85
115
  // is MISSING"; this asks "which existing edge is DEAD" — a live task gated by an
86
116
  // ABANDONED dependency. That edge can never satisfy (an abandoned task can never
@@ -91,9 +121,13 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
91
121
  // Exits 1 when anything is stranded, so a sweep or CI leg can gate on it.
92
122
  async function reportStranded({ asJson }) {
93
123
  const api = await cliClient();
94
- const r = await api.tasks.getTasks({ query: { include: 'deps', limit: 3000 } });
95
- if (!r.ok) { console.error(`audit-deps: could not list tasks (${r.status})`); process.exit(1); }
96
- const all = r.data.tasks || [];
124
+ let all;
125
+ try {
126
+ all = await fetchAllTasks((query) => api.tasks.getTasks({ query }), { query: { include: 'deps' } });
127
+ } catch (e) {
128
+ console.error(`audit-deps: could not list tasks (${e.message})`);
129
+ process.exit(1);
130
+ }
97
131
  const stranded = findStrandedTasks(all);
98
132
 
99
133
  // Context worth stating even at zero: abandoned tasks that still CARRY
@@ -174,7 +208,7 @@ async function main() {
174
208
  console.log('NB: advisory only. Backfill `high` edges by hand (POST /tasks/:id/dependencies) after reading both tasks; a wrong edge gates the dependent.');
175
209
  }
176
210
 
177
- module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded };
211
+ module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded, fetchAllTasks };
178
212
 
179
213
  if (require.main === module) {
180
214
  main().catch((e) => { console.error('audit-deps:', e.message); process.exit(1); });