@bongos/core 1.19.999 → 1.19.1001

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 (89) hide show
  1. package/.bongos-core.json +116 -91
  2. package/.claude/skills/collab-review/SKILL.md +24 -13
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +155 -5
  11. package/docs/api-reference.md +6 -4
  12. package/docs/copy-inventory.md +18 -13
  13. package/docs/copy-registry.json +70 -15
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/hall-ui/public/collab-lib.js +79 -0
  16. package/modules/hall-ui/public/collab.css +60 -0
  17. package/modules/hall-ui/public/collab.js +134 -5
  18. package/modules/lifecycle/help-requests.js +118 -8
  19. package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
  20. package/modules/lifecycle/routes/help-requests.js +93 -13
  21. package/modules/provisioning/capacity.js +3 -3
  22. package/modules/provisioning/control-plane-guard.js +1 -1
  23. package/modules/provisioning/core-upgrade.js +7 -7
  24. package/modules/provisioning/cost-ledger.js +74 -0
  25. package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
  26. package/modules/provisioning/onboard-plan.js +1 -1
  27. package/modules/provisioning/paid-shape-gate.js +28 -15
  28. package/modules/provisioning/provisioning.js +20 -39
  29. package/modules/provisioning/routes/provisioning.js +4 -3
  30. package/modules/provisioning/routes/repo-private.js +1 -1
  31. package/modules/provisioning/tests/liveness.mjs +16 -8
  32. package/modules/provisioning/tests/provisioning.mjs +1 -1
  33. package/modules/public-landing/public/projects.html +26 -27
  34. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/init.js +12 -4
  38. package/scripts/gds/onboard.js +1 -1
  39. package/scripts/gds/provision-capacity.js +6 -1
  40. package/scripts/gds/provision-config.js +6 -3
  41. package/scripts/gds/provision-core-upgrade.js +8 -8
  42. package/scripts/gds/provision-disconnect.js +2 -2
  43. package/scripts/gds/provision-process.js +2 -2
  44. package/scripts/gds/provision-repo-private.js +1 -1
  45. package/scripts/gds/provision-repo.js +13 -3
  46. package/scripts/gds/provision-teardown.js +2 -2
  47. package/scripts/gds/provision-units.js +2 -2
  48. package/scripts/gds/provision.js +6 -6
  49. package/src/module-api.js +1 -1
  50. package/tests/collab_page.mjs +140 -7
  51. package/tests/core_upgrade_door.mjs +1 -1
  52. package/tests/core_upgrade_runner.mjs +20 -20
  53. package/tests/deploy_page_projects.mjs +5 -5
  54. package/tests/hall_collab_world.mjs +6 -2
  55. package/tests/help_requests.mjs +201 -6
  56. package/tests/hub_disconnect_ui.mjs +4 -1
  57. package/tests/init.mjs +6 -6
  58. package/tests/onboard.mjs +5 -4
  59. package/tests/project_catalog_backfill.mjs +1 -1
  60. package/tests/project_catalog_bridge.mjs +2 -2
  61. package/tests/project_catalog_routes.mjs +4 -4
  62. package/tests/projects_hub.mjs +1 -1
  63. package/tests/projects_hub_pre_uat.mjs +22 -5
  64. package/tests/projects_hub_repo_private.mjs +1 -1
  65. package/tests/provision.mjs +32 -32
  66. package/tests/provision_byo_host.mjs +1 -1
  67. package/tests/provision_caddy_wiring.mjs +1 -1
  68. package/tests/provision_disconnect.mjs +3 -3
  69. package/tests/provision_repo_private.mjs +1 -1
  70. package/tests/provision_restart.mjs +5 -5
  71. package/tests/provision_settings_apply.mjs +4 -4
  72. package/tests/provision_teardown_address.mjs +2 -2
  73. package/tests/provisioning_capacity.mjs +40 -34
  74. package/tests/provisioning_capacity_gate.mjs +5 -12
  75. package/tests/provisioning_cost_ledger.mjs +98 -0
  76. package/tests/provisioning_default_domain.mjs +1 -1
  77. package/tests/provisioning_domain_attach.mjs +4 -4
  78. package/tests/provisioning_domain_unique.mjs +2 -2
  79. package/tests/provisioning_error_note_scope.mjs +1 -1
  80. package/tests/provisioning_module_selection.mjs +5 -5
  81. package/tests/provisioning_onboard_mode.mjs +2 -2
  82. package/tests/provisioning_paid_shape_gate.mjs +57 -44
  83. package/tests/provisioning_repo_private_route.mjs +1 -1
  84. package/tests/provisioning_restart.mjs +11 -4
  85. package/tests/provisioning_settings_apply.mjs +12 -5
  86. package/tests/provisioning_settings_env.mjs +1 -1
  87. package/tests/provisioning_teardown_intent.mjs +3 -3
  88. package/tests/public_landing_projects.mjs +13 -6
  89. package/tests/upgrade.mjs +2 -2
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.999",
3
+ "version": "1.19.1001",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.999",
9
+ "version": "1.19.1001",
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.999",
3
+ "version": "1.19.1001",
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",
@@ -93,7 +93,12 @@ const KNOWN_MODULES = ['game', 'art-pipeline', 'discord', 'dev-box'];
93
93
  // more than the server does is worse than no list, because the refusal arrives a step
94
94
  // later with no useful message. Dropping it means `--shape co-tenant` fails here, naming
95
95
  // the shapes that work.
96
- const HOSTING_SHAPES = ['byo-host', 'dedicated', 'standalone'];
96
+ //
97
+ // 'dedicated' left for the same reason, one step further on: ADR 0327 decision 4 deleted
98
+ // the shape outright, so it is not merely refused but unstorable (migration
99
+ // provisioning_027 folded its value into 'cloud-host'). 'standalone' was renamed to
100
+ // 'cloud-host' by the same migration. Both were requestable from here until task 1004182.
101
+ const HOSTING_SHAPES = ['byo-host', 'cloud-host'];
97
102
 
98
103
  // The Cloud Bongos core npm package an instance pins as a dependency (ADR 0108).
99
104
  // `bongos init --adopt` LAYERS this onto an existing repo's package.json; `bongos
@@ -459,11 +464,14 @@ async function interview({ adopt = false } = {}) {
459
464
  // be a second copy of it, in a file no designer renaming the product would
460
465
  // think to open. A CLI prompt naming the stored value it is about to write is
461
466
  // the right register anyway: this asks for a column value, not for a purchase.
462
- const shape = (await ask(rl, ' Hosting shape? (byo-host = you run it on your own host / standalone = its own repo + pinned @bongos/core on the platform\'s box / dedicated = the same on its own droplet, priced by tier)', 'byo-host')).toLowerCase();
467
+ const shape = (await ask(rl, ' Hosting shape? (byo-host = you run it on your own host / cloud-host = its own repo + pinned @bongos/core, stood up for you)', 'byo-host')).toLowerCase();
463
468
  const domain = await ask(rl, ' Domain you own — bring-your-own (blank = the platform assigns <slug>.<its apex> when configured)?');
464
469
  const targetRef = await ask(rl, ' Target credential ref? (which control-plane creds the runner should use; optional)');
465
- let tier = '';
466
- if (shape === 'dedicated') tier = await ask(rl, ' Droplet tier? (e.g. s-1vcpu-2gb; optional)');
470
+ // No tier question any more. It existed only for 'dedicated', which ADR 0327
471
+ // decision 4 deleted: on the owner's own platform account a bigger plan is a
472
+ // setting they choose, not a hosting shape we model. `tier` stays in the spec
473
+ // shape below because a previously-written spec may still carry one.
474
+ const tier = '';
467
475
  provisioning = {
468
476
  shape: HOSTING_SHAPES.includes(shape) ? shape : 'byo-host',
469
477
  domain: domain || undefined,
@@ -54,7 +54,7 @@ function buildInitSpec(a = {}) {
54
54
  version: { id: a.versionId || 'V1', name: a.versionName || `${a.versionId || 'V1'} — Prove the loop`, done_when: a.doneWhen || 'The loop is proven end-to-end.' },
55
55
  };
56
56
  if (a.domain) spec.domains = { publicOrigin: `https://${a.domain}` };
57
- if (a.domain || a.slug) spec.provisioning = { shape: 'standalone', domain: a.domain || null, slug: a.slug || null };
57
+ if (a.domain || a.slug) spec.provisioning = { shape: 'cloud-host', domain: a.domain || null, slug: a.slug || null };
58
58
  return spec;
59
59
  }
60
60
 
@@ -31,7 +31,12 @@ const MEMINFO_CMD = "grep -E '^Mem(Total|Available):' /proc/meminfo";
31
31
  async function capacityPreflight(inst, deps) {
32
32
  const { db, provisioning, exec, log, apply } = deps;
33
33
  const target = capacity.placementBox(inst.hosting_shape, { remoteHost: CONFIG.remoteHost });
34
- if (!target) return { ok: true, skipped: 'dedicated' };
34
+ // No target box = no box of OURS to place this on, so there is no ceiling to ask about.
35
+ // Today that is 'byo-host' alone: the owner supplies the machine. It also covered
36
+ // 'dedicated' until ADR 0327 decision 4 removed that shape, which is why the skip reason
37
+ // is named for the QUESTION rather than for whichever shape happens to answer it — a
38
+ // reason label that names a shape goes stale the moment the shape does.
39
+ if (!target) return { ok: true, skipped: 'no-shared-box' };
35
40
  const boxExec = target.box === 'control-plane' ? (deps.controlExec || exec) : exec;
36
41
  let res = null;
37
42
  try { res = boxExec(MEMINFO_CMD, { allowFail: true }); } catch { res = null; }
@@ -102,9 +102,12 @@ const CONFIG = {
102
102
  // remote-exec splits the two boxes; all-local (no remoteHost) falls back to publicIp
103
103
  // since the control plane and the run box are then the same machine.
104
104
  controlPlaneIp: process.env.PROVISION_CONTROL_PLANE_IP || null,
105
- // Parent dir for a STANDALONE instance's OWN repo checkout (hosting_shape='standalone',
106
- // ADR 0108): each lives at <standaloneBase>/<slug> with @bongos/core installed as
107
- // an npm dep. Distinct from co-tenant (which shares THIS control-plane checkout).
105
+ // Parent dir for the OWN repo checkout of an ADR 0108 standalone-topology instance —
106
+ // today that is the 'cloud-host' hosting shape. Each lives at <standaloneBase>/<slug>
107
+ // with @bongos/core installed as an npm dep. Distinct from co-tenant, which shares THIS
108
+ // control-plane checkout. The name stays "standalone" because it describes the TOPOLOGY,
109
+ // not the shape whose stored value task 1004182 renamed; PROVISION_STANDALONE_BASE is
110
+ // set on the live box and named in the /new-project skill, so renaming it breaks both.
108
111
  standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
109
112
  // ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
110
113
  // The intent runner runs as a NON-root user (provision-intent-runner.service:
@@ -39,7 +39,7 @@ const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
39
39
  // for one would be a request that is accepted and never lands. The route refuses the
40
40
  // same shape; this is the second gate, and it is the one that holds if a row's shape
41
41
  // changes between the request and the drain.
42
- const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'standalone', 'control-plane']));
42
+ const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'cloud-host', 'control-plane']));
43
43
 
44
44
  // The strict three-number form a core version takes. Re-stated here rather than imported
45
45
  // from the web tier's core-upgrade.js: a module may not be required from scripts/ (ADR
@@ -152,7 +152,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
152
152
  const { db, provisioning, exec, log, apply, privileged } = deps;
153
153
  // A standalone's box IS the control plane's box, the same routing restartInstance and
154
154
  // settingsApplyInstance use; a co-tenant shares this checkout.
155
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
155
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
156
156
  const to = intent && intent.target_version;
157
157
  log(`core-upgrade ${inst.slug} → ${to || '(no target)'} (shape=${inst.hosting_shape}, status=${inst.status})`);
158
158
 
@@ -190,7 +190,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
190
190
  // ---- 2. is the target real, and permitted -----------------------------------------
191
191
  const channelMod = deps.updateChannel || require('./update-channel.js');
192
192
  const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
193
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
193
+ const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
194
194
  const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
195
195
  const installed = readInstalled(instanceDir);
196
196
  // THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
@@ -325,7 +325,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
325
325
  // probe on `exec` would then ask the co-hosting box whether an account exists while
326
326
  // the command it is deciding for runs on the control plane — a wrong answer that
327
327
  // looks like a right one.
328
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || deps.exec) : deps.exec;
328
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || deps.exec) : deps.exec;
329
329
  const r = boxExec(`id -u ${wanted}`, { allowFail: true });
330
330
  const failed = !r || r.softFailed === true || r.ok === false;
331
331
  if (!failed) return { runAs: wanted, fellBack: false };
@@ -371,7 +371,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
371
371
  // just to node_modules/@bongos/core — and the door aimed at the package instead of
372
372
  // the project. So ask, rather than assume; in the legacy shape the answer is
373
373
  // REPO_ROOT and the command below is byte-identical to what it always was.
374
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
374
+ const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
375
375
  // WHICH ACCOUNT, AND WHY IT IS RESOLVED RATHER THAN DERIVED (task 1004128).
376
376
  //
377
377
  // Task 1004119 wrote this as `control-plane ? APP_USER : instanceUser(inst)` and was
@@ -409,7 +409,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
409
409
  // is the interesting answer, not an exception: pre-flight refusing is what this is for.
410
410
  function preflightUpgrade(inst, to, deps) {
411
411
  const { exec, privileged } = deps;
412
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
412
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
413
413
  // A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
414
414
  // the same name would TDZ-shadow the import and crash every uninjected call.
415
415
  const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
@@ -718,10 +718,10 @@ async function preflightCoreUpgrade(inst, deps, intent) {
718
718
  async function snapshotCoreVersions(inst, deps) {
719
719
  const { db, exec, log } = deps;
720
720
  if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') return { ok: true, skipped: true };
721
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
721
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
722
722
  const channelMod = deps.updateChannel || require('./update-channel.js');
723
723
  const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
724
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
724
+ const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
725
725
  try {
726
726
  const probe = boxExec(versionCmd(inst), { allowFail: true });
727
727
  const body = probe && !probe.softFailed ? (probe.stdout || '') : null;
@@ -49,7 +49,7 @@ function eraseCommands(inst, { privileged = false } = {}) {
49
49
  `${sudo}find ${CONFIG.backupDir} -maxdepth 1 -type f -name '${dumpGlob(inst)}' -delete`,
50
50
  `${sudo}rm -rf ${path.posix.dirname(webEnvPath(inst))} ${instanceStateDir(inst)}`,
51
51
  ];
52
- if (inst.hosting_shape === 'standalone') cmds.push(`${sudo}rm -rf ${standaloneRoot(inst)}`);
52
+ if (inst.hosting_shape === 'cloud-host') cmds.push(`${sudo}rm -rf ${standaloneRoot(inst)}`);
53
53
  cmds.push(`if id -u ${user} >/dev/null 2>&1; then ${sudo}userdel ${user}; fi`);
54
54
  return cmds;
55
55
  }
@@ -63,7 +63,7 @@ async function disconnectInstance(inst, deps) {
63
63
  await teardownInstance(inst, deps);
64
64
 
65
65
  if (inst.hosting_shape !== 'dedicated') {
66
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
66
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
67
67
  for (const cmd of eraseCommands(inst, { privileged })) {
68
68
  try { boxExec(cmd); }
69
69
  catch (e) { return { ok: false, error: `erase step failed (${cmd.split(' ').slice(0, 3).join(' ')}): ${(e && e.message) || e}` }; }
@@ -75,13 +75,13 @@ function supervisorConfPath(inst) {
75
75
  // cannot drift: a standalone runs out of its own pinned core, anything else out of this
76
76
  // checkout.
77
77
  function programCommand(inst) {
78
- return inst.hosting_shape === 'standalone'
78
+ return inst.hosting_shape === 'cloud-host'
79
79
  ? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
80
80
  : '/usr/bin/node src/platform-server.js';
81
81
  }
82
82
 
83
83
  function supervisorProgram(inst, { etcBase = CONFIG.etcBase } = {}) {
84
- const workDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
84
+ const workDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : REPO_ROOT;
85
85
  return `[program:${inst.slug}]
86
86
  ; Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0306)
87
87
  command=${programCommand(inst)}
@@ -40,7 +40,7 @@ async function repoPrivateInstance(inst, deps) {
40
40
 
41
41
  // Only a standalone project has its own checkout to wire; any other shape reads from a
42
42
  // checkout this project does not own, so a key minted here would change nothing.
43
- if (inst.hosting_shape !== 'standalone') {
43
+ if (inst.hosting_shape !== 'cloud-host') {
44
44
  return { ok: false, error: 'This project shares its code checkout with others, so its repository cannot be made private from here.' };
45
45
  }
46
46
  if (inst.status !== 'active') {
@@ -79,8 +79,18 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
79
79
  return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${dbName(inst)} -c '${sql}'`, privileged);
80
80
  }
81
81
 
82
- // ── STANDALONE shape (hosting_shape='standalone', ADR 0108, task 1978) ────────
83
- // A standalone instance runs from its OWN repo checkout (host content: config/,
82
+ // ── The CLOUD-HOST shape (hosting_shape='cloud-host', ADR 0108, task 1978) ────
83
+ //
84
+ // TWO SENSES OF "STANDALONE", AND THIS FILE USES BOTH. The ADR 0108 sense is a repo
85
+ // TOPOLOGY: an instance running from its own checkout with the core as an npm dependency,
86
+ // as opposed to co-tenant's shared control-plane checkout. That is what `standaloneRoot`,
87
+ // `standaloneBase` and `PROVISION_STANDALONE_BASE` name, and those keep their spelling —
88
+ // `PROVISION_STANDALONE_BASE` in particular is a live environment variable on the control
89
+ // plane and is documented in the /new-project skill. The other sense was the HOSTING SHAPE
90
+ // once stored as 'standalone'; task 1004182 renamed that value to 'cloud-host'. Only the
91
+ // stored value moved. Do not "finish the rename" by touching the topology names.
92
+ //
93
+ // A cloud-host instance runs from its OWN repo checkout (host content: config/,
84
94
  // migrations/instance/, the core PIN in package.json) with @bongos/core installed
85
95
  // as an npm DEPENDENCY — NOT the shared control-plane checkout the co-tenant shape uses.
86
96
  // It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
@@ -883,7 +893,7 @@ function deployKeyPath(inst) { return path.posix.join(standaloneRoot(inst), '.gi
883
893
  async function ensurePrivateRepoAccess(inst, deps) {
884
894
  const { log, apply } = deps;
885
895
  const exec = deps.controlExec || deps.exec;
886
- if (inst.hosting_shape !== 'standalone') return { skipped: 'shape' };
896
+ if (inst.hosting_shape !== 'cloud-host') return { skipped: 'shape' };
887
897
  const repo = parseTargetRef(inst.target_ref);
888
898
  if (!repo) return { skipped: 'no_repo' };
889
899
  // Dry-run stays OFFLINE like every other plan path here (the checkDnsTokenScope
@@ -24,7 +24,7 @@ async function teardownInstance(inst, deps) {
24
24
  // local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
25
25
  // box, where it was never installed (that would allowFail-noop and leave the real
26
26
  // control-plane unit running). Co-tenant + dedicated are unchanged.
27
- const boxExec = inst.hosting_shape === 'standalone' ? controlExec : exec;
27
+ const boxExec = inst.hosting_shape === 'cloud-host' ? controlExec : exec;
28
28
  const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
29
29
  log(`teardown ${inst.slug} (status=${inst.status})`);
30
30
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
@@ -47,7 +47,7 @@ async function teardownInstance(inst, deps) {
47
47
  // files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
48
48
  // silently keeping durable read access to the owner's private repo is the exact
49
49
  // shape a teardown exists to end. A missing token / API hiccup never blocks it.
50
- if (inst.hosting_shape === 'standalone' && inst.target_ref) {
50
+ if (inst.hosting_shape === 'cloud-host' && inst.target_ref) {
51
51
  const repo = parseTargetRef(inst.target_ref);
52
52
  if (repo) {
53
53
  try {
@@ -16,7 +16,7 @@ const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanc
16
16
  function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
17
17
  const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
18
18
  const user = instanceUser(inst), stateDir = instanceStateDir(inst);
19
- const standalone = inst.hosting_shape === 'standalone';
19
+ const standalone = inst.hosting_shape === 'cloud-host';
20
20
  const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
21
21
  const execStart = standalone
22
22
  ? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
@@ -91,7 +91,7 @@ function backupTimerPath(inst) { return `/etc/systemd/system/${backupUnitName(in
91
91
  // scripts/gds/ — mirrors serviceUnit's execStart resolution so they can't diverge.
92
92
  function backupScriptPath(inst) {
93
93
  const rel = 'scripts/gds/db-backup-nightly.sh';
94
- return inst.hosting_shape === 'standalone'
94
+ return inst.hosting_shape === 'cloud-host'
95
95
  ? path.posix.join(standaloneRoot(inst), CORE_PKG_DIR, rel)
96
96
  : path.posix.join(REPO_ROOT, rel);
97
97
  }
@@ -238,7 +238,7 @@ async function provisionInstance(inst, deps) {
238
238
  // So a standalone uses the control-plane executors; co-tenant + dedicated are
239
239
  // unchanged (boxExec === exec, boxWriteFile === writeFile). When no remoteHost is
240
240
  // set, controlExec === exec anyway, so this is a no-op for the all-local default.
241
- const standalone = inst.hosting_shape === 'standalone';
241
+ const standalone = inst.hosting_shape === 'cloud-host';
242
242
  const boxExec = standalone ? controlExec : exec;
243
243
  const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
244
244
  const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
@@ -372,7 +372,7 @@ async function provisionInstance(inst, deps) {
372
372
  // materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
373
373
  // (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
374
374
  // throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
375
- if (inst.hosting_shape === 'standalone') {
375
+ if (inst.hosting_shape === 'cloud-host') {
376
376
  await scaffoldStandaloneRepo(inst, deps);
377
377
  // A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's
378
378
  // `git pull --ff-only origin main` — the pull that failed unauthenticated
@@ -740,7 +740,7 @@ async function domainAttachInstance(inst, deps) {
740
740
  const controlExec = deps.controlExec || exec;
741
741
  // Same box routing as provisionInstance: a standalone runs control-plane-local
742
742
  // (task 2074), so its env/caddy/service live HERE, not on the co-hosting box.
743
- const standalone = inst.hosting_shape === 'standalone';
743
+ const standalone = inst.hosting_shape === 'cloud-host';
744
744
  const boxExec = standalone ? controlExec : exec;
745
745
  const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
746
746
  const sudoP = privileged ? 'sudo ' : '';
@@ -876,7 +876,7 @@ async function domainAttachInstance(inst, deps) {
876
876
  async function settingsApplyInstance(inst, deps) {
877
877
  const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
878
878
  // Same box routing as domainAttachInstance: a standalone runs control-plane-local.
879
- const standalone = inst.hosting_shape === 'standalone';
879
+ const standalone = inst.hosting_shape === 'cloud-host';
880
880
  const boxExec = standalone ? (deps.controlExec || exec) : exec;
881
881
  const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
882
882
  const sudoP = privileged ? 'sudo ' : '';
@@ -992,7 +992,7 @@ async function settingsApplyInstance(inst, deps) {
992
992
  async function restartInstance(inst, deps) {
993
993
  const { db, provisioning, exec, log, apply, privileged } = deps;
994
994
  // Same box routing as settingsApplyInstance: a standalone runs control-plane-local.
995
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
995
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
996
996
  const sudoP = privileged ? 'sudo ' : '';
997
997
  log(`restart ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
998
998
 
@@ -1124,7 +1124,7 @@ async function placeManifestCreds(inst, mrow, deps) {
1124
1124
  return { ok: false, error: 'GitHub App Manifest cred placement is not supported for a dedicated droplet yet — run oauth-secret.js on the droplet instead' };
1125
1125
  }
1126
1126
  if (!mrow.github_code) return { ok: false, error: 'manifest row carries no github_code to convert' };
1127
- const standalone = inst.hosting_shape === 'standalone';
1127
+ const standalone = inst.hosting_shape === 'cloud-host';
1128
1128
  const boxExec = standalone ? (deps.controlExec || deps.exec) : deps.exec;
1129
1129
  const boxWriteFile = standalone ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
1130
1130
  const sudoP = deps.privileged ? 'sudo ' : '';
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.999'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1001'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -565,12 +565,138 @@ test('the asker can settle in place, with both outcomes', () => {
565
565
  assert.match(js, /patchHelpRequestsId/, 'through the real API, not a local edit');
566
566
  });
567
567
 
568
- test('there is deliberately NO reply thread — that would reimplement the Agora', () => {
569
- // The goal forbids reimplementing the Agora to get around C06's dependency.
570
- const js = read('modules', 'hall-ui', 'public', 'collab.js');
571
- for (const f of ['reply', 'comment', 'thread', 'message']) {
572
- assert.ok(!new RegExp(`data-${f}|post${f}`, 'i').test(js), `no ${f} surface`);
568
+ // REPLACED, not deleted (task 1004216). This test used to read "there is
569
+ // deliberately NO reply thread — that would reimplement the Agora", deferring
570
+ // replies to task 1002946 under goal 1000064. That task is ABANDONED and that
571
+ // goal is ARCHIVED, so the deferral had no addressee left: the surface that was
572
+ // to own replies will never be built, and an ask stayed unanswerable to keep a
573
+ // promise to a thing that does not exist. ADR 0330 records the reversal.
574
+ //
575
+ // What the old test was PROTECTING is still real, so it is what these assert:
576
+ // a thread here must not grow into the chat surface ADR 0187 §4a declined.
577
+ test('an ask can be answered — and the answer surface stops well short of a chat', () => {
578
+ const src = read('modules', 'hall-ui', 'public', 'collab.js');
579
+ // Comments stripped before the bans are checked: this file ARGUES at length
580
+ // about the four things it is not building, and a scan that read the argument
581
+ // as the thing would fail on the very prose explaining the restraint.
582
+ const js = src.replace(/^\s*\/\/.*$/gm, '');
583
+ assert.match(js, /data-reply-form/, 'there is somewhere to write an answer');
584
+ assert.match(js, /POST', `\/help-requests\/\$\{id\}\/replies`/, 'and it posts to the replies route');
585
+
586
+ // The four things a chat has that this must not grow.
587
+ assert.ok(!/data-edit|patchReply|editReply/i.test(js), 'no edit — a reply is what was said at the time');
588
+ assert.ok(!/deleteReply|data-reply-delete/i.test(js), 'no delete');
589
+ assert.ok(!/data-react|reaction|emoji/i.test(js), 'no reactions');
590
+ assert.ok(!/parent_reply|data-reply-to|@mention|mentions/i.test(js), 'no nesting and no mentions');
591
+ });
592
+
593
+ // RUN, not grepped — this file's own convention for everything in collab-lib.js,
594
+ // and the grader was right that the first pass broke it. canClose in particular
595
+ // decides whether a close control is drawn at all, so each branch of it needs a
596
+ // test that calls it rather than a regex that agrees with a wrong answer.
597
+ test('canClose: the asker and the addressee, and nobody else', () => {
598
+ const L = lib();
599
+ const craftAsk = { requested_by: '90', needs_craft: 'artist', needs_builder_id: null };
600
+ const namedAsk = { requested_by: '90', needs_craft: null, needs_builder_id: '7' };
601
+
602
+ assert.equal(L.canClose(namedAsk, 90, []), true, 'the asker always may');
603
+ assert.equal(L.canClose(namedAsk, 7, []), true, 'so may the person it names');
604
+ assert.equal(L.canClose(craftAsk, 7, ['artist']), true, 'or a craft they belong to');
605
+
606
+ assert.equal(L.canClose(craftAsk, 7, ['builder']), false, 'a craft they do not belong to is not theirs to close');
607
+ assert.equal(L.canClose(namedAsk, 8, ['artist']), false, 'a stranger to a by-name ask');
608
+ assert.equal(L.canClose(craftAsk, 7, []), false, 'no declared craft reaches no craft ask');
609
+ });
610
+
611
+ test('canClose fails CLOSED on every missing fact', () => {
612
+ // The page reads meId from /me. A failed read leaves it null, and the correct
613
+ // answer then is "draw nothing" — never "draw it and let the server refuse".
614
+ const L = lib();
615
+ const ask = { requested_by: '90', needs_builder_id: '7' };
616
+ for (const bad of [null, undefined]) {
617
+ assert.equal(L.canClose(ask, bad, ['artist']), false, `meId ${bad} draws no control`);
618
+ }
619
+ assert.equal(L.canClose(null, 7, ['artist']), false, 'no request, no control');
620
+ assert.equal(L.canClose({ requested_by: '90', needs_craft: null }, 7, null), false,
621
+ 'a non-array craft list is not iterated into a yes');
622
+ });
623
+
624
+ test('canClose compares ids ACROSS types — the API sends strings, /me sends what it sends', () => {
625
+ // The live bug this forecloses: needs_builder_id arrives as "7" and meId as 7,
626
+ // and === between them is false, so the person a request names would silently
627
+ // lose their own close control.
628
+ const L = lib();
629
+ assert.equal(L.canClose({ requested_by: '90', needs_builder_id: '7' }, 7, []), true);
630
+ assert.equal(L.canClose({ requested_by: 90, needs_builder_id: 7 }, '7', []), true);
631
+ });
632
+
633
+ test('threadOf: oldest first, ties broken on id, and never explodes on a missing thread', () => {
634
+ const L = lib();
635
+ const out = L.threadOf({ replies: [
636
+ { id: 3, created_at: '2026-09-22T10:00:00Z' },
637
+ { id: 1, created_at: '2026-09-20T10:00:00Z' },
638
+ { id: 2, created_at: '2026-09-22T10:00:00Z' },
639
+ ] });
640
+ assert.deepEqual(out.map((r) => r.id), [1, 2, 3], 'two replies in the same second keep the order written');
641
+ assert.deepEqual(L.threadOf({}), [], 'a row with no replies field');
642
+ assert.deepEqual(L.threadOf(null), []);
643
+ assert.deepEqual(L.threadOf({ replies: 'nonsense' }), [], 'and a field that is not a list');
644
+ });
645
+
646
+ test('saidLabel is the PAST tense — an answer is not a debt', () => {
647
+ // ageLabel says "waiting 3 days", which under an answer reads as the answer
648
+ // still being owed: the exact opposite of what a reply means.
649
+ const L = lib();
650
+ const NOW2 = Date.parse('2026-09-23T12:00:00Z');
651
+ const ago = (d) => new Date(NOW2 - d * 86400000).toISOString();
652
+ assert.equal(L.saidLabel(ago(0), NOW2), 'today');
653
+ assert.equal(L.saidLabel(ago(1), NOW2), 'yesterday');
654
+ assert.equal(L.saidLabel(ago(3), NOW2), '3 days ago');
655
+ assert.equal(L.saidLabel(ago(21), NOW2), '3 weeks ago');
656
+ assert.equal(L.saidLabel('nonsense', NOW2), 'just now', 'an unknown time is not an accusation');
657
+ for (const label of [L.saidLabel(ago(3), NOW2), L.saidLabel(ago(21), NOW2)]) {
658
+ assert.ok(!/waiting/.test(label), `"${label}" must not borrow the ask's tense`);
659
+ }
660
+ });
661
+
662
+ test('replyAuthor names somebody, whatever the server sent', () => {
663
+ const L = lib();
664
+ // Neutral names on purpose: this is the portable core, and a founder's login
665
+ // baked into a fixture is what audit-authorship.sh exists to catch.
666
+ assert.equal(L.replyAuthor({ builder_name: 'Preview Alpha', builder_login: 'preview-alpha' }), 'Preview Alpha');
667
+ assert.equal(L.replyAuthor({ builder_login: 'preview-alpha' }), 'preview-alpha', 'the login when there is no display name');
668
+ assert.equal(L.replyAuthor({ builder_id: 7 }), 'builder 7', 'the bare id an older server sends');
669
+ assert.equal(L.replyAuthor({}), 'someone', 'never an empty byline');
670
+ assert.equal(L.replyAuthor(null), '');
671
+ });
672
+
673
+ test('validateReplyBody refuses before the round trip, and trims what it accepts', () => {
674
+ const L = lib();
675
+ assert.deepEqual(L.validateReplyBody(' moot '), { ok: true, body: 'moot' });
676
+ assert.equal(L.validateReplyBody(' ').ok, false);
677
+ assert.equal(L.validateReplyBody(' ').reason, 'empty');
678
+ assert.equal(L.validateReplyBody(null).reason, 'empty');
679
+ assert.equal(L.validateReplyBody('x'.repeat(L.MAX_REPLY + 1)).reason, 'too_long');
680
+ assert.equal(L.validateReplyBody('x'.repeat(L.MAX_REPLY)).ok, true, 'the cap itself is allowed');
681
+ });
682
+
683
+ test('append-only is enforced by the DATABASE, not by the routes that exist today', () => {
684
+ // A route layer that merely omits an update is one careless PR from allowing
685
+ // one. The table refuses an UPDATE or a DELETE outright, so the guarantee
686
+ // holds against a future route, a migration, or a hand-run statement.
687
+ const raw = read('modules', 'lifecycle', 'migrations', 'lifecycle_011_help_request_replies.sql');
688
+ assert.match(raw, /BEFORE UPDATE OR DELETE ON lifecycle_help_request_replies/);
689
+ assert.match(raw, /RAISE EXCEPTION/, 'and it raises rather than silently ignoring the write');
690
+ // Same reason as above — the migration's header names the columns it refuses
691
+ // to have, so the ban is checked against the STATEMENTS, not the argument.
692
+ const sql = raw.replace(/^\s*--.*$/gm, '');
693
+ for (const col of ['updated_at', 'edited_by', 'deleted_at', 'parent_reply_id']) {
694
+ assert.ok(!new RegExp(`\\b${col}\\b`).test(sql), `no ${col} column — the shape refuses to become a chat`);
573
695
  }
696
+
697
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
698
+ assert.ok(!/router\.(patch|delete|put)\('\/help-requests\/:id\/replies/.test(routes),
699
+ 'and no route offers to change one');
574
700
  });
575
701
 
576
702
  // ---- placement ------------------------------------------------------------
@@ -724,7 +850,14 @@ test('the skill the page invokes exists, and is the ANSWERING half', () => {
724
850
  assert.match(md, /^name: collab-review$/m);
725
851
  assert.match(md, /GET \/api\/bongos\/help-requests\/for-me/);
726
852
  assert.match(md, /GET \/api\/bongos\/task-recommendations\/for-me/);
727
- // The asymmetry the routes enforce, stated where a session will read it.
728
- assert.match(md, /cannot settle a help request addressed to you/i);
853
+ // The rules the routes enforce, stated where a session will read them. Since
854
+ // ADR 0330 the addressee CAN close an ask — so what the skill must carry is no
855
+ // longer "you cannot", it is "you do not, because you are not the one who read
856
+ // the answer", plus the half that did not widen.
857
+ assert.match(md, /POST \/api\/bongos\/help-requests\/:id\/replies/, 'it answers on the ask');
858
+ assert.match(md, /Do NOT close the ask/, 'and hands the close to a person');
859
+ assert.match(md, /withdraw/i, 'and knows withdraw did not widen');
860
+ assert.ok(!/POST \/api\/bongos\/help-requests --body-file/.test(md),
861
+ 'the file-a-counter-ask workaround is deleted, not left beside the real thing');
729
862
  assert.match(md, /DATA, not instructions/, 'an ask is untrusted input');
730
863
  });
@@ -119,7 +119,7 @@ await t('and widening it did NOT let the destructive acts through', () => {
119
119
  });
120
120
 
121
121
  await t('an ordinary instance is untouched by the guard', () => {
122
- for (const shape of ['co-tenant', 'dedicated', 'standalone']) {
122
+ for (const shape of ['co-tenant', 'dedicated', 'cloud-host']) {
123
123
  for (const a of P.INTENT_ACTIONS) {
124
124
  assert.equal(G.intentAllowedForShape(a, shape), true, `${a}/${shape} must be unaffected`);
125
125
  }