@bongos/core 1.19.773 → 1.19.775

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 (96) hide show
  1. package/.bongos-core.json +195 -80
  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/config/modules.neutral.json +1 -1
  8. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  9. package/docs/adr/0177-goal-membership-kind.md +26 -2
  10. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  11. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  12. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  13. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  14. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  15. package/docs/adr/README.md +3 -0
  16. package/docs/api/openapi.json +102 -11
  17. package/docs/api-reference.md +5 -4
  18. package/docs/copy-inventory.md +205 -190
  19. package/docs/copy-registry.json +435 -269
  20. package/docs/file-map.md +1 -0
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/modules-contract.md +1 -1
  23. package/modules/builder-settings/render-prefs.js +281 -33
  24. package/modules/builder-settings/routes/settings.js +20 -12
  25. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  26. package/modules/dev-box/module.json +4 -0
  27. package/modules/hall-ui/public/settings-render.js +138 -0
  28. package/modules/hall-ui/public/settings.html +16 -9
  29. package/modules/hall-ui/public/settings.js +3 -70
  30. package/modules/lifecycle/ship-card.js +49 -13
  31. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  32. package/modules/platform-identity/platform-identity.js +8 -19
  33. package/modules/platform-identity/project-catalog.js +24 -0
  34. package/modules/platform-identity/routes/sso.js +11 -14
  35. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  36. package/modules/provisioning/capacity-gate.js +54 -0
  37. package/modules/provisioning/catalog-bridge.js +2 -0
  38. package/modules/provisioning/disconnect.js +35 -0
  39. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  40. package/modules/provisioning/module.json +1 -1
  41. package/modules/provisioning/provisioning.js +3 -3
  42. package/modules/provisioning/routes/disconnect.js +63 -0
  43. package/modules/provisioning/routes/provisioning.js +6 -6
  44. package/modules/provisioning/starter-bundles.js +32 -30
  45. package/modules/public-landing/public/projects.html +116 -11
  46. package/modules/public-landing/public/projects.probes.json +3 -0
  47. package/modules/ui-design/kit/serve.js +5 -5
  48. package/modules/ui-design/module.json +1 -0
  49. package/package-lock.json +2 -2
  50. package/package.json +1 -1
  51. package/scripts/gds/address-park-sweep.sh +78 -0
  52. package/scripts/gds/cli-lib.js +37 -0
  53. package/scripts/gds/fitness-migration-contraction.js +50 -0
  54. package/scripts/gds/fitness.js +18 -0
  55. package/scripts/gds/land-watch.js +8 -2
  56. package/scripts/gds/provision-config.js +4 -4
  57. package/scripts/gds/provision-disconnect.js +90 -0
  58. package/scripts/gds/provision-teardown.js +108 -0
  59. package/scripts/gds/provision.js +6 -65
  60. package/scripts/gds/seam-wiring-guard.js +273 -0
  61. package/scripts/gds/ship-finish.js +17 -10
  62. package/scripts/gds/ship-flow.js +5 -5
  63. package/scripts/gds/start.js +13 -7
  64. package/src/bongos/auth-admission.js +7 -8
  65. package/src/bongos/auth-config.js +3 -2
  66. package/src/bongos/goal-scope-check.js +23 -8
  67. package/src/bongos/module-scope-map.js +40 -0
  68. package/src/module-api.js +1 -1
  69. package/src/module-loader/manifest-schema.js +79 -1
  70. package/tests/catalog_only_client.mjs +8 -5
  71. package/tests/fitness.mjs +22 -0
  72. package/tests/goal_scope_check.mjs +63 -1
  73. package/tests/hall_settings_world.mjs +57 -1
  74. package/tests/hub_disconnect_ui.mjs +105 -0
  75. package/tests/land_watch.mjs +44 -0
  76. package/tests/membership_kind_lockstep.mjs +86 -27
  77. package/tests/module-scope-map.mjs +39 -0
  78. package/tests/module_exports_unique_keys.mjs +70 -0
  79. package/tests/module_manifest.mjs +85 -0
  80. package/tests/project_catalog_forget.mjs +83 -0
  81. package/tests/project_type_editable.mjs +8 -8
  82. package/tests/projects_hub.mjs +10 -4
  83. package/tests/projects_hub_pre_uat.mjs +5 -3
  84. package/tests/provision_disconnect.mjs +231 -0
  85. package/tests/provision_teardown_address.mjs +230 -0
  86. package/tests/provisioning_capacity_gate.mjs +192 -0
  87. package/tests/provisioning_disconnect.mjs +231 -0
  88. package/tests/provisioning_module_selection.mjs +14 -14
  89. package/tests/provisioning_recommendations.mjs +17 -25
  90. package/tests/provisioning_restart.mjs +5 -5
  91. package/tests/provisioning_starter_bundles.mjs +31 -9
  92. package/tests/public_landing_projects.mjs +4 -3
  93. package/tests/render_prefs.mjs +152 -0
  94. package/tests/seam_wiring_guard.mjs +234 -0
  95. package/tests/ship_card.mjs +67 -1
  96. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
@@ -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 },
@@ -70,6 +70,21 @@ const ALWAYS_ON_CORE = Object.freeze([
70
70
  // refuse them by construction rather than by anyone remembering.
71
71
  const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning']);
72
72
 
73
+ // Modules that were once offered and are being WITHDRAWN. A retired key is still
74
+ // a module on disk — its manifest, routes and tables are deleted by later tasks —
75
+ // but it must stop being OFFERED the moment the withdrawal starts, or an owner
76
+ // creating a project today opts into something that is on its way out.
77
+ //
78
+ // WHY A LIST AND NOT JUST DELETING THE ROW. The integrity test below pins
79
+ // OPTIONAL_MODULES against the on-disk `default: false` manifests, so that a new
80
+ // opt-in module cannot ship without owner-facing copy. Dropping a row while its
81
+ // manifest is still on disk would trip exactly that guard. Naming the withdrawal
82
+ // keeps the guard honest in both directions: the catalog may omit a RETIRED
83
+ // module and may omit nothing else. The entry is deleted when the module is
84
+ // (goal 1000091, task 1003892) — at which point the manifest is gone and the
85
+ // plain drift check covers it again.
86
+ const RETIRED_MODULES = Object.freeze(['dev-box']);
87
+
73
88
  // The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
74
89
  // every module an owner may actually choose, in the order the picker lists them,
75
90
  // with copy written for someone who has met none of them.
@@ -90,11 +105,6 @@ const OPTIONAL_MODULES = Object.freeze([
90
105
  label: 'Extra agents',
91
106
  summary: 'More AI agents alongside the ones every project gets, for reading, research and review.',
92
107
  }),
93
- Object.freeze({
94
- key: 'dev-box',
95
- label: 'Dev boxes',
96
- summary: 'A cloud workspace where a change can be tried out before anyone else sees it. Nothing is charged until someone asks for one.',
97
- }),
98
108
  Object.freeze({
99
109
  key: 'discord',
100
110
  label: 'Discord',
@@ -122,8 +132,10 @@ const OPTIONAL_MODULE_KEYS = Object.freeze(OPTIONAL_MODULES.map((m) => m.key));
122
132
  // stay there. Decision: ADR 0243 (BV1.R22, task 1002341).
123
133
  //
124
134
  // Deliberately tiny: ADR 0237 already made the per-type preset the opinion, and
125
- // today's opt-in catalog is three modules wide — so this table's job is to correct
126
- // a preset the owner's answer contradicts, not to re-derive it.
135
+ // today's opt-in catalog is two modules wide — so this table's job is to correct
136
+ // a preset the owner's answer contradicts, not to re-derive it. The two rules that
137
+ // added and removed `dev-box` by team shape went with the module (task 1003886):
138
+ // a rule about a module nobody can select adjusts nothing.
127
139
  //
128
140
  // A rule may only ever name an OPT-IN module. Adding or removing an always-on one
129
141
  // would be a toggle that toggles nothing (ADR 0237 §3's argument, and
@@ -136,20 +148,6 @@ const TEAM_SHAPE_RULES = Object.freeze([
136
148
  module: 'discord',
137
149
  reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
138
150
  }),
139
- Object.freeze({
140
- id: 'a-team-shares-one-environment',
141
- teamShape: 'small-team',
142
- effect: 'add',
143
- module: 'dev-box',
144
- reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.',
145
- }),
146
- Object.freeze({
147
- id: 'building-alone-needs-no-shared-environment',
148
- teamShape: 'solo',
149
- effect: 'remove',
150
- module: 'dev-box',
151
- reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.',
152
- }),
153
151
  ]);
154
152
 
155
153
  // The team shapes the rules above can fire on. Derived from the table rather than
@@ -167,16 +165,17 @@ const RULED_TEAM_SHAPES = Object.freeze([...new Set(TEAM_SHAPE_RULES.map((r) =>
167
165
  // here has not met any of these modules yet.
168
166
  //
169
167
  // The lists are SHORT on purpose. A preselect an owner has to audit is worse than
170
- // none, and today's opt-in catalog is genuinely small (`agents`, `dev-box`,
171
- // `discord`); a bundle grows as the catalog does, which is exactly why this is a
172
- // declaration and not a rule engine. Enabling `dev-box` opens the door to a cloud
173
- // dev environment — it provisions nothing and bills nothing until a builder asks
174
- // for a box — so preselecting it costs the owner nothing but a toggle.
168
+ // none, and today's opt-in catalog is genuinely small (`agents`, `discord`); a
169
+ // bundle grows as the catalog does, which is exactly why this is a declaration and
170
+ // not a rule engine. `dev-box` was preselected by the game and business bundles
171
+ // until it was retired (goal 1000091, task 1003886) — withdrawing it left both
172
+ // bundles one module wide, which is the floor working as intended, not a gap to
173
+ // backfill with a module an owner did not ask for.
175
174
  const STARTER_BUNDLES = Object.freeze({
176
175
  game: Object.freeze({
177
176
  label: 'Game starter',
178
- summary: 'Somewhere for players to gather, and a place to walk a change through before anyone plays it.',
179
- modules: Object.freeze(['dev-box', 'discord']),
177
+ summary: 'Somewhere for players to gather, hear what you are building, and send their ideas back.',
178
+ modules: Object.freeze(['discord']),
180
179
  }),
181
180
  research: Object.freeze({
182
181
  label: 'Research starter',
@@ -185,8 +184,8 @@ const STARTER_BUNDLES = Object.freeze({
185
184
  }),
186
185
  business: Object.freeze({
187
186
  label: 'Business starter',
188
- summary: 'A place to try a change before customers see it, and agents to look it over first.',
189
- modules: Object.freeze(['agents', 'dev-box']),
187
+ summary: 'Extra agents to look a change over before customers ever see it.',
188
+ modules: Object.freeze(['agents']),
190
189
  }),
191
190
  'non-profit': Object.freeze({
192
191
  label: 'Non-profit starter',
@@ -259,6 +258,8 @@ function adjustBundle(bundle, teamShape) {
259
258
  for (const rule of TEAM_SHAPE_RULES) {
260
259
  if (rule.teamShape !== teamShape) continue;
261
260
  if (rule.effect === 'add' && !held.has(rule.module)) held.add(rule.module);
261
+ // `remove` is declared but named by no live rule since dev-box was retired
262
+ // (task 1003886). Kept for the next rule that needs it, not exercised today.
262
263
  else if (rule.effect === 'remove' && held.has(rule.module)) held.delete(rule.module);
263
264
  else continue;
264
265
  adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
@@ -374,6 +375,7 @@ function effectiveModules(row) {
374
375
  module.exports = {
375
376
  ALWAYS_ON_CORE,
376
377
  HUB_ONLY_MODULES,
378
+ RETIRED_MODULES,
377
379
  OPTIONAL_MODULES,
378
380
  STARTER_BUNDLES,
379
381
  TEAM_SHAPE_RULES,
@@ -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"]]} },
@@ -121,15 +121,14 @@ const STARTER_BUNDLES_FIXTURE = {
121
121
  'security', 'sessions', 'status-ui', 'ui-design',
122
122
  ],
123
123
  bundles: [
124
- { type: 'game', bundle: ['dev-box', 'discord'] },
124
+ { type: 'game', bundle: ['discord'] },
125
125
  { type: 'research', bundle: ['agents'] },
126
- { type: 'business', bundle: ['agents', 'dev-box'] },
126
+ { type: 'business', bundle: ['agents'] },
127
127
  { type: 'non-profit', bundle: ['discord'] },
128
128
  { type: 'not-sure', bundle: [] },
129
129
  ],
130
130
  optional: [
131
131
  { key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets, for reading, research and review.' },
132
- { key: 'dev-box', label: 'Dev boxes', summary: 'A cloud workspace where a change can be tried out before anyone else sees it. Nothing is charged until someone asks for one.' },
133
132
  { key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.' },
134
133
  ],
135
134
  };
@@ -157,6 +156,7 @@ function stamp(v) {
157
156
  const FLOW_RECEIPTS = {
158
157
  teardown: { queued: true, message: 'Teardown queued.' },
159
158
  restart: { queued: true, message: 'Restart queued.' },
159
+ 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
160
  domain: { recorded: true, queued: true, message: 'Address recorded.' },
161
161
  // 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
162
  provision: { queued: true, message: 'Queued. The platform picks it up from here.', instance: { id: 102, domain: 'mercury.cloudbongos.com' } },
@@ -220,10 +220,10 @@ function withPlanet(inst) {
220
220
  }
221
221
  function flowReceipt(rest, method) {
222
222
  if (method === 'POST' && rest === '/provisioning/instances') return FLOW_RECEIPTS.provision;
223
- const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain)$/);
223
+ const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain|disconnect)$/);
224
224
  if (!m) return null;
225
225
  if (method !== 'POST') return null;
226
- if (m[2] === 'teardown' || m[2] === 'restart') ASKED.set(m[1], m[2]);
226
+ if (m[2] === 'teardown' || m[2] === 'restart' || m[2] === 'disconnect') ASKED.set(m[1], m[2]);
227
227
  return FLOW_RECEIPTS[m[2]];
228
228
  }
229
229
  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.773",
3
+ "version": "1.19.775",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.773",
9
+ "version": "1.19.775",
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.773",
3
+ "version": "1.19.775",
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