@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
@@ -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.773",
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.773",
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.773",
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
@@ -1166,6 +1166,42 @@ function detectWorktreeName() {
1166
1166
  }
1167
1167
  }
1168
1168
 
1169
+ // ---- card richness (task 1002518) ------------------------------------------
1170
+ //
1171
+ // The one place a CLI turns a /me payload into "what should this card look
1172
+ // like": three booleans — print the card at all, write the widget, open its
1173
+ // summary — that emitCard consumes directly.
1174
+ //
1175
+ // WHY THIS READS THE PAYLOAD AND NEVER require()s render-prefs.js. The ladder
1176
+ // lives in modules/builder-settings/render-prefs.js, and requiring it from here
1177
+ // would work on a full checkout and BREAK on a task-scoped dev box, which
1178
+ // carries scripts/ and src/bongos/ but not every modules/ directory (task
1179
+ // 1003098 — tree-preflight exists precisely to fail an entry point that reaches
1180
+ // for module code, and start.js is one). So the server resolves the rung and
1181
+ // ships the answers on `render.flags`; this function only picks them up.
1182
+ //
1183
+ // TWO SERVER SHAPES, deliberately. `render.flags` is the ladder (task 1002518);
1184
+ // `render.widgets_enabled` is the boolean that predates it. A CLI newer than the
1185
+ // instance it is pointed at sees only the boolean — the /me payload is not
1186
+ // version-negotiated — so the fallback is not defensive padding, it is the
1187
+ // supported path against a pinned instance, and it reconstructs exactly what
1188
+ // that boolean has always meant: card always, widget iff true, never expanded.
1189
+ // An absent or failed /me lands on the same default.
1190
+ function cardFlagsFromMe(me) {
1191
+ const render = me && me.data && me.data.render;
1192
+ const f = render && render.flags;
1193
+ if (f && typeof f === 'object') {
1194
+ return {
1195
+ level: typeof f.level === 'string' ? f.level : null,
1196
+ showCard: f.showCard !== false,
1197
+ showWidget: f.showWidget === true,
1198
+ expanded: f.expanded === true,
1199
+ };
1200
+ }
1201
+ const enabled = !render || render.widgets_enabled !== false;
1202
+ return { level: null, showCard: true, showWidget: enabled, expanded: false };
1203
+ }
1204
+
1169
1205
  module.exports = {
1170
1206
  API_BASE,
1171
1207
  detectWorktreeName,
@@ -1220,4 +1256,5 @@ module.exports = {
1220
1256
  printOnboardingNudge,
1221
1257
  printCostWarning,
1222
1258
  printNeedsNudge,
1259
+ cardFlagsFromMe,
1223
1260
  };
@@ -0,0 +1,50 @@
1
+ // scripts/gds/fitness-migration-contraction.js — the ONE shape of destructive DDL a
2
+ // module migration may carry: an ACKNOWLEDGED contract-phase DROP COLUMN on its own
3
+ // namespaced table (task 1003092; ADR 0083 §Module-owned migrations, amended; the
4
+ // expand-then-contract pattern of ADR 0177).
5
+ //
6
+ // WHY. Check 11 in fitness.js keeps module migrations ADDITIVE: there is no down
7
+ // path, "disable a module" means "stop applying", so a DROP can only be an accident.
8
+ // That rule met its first legitimate exception the moment a module ran an
9
+ // expand-then-contract rename (ADR 0177 §"the fifth use"): `platform_identity_010`
10
+ // ADDed `membership_kind` beside the legacy `role` column and kept the pair in
11
+ // lockstep; the CONTRACT phase has to DROP the legacy column, or the rename never
12
+ // finishes and the shim outlives its release (ADR 0174's Lineage lesson). A drop
13
+ // that a previous migration wrote down in advance is not an accident.
14
+ //
15
+ // THE SHAPE, AND WHY IT IS THIS NARROW. A statement is let through only when ALL of:
16
+ // 1. the FILE carries the `-- forward-unsafe: <reason>` pragma — the same
17
+ // acknowledgement scripts/gds/lint-migrations.js requires of a core migration,
18
+ // so one vocabulary says "this destruction is deliberate" everywhere;
19
+ // 2. the statement is EXACTLY `ALTER TABLE <t> DROP COLUMN [IF EXISTS] <c>` and
20
+ // nothing else — no second clause, no DROP TABLE, no TRUNCATE, no bare DROP
21
+ // CONSTRAINT (which stays a violation even with the pragma);
22
+ // 3. <t> is inside the module's own namespace (or its grandfathered legacy set),
23
+ // the same test the namespace check applies to every ALTER.
24
+ // The pragma does not turn the check off for the file: every OTHER destructive
25
+ // statement in an acknowledged file is still flagged. PURE — no fs, no process.
26
+ 'use strict';
27
+
28
+ // Same single-line acknowledgement lint-migrations.js captures (ACK_RE there).
29
+ const ACK_RE = /^\s*--\s*forward-unsafe\s*:\s*(.+)$/im;
30
+
31
+ // Exactly one column drop, on one table, and nothing else in the statement.
32
+ const COLUMN_DROP_RE = /^\s*alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?("?[\w.]+"?)\s+drop\s+column\s+(?:if\s+exists\s+)?"?\w+"?\s*$/i;
33
+
34
+ // Is the migration FILE acknowledged as forward-unsafe? Read on the RAW sql — the
35
+ // pragma is a comment, and the destructive-DDL scan strips comments first.
36
+ function acknowledgedContraction(rawSql) {
37
+ const m = ACK_RE.exec(String(rawSql == null ? '' : rawSql));
38
+ return m ? m[1].trim() : null;
39
+ }
40
+
41
+ // Is THIS statement the one allowed shape? `inNamespace(table)` is the caller's
42
+ // namespace predicate (fitness.js owns the <key>_ / legacy-set rule).
43
+ function isAcknowledgedColumnContraction(stmt, { acknowledged, inNamespace }) {
44
+ if (!acknowledged) return false;
45
+ const m = COLUMN_DROP_RE.exec(stmt);
46
+ if (!m) return false;
47
+ return inNamespace(m[1]);
48
+ }
49
+
50
+ module.exports = { acknowledgedContraction, isAcknowledgedColumnContraction };
@@ -827,6 +827,12 @@ function isConstraintWidening(stmt, reAdded) {
827
827
  return reAdded.has(m[1].replace(/"/g, '')); // re-added same-named constraint → widening
828
828
  }
829
829
 
830
+ // The ONE destructive shape a module may carry: an ACKNOWLEDGED contract-phase
831
+ // `ALTER TABLE <own table> DROP COLUMN <c>` (task 1003092 — the expand-then-contract
832
+ // rename of ADR 0177 has to finish; ADR 0083 §Module-owned migrations, amended). The
833
+ // pragma is read off the RAW sql (it is a comment); everything else here still runs.
834
+ const { acknowledgedContraction, isAcknowledgedColumnContraction } = require('./fitness-migration-contraction.js');
835
+
830
836
  // Pure: violations for ONE module's migration SQL. Exported for the negative test.
831
837
  function moduleMigrationViolations(key, sql) {
832
838
  const ns = moduleNamespace(key);
@@ -835,8 +841,11 @@ function moduleMigrationViolations(key, sql) {
835
841
  const violations = [];
836
842
  // additive — scan per statement so an ALTER…DROP never spans a `;`
837
843
  const reAdded = reAddedConstraintNames(clean);
844
+ const acknowledged = acknowledgedContraction(sql);
845
+ const ownTable = (t) => inNamespace(t, ns, legacy);
838
846
  for (const stmt of clean.split(';')) {
839
847
  if (isConstraintWidening(stmt, reAdded)) continue; // additive CHECK-enum widening (task 1978)
848
+ if (isAcknowledgedColumnContraction(stmt, { acknowledged, inNamespace: ownTable })) continue; // contract phase (task 1003092)
840
849
  for (const d of DESTRUCTIVE_DDL) {
841
850
  if (d.re.test(stmt)) {
842
851
  violations.push(`module "${key}" migration uses destructive DDL (${d.label}) — module migrations must be ADDITIVE; disabling a module stops applying, it never drops (ADR 0083 §Decision #5).`);
@@ -1205,6 +1214,15 @@ const CHECKS = [
1205
1214
  // migrations: same shared, monotonic, human-allocated number line, and until this
1206
1215
  // landed only one of the two was enforced. An ADR number is a citation target, so a
1207
1216
  // duplicate is wrong forever (ADR 0195).
1217
+ // Check 33 — task 1003819: a module declares what it contributes, and nothing
1218
+ // checked that a declared seam is actually WIRED. verifyPortsSatisfied asks the
1219
+ // opposite question (every `consumes` has a provider) and validateManifest checks
1220
+ // only the SHAPE, so a contributes entry whose artifact was never shipped — or was
1221
+ // renamed out from under the manifest — boots silent: every consumer skips it the
1222
+ // same way it correctly skips a real declarative-only entry. CI, not boot: nothing
1223
+ // crashes later, so a start-up refusal would trade an outage risk for a cosmetic
1224
+ // one (ADR 0289). Rationale + the seam→consumer table live in that file.
1225
+ require('./seam-wiring-guard.js').checkSeamWiring,
1208
1226
  require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
1209
1227
  ];
1210
1228
 
@@ -122,9 +122,15 @@ function buildShippedEnvelope(crumb, task) {
122
122
  title: inputs.title || (task && task.title) || `Task #${crumb.taskId}`,
123
123
  valueSummary: inputs.valueSummary || (task && task.value_summary) || '',
124
124
  });
125
+ // task 1002518: prefer the rung the shipping session actually resolved, so the
126
+ // later-rendered shipped card matches the one it printed. A breadcrumb written
127
+ // by an older CLI carries only `widgetsEnabled`, which still reads correctly.
128
+ const flags = (crumb.cardFlags && typeof crumb.cardFlags === 'object')
129
+ ? crumb.cardFlags
130
+ : { showWidget: crumb.widgetsEnabled !== false, expanded: false };
125
131
  return {
126
- widgets_enabled: crumb.widgetsEnabled !== false,
127
- html: shipCard.renderCardHtml(model),
132
+ widgets_enabled: flags.showWidget === true,
133
+ html: shipCard.renderCardHtml(model, { expanded: flags.expanded === true }),
128
134
  markdown: shipCard.renderMonospaceCard(model),
129
135
  warnings: [],
130
136
  title: `task_${crumb.taskId}_card`,