@bongos/core 1.17.0 → 1.17.1

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 +154 -83
  2. package/CLAUDE.md +11 -238
  3. package/bin/bongos.js +1 -0
  4. package/clients/bongos-client/README.md +4 -4
  5. package/clients/bongos-client/bongos-client.global.js +14 -1
  6. package/clients/bongos-client/index.cjs +14 -1
  7. package/clients/bongos-client/index.d.ts +19 -2
  8. package/clients/bongos-client/index.mjs +11 -1
  9. package/clients/bongos-client/package.json +1 -1
  10. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -0
  11. package/docs/adr/0134-private-first-npm-distribution.md +20 -6
  12. package/docs/adr/0135-module-upstream-submission-interim-queue.md +34 -0
  13. package/docs/adr/README.md +5 -4
  14. package/docs/api/openapi.json +315 -6
  15. package/docs/api-reference.md +10 -5
  16. package/docs/architecture.md +1 -1
  17. package/docs/file-map.md +2 -2
  18. package/docs/modules-contract.md +2 -0
  19. package/docs/recipes/instance-deploy.md +4 -4
  20. package/docs/recipes/instance-first-run.md +51 -0
  21. package/docs/recipes/packaging-the-core.md +5 -5
  22. package/docs/recipes/private-npm-distribution.md +22 -12
  23. package/docs/recipes/self-host.md +1 -1
  24. package/docs/recipes/upgrading-the-core.md +8 -8
  25. package/migrations/core_191_module_submissions.sql +45 -0
  26. package/modules/autonomy/CLAUDE.md +11 -45
  27. package/modules/builder-settings/CLAUDE.md +11 -54
  28. package/modules/dev-box/CLAUDE.md +11 -34
  29. package/modules/discord/CLAUDE.md +11 -46
  30. package/modules/economy/CLAUDE.md +11 -44
  31. package/modules/grading/CLAUDE.md +11 -38
  32. package/modules/hall-ui/CLAUDE.md +11 -57
  33. package/modules/hall-ui/public/goals-page.js +227 -22
  34. package/modules/hall-ui/public/goals.html +16 -0
  35. package/modules/hall-ui/public/new-project-wizard.js +5 -4
  36. package/modules/ideas/CLAUDE.md +11 -33
  37. package/modules/lifecycle/CLAUDE.md +11 -59
  38. package/modules/lifecycle/routes/goals.js +80 -0
  39. package/modules/memory/CLAUDE.md +11 -36
  40. package/modules/onboarding/CLAUDE.md +11 -51
  41. package/modules/security/CLAUDE.md +11 -40
  42. package/modules/sessions/CLAUDE.md +11 -35
  43. package/modules/status-ui/CLAUDE.md +11 -43
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/public-landing/create.html +58 -12
  47. package/public-landing/index.html +40 -21
  48. package/scripts/gds/CLAUDE.md +11 -140
  49. package/scripts/gds/claude-materialize.js +151 -18
  50. package/scripts/gds/claude-md-template.js +84 -0
  51. package/scripts/gds/cli-lib.js +112 -2
  52. package/scripts/gds/gen-api-client.js +10 -10
  53. package/scripts/gds/init.js +53 -13
  54. package/scripts/gds/migration-namespace.js +1 -1
  55. package/scripts/gds/module.js +118 -4
  56. package/scripts/gds/npmrc.js +93 -0
  57. package/scripts/gds/onboard.js +13 -24
  58. package/scripts/gds/package-core.js +56 -14
  59. package/scripts/gds/provision.js +102 -17
  60. package/scripts/gds/seed-adopt-tasks.js +1 -1
  61. package/scripts/gds/status.js +6 -1
  62. package/scripts/gds/task.js +166 -0
  63. package/scripts/gds/upgrade.js +58 -18
  64. package/src/bongos/CLAUDE.md +11 -60
  65. package/src/bongos/auth.js +11 -0
  66. package/src/bongos/module-submissions.js +56 -0
  67. package/src/bongos/route-rank-check.js +6 -0
  68. package/src/bongos/routes/CLAUDE.md +11 -39
  69. package/src/bongos/routes/auth.js +6 -1
  70. package/src/bongos/routes/modules.js +123 -21
  71. package/src/instance-config.js +1 -1
  72. package/src/module-api.js +13 -1
  73. package/tests/claude_materialize.mjs +105 -0
  74. package/tests/device_flow_start.mjs +73 -0
  75. package/tests/first_run_engine_check.mjs +79 -0
  76. package/tests/goal_membership_requests.mjs +33 -3
  77. package/tests/hook_user_agents.mjs +62 -0
  78. package/tests/init.mjs +59 -6
  79. package/tests/instance_mismatch_guard.mjs +101 -0
  80. package/tests/module_cli.mjs +130 -2
  81. package/tests/module_submissions.mjs +135 -0
  82. package/tests/npmrc.mjs +105 -0
  83. package/tests/onboard.mjs +49 -0
  84. package/tests/package_core.mjs +68 -22
  85. package/tests/platform_boot.mjs +5 -2
  86. package/tests/provision.mjs +107 -8
  87. package/tests/provisioning_github_app_routes.mjs +29 -2
  88. package/tests/served_paths_shipped.mjs +2 -2
  89. package/tests/task_cli.mjs +99 -0
  90. package/tests/upgrade.mjs +89 -25
@@ -172,7 +172,7 @@ function resolveInstanceRoot({ env = process.env } = {}) {
172
172
  // is written/read. A consumer that installs the core as an npm dep points at its
173
173
  // own repo (via <PREFIX>_INSTANCE_ROOT / bongos.config.json — e.g. `bongos
174
174
  // upgrade` sets it), so the docs+client regenerate INTO the instance, not into
175
- // node_modules/@cloudbongos/core. In a single checkout no pointer is set, so this
175
+ // node_modules/@bongos/core. In a single checkout no pointer is set, so this
176
176
  // is resolveCoreRoot() — BYTE-IDENTICAL to today (ADR 0108). Deliberately NOT the
177
177
  // cwd fallback: a generated-doc write must be deterministic.
178
178
  function resolveDocsRoot(opts = {}) {
package/src/module-api.js CHANGED
@@ -176,8 +176,20 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
176
176
  // the same reason validateOrRespond/LIMITS are on the doorway. Additive
177
177
  // (new exports, no export removed or changed) → MINOR; every `^1.x`
178
178
  // module keeps working, and a module using them is satisfied by 1.17.0.
179
+ // 1.17.1 — task 2124 (republish after task 2121): the FIRST PATCH bump on this
180
+ // surface. No doorway export is added, changed, or removed — the exported
181
+ // surface is byte-identical to 1.17.0. What changed is the PUBLISHED ARTIFACT's
182
+ // content: (a) task 2121 taught package-core to replace every CLAUDE.md/AGENTS.md
183
+ // in the @bongos/core tarball with a tiny brand-free stub, so a consumer instance's
184
+ // agent no longer auto-loads the platform's own project memory from
185
+ // node_modules/@bongos/core/ (the 2026-07-08 demo audit leak); (b) the bundled code
186
+ // carries the task-2126 clearer device_flow_disabled error surfacing. A backwards-
187
+ // compatible artifact fix with no API change is exactly semver PATCH. The bump is
188
+ // REQUIRED because 1.17.0 was already published to npm and task 2121 shipped
189
+ // without one, so `bongos package-core --publish` on 1.17.0 would be rejected as a
190
+ // re-publish. Every `^1.x` / `^1.17` module pin is satisfied by 1.17.1.
179
191
  // ---------------------------------------------------------------------------
180
- const CORE_VERSION = '1.17.0'; // see the changelog above — R14 adds the shared pagination helpers to the doorway (#2001)
192
+ const CORE_VERSION = '1.17.1'; // see the changelog above — 1.17.1 republishes the artifact with the task-2121 agent-doc stub (no doorway change) (task 2124)
181
193
 
182
194
  // A namespaced logger so a module's log lines are attributable + consistent.
183
195
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -144,5 +144,110 @@ t('copyTreeTransformed: overwrite defaults true → greenfield still clobbers (r
144
144
  rmSync(dest, { recursive: true, force: true });
145
145
  });
146
146
 
147
+ // ---- instance rebrand (task 2103a) -----------------------------------------
148
+ const OTB = { domains: { publicOrigin: 'https://amazonprimea.com', buildersOrigin: 'https://builders.amazonprimea.com', statusOrigin: 'https://status.amazonprimea.com' }, configDir: 'otb', envPrefix: 'OTB' };
149
+
150
+ t('buildRebrandRules: full target → founder origin/config-dir/prefix all rewritten', () => {
151
+ const to = { domains: { publicOrigin: 'https://demo.cloudbongos.com', buildersOrigin: 'https://demo.cloudbongos.com', statusOrigin: 'https://demo.cloudbongos.com' }, envPrefix: 'CLOUDBONGOS' };
152
+ const out = m.rebrandText('b="https://amazonprimea.com" h="builders.amazonprimea.com" ~/.config/otb OTB_API_BASE [otb]', m.buildRebrandRules(OTB, to));
153
+ assert.ok(!out.includes('amazonprimea'), `no founder host survives: ${out}`);
154
+ assert.ok(out.includes('https://demo.cloudbongos.com'), 'public origin rewritten');
155
+ assert.ok(out.includes('~/.config/cloudbongos') && out.includes('CLOUDBONGOS_API_BASE') && out.includes('[cloudbongos]'), 'config-dir + prefix + log tag rewritten');
156
+ });
157
+
158
+ t('buildRebrandRules: partial target (only publicOrigin) → subdomains fall back to target public host, not builders.<target>', () => {
159
+ const out = m.rebrandText('builders.amazonprimea.com', m.buildRebrandRules(OTB, { domains: { publicOrigin: 'https://acme.example.org' }, envPrefix: 'ACME' }));
160
+ assert.equal(out, 'acme.example.org', 'no builders.acme.example.org mangling');
161
+ });
162
+
163
+ t('buildRebrandRules: source === target is an empty ruleset → rebrandText is a no-op', () => {
164
+ assert.equal(m.buildRebrandRules(OTB, OTB).length, 0);
165
+ const s = 'https://amazonprimea.com ~/.config/otb';
166
+ assert.equal(m.rebrandText(s, m.buildRebrandRules(OTB, OTB)), s);
167
+ });
168
+
169
+ // ---- skill filtering by disabled module (task 2103b) -----------------------
170
+ t('disabledModuleSkills: only skills of DISABLED modules are collected (default + explicit override)', () => {
171
+ const core = mkdtempSync(join(tmpdir(), 'rb-core-'));
172
+ const inst = mkdtempSync(join(tmpdir(), 'rb-inst-'));
173
+ const mod = (key, def, skills) => { const d = join(core, 'modules', key); mkdirSync(d, { recursive: true }); writeFileSync(join(d, 'module.json'), JSON.stringify({ key, default: def, contributes: { skills } })); };
174
+ mod('foo', false, ['a']); // default off, but ENABLED in config → keep
175
+ mod('bar', true, ['b']); // default on, not overridden → keep
176
+ mod('baz', false, ['c']); // default off, not overridden → SKIP
177
+ mod('qux', true, ['d']); // default on, but DISABLED in config → SKIP
178
+ mkdirSync(join(inst, 'config'), { recursive: true });
179
+ writeFileSync(join(inst, 'config', 'modules.json'), JSON.stringify({ modules: { foo: true, qux: false } }));
180
+ const skip = m.disabledModuleSkills({ coreRoot: core, instanceDir: inst });
181
+ assert.deepEqual([...skip].sort(), ['c', 'd']);
182
+ rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true });
183
+ });
184
+
185
+ // ---- materializeClaude end-to-end: rebrand + skill-exclude (from the REAL repo) --
186
+ t('materializeClaude: copied hooks carry NO founder origin/config-dir; a disabled-module skill is excluded', () => {
187
+ const dir = mkdtempSync(join(tmpdir(), 'rb-e2e-'));
188
+ // Target instance: demo-branded, art-pipeline OFF (so its otb-* skills must be dropped).
189
+ mkdirSync(join(dir, 'config'), { recursive: true });
190
+ writeFileSync(join(dir, 'config', 'branding.json'), JSON.stringify({ domains: { publicOrigin: 'https://demo.cloudbongos.com', buildersOrigin: 'https://demo.cloudbongos.com', statusOrigin: 'https://demo.cloudbongos.com', oauthOrigin: 'https://demo.cloudbongos.com' }, configDir: 'cloudbongos', envPrefix: 'CLOUDBONGOS' }));
191
+ writeFileSync(join(dir, 'config', 'modules.json'), JSON.stringify({ modules: { 'art-pipeline': false } }));
192
+ const res = m.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
193
+ assert.ok(res.rebrandRules > 0, 'rebrand rules were built from the OTB→demo branding delta');
194
+
195
+ // (a) no copied hook may still name the founder origin or config dir.
196
+ const offenders = [];
197
+ const hooksDir = join(dir, '.claude', 'hooks');
198
+ if (existsSync(hooksDir)) {
199
+ const walk = (d) => { for (const n of readdirSync(d, { withFileTypes: true })) { const p = join(d, n.name); if (n.isDirectory()) walk(p); else { const txt = readFileSync(p, 'utf8'); if (txt.includes('amazonprimea.com') || txt.includes('.config/otb') || txt.includes('/etc/otb')) offenders.push(n.name); } } };
200
+ walk(hooksDir);
201
+ }
202
+ assert.deepEqual(offenders, [], `founder origin/config-dir survived in: ${offenders.join(', ')}`);
203
+
204
+ // (b) art-pipeline is off → its otb-* skills are excluded; a core skill remains.
205
+ assert.ok(res.excludedSkills.includes('otb-tile-generate'), `art-pipeline skill excluded (got ${res.excludedSkills.join(',')})`);
206
+ assert.ok(!existsSync(join(dir, '.claude', 'skills', 'otb-tile-generate')), 'excluded skill not written');
207
+ assert.ok(existsSync(join(dir, '.claude', 'skills', 'builder-ship')), 'a core skill is still materialized');
208
+ rmSync(dir, { recursive: true, force: true });
209
+ });
210
+
211
+ // ---- CLAUDE.md scaffold (task 2104) ----------------------------------------
212
+ const tmpl = require('../scripts/gds/claude-md-template.js');
213
+
214
+ t('renderInstanceClaudeMd: brand-substituted — names the instance + its hall/currency, no founder literal', () => {
215
+ const md = tmpl.renderInstanceClaudeMd({ identity: { productName: 'Acme', worldName: 'Acmeland', company: { name: 'Widgets Inc' } }, domains: { publicOrigin: 'https://acme.example.org', buildersOrigin: 'https://builders.acme.example.org', statusOrigin: 'https://status.acme.example.org' }, currency: { label: 'coins' } });
216
+ assert.ok(md.startsWith('# Acme — Project Memory'), 'titled with the product name');
217
+ assert.ok(md.includes('the Acmeland world') && md.includes('by Widgets Inc'), 'world + company woven in');
218
+ assert.ok(md.includes('https://builders.acme.example.org'), 'hall origin present');
219
+ assert.ok(md.includes('coins'), 'currency label present');
220
+ assert.ok(!md.toLowerCase().includes('amazonprimea') && !md.includes('~/.config/otb'), 'no founder literal in the starter');
221
+ });
222
+
223
+ t('renderInstanceClaudeMd: degrades cleanly on a minimal branding (no world dup, defaults currency)', () => {
224
+ const md = tmpl.renderInstanceClaudeMd({ identity: { productName: 'Solo', worldName: 'Solo' } });
225
+ assert.ok(md.startsWith('# Solo — Project Memory'));
226
+ assert.ok(!md.includes('the Solo world'), 'world == product is not duplicated');
227
+ assert.ok(md.includes('credits'), 'currency defaults to credits');
228
+ });
229
+
230
+ t('materializeClaude: writes a brand-substituted CLAUDE.md when the instance has none', () => {
231
+ const dir = mkdtempSync(join(tmpdir(), 'cm-md-'));
232
+ mkdirSync(join(dir, 'config'), { recursive: true });
233
+ writeFileSync(join(dir, 'config', 'branding.json'), JSON.stringify({ identity: { productName: 'Freshie' }, domains: { publicOrigin: 'https://freshie.example.com' }, configDir: 'freshie', envPrefix: 'FRESHIE' }));
234
+ const res = m.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
235
+ assert.equal(res.claudeMd, true, 'reported writing CLAUDE.md');
236
+ assert.ok(readFileSync(join(dir, 'CLAUDE.md'), 'utf8').startsWith('# Freshie — Project Memory'), 'written + brand-substituted');
237
+ rmSync(dir, { recursive: true, force: true });
238
+ });
239
+
240
+ t('materializeClaude: NEVER clobbers an existing CLAUDE.md (init / adopt / upgrade safe)', () => {
241
+ const dir = mkdtempSync(join(tmpdir(), 'cm-md2-'));
242
+ mkdirSync(join(dir, 'config'), { recursive: true });
243
+ writeFileSync(join(dir, 'config', 'branding.json'), JSON.stringify({ identity: { productName: 'Freshie' }, domains: { publicOrigin: 'https://freshie.example.com' } }));
244
+ const owner = '# My own CLAUDE.md — do not touch\n';
245
+ writeFileSync(join(dir, 'CLAUDE.md'), owner);
246
+ const res = m.materializeClaude({ coreRoot: ROOT, instanceDir: dir, dryRun: false });
247
+ assert.equal(res.claudeMd, false, 'did not (re)write');
248
+ assert.equal(readFileSync(join(dir, 'CLAUDE.md'), 'utf8'), owner, 'existing CLAUDE.md left byte-untouched');
249
+ rmSync(dir, { recursive: true, force: true });
250
+ });
251
+
147
252
  console.log(`\nclaude_materialize: ${passed} passed, ${failed} failed`);
148
253
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,73 @@
1
+ // tests/device_flow_start.mjs
2
+ //
3
+ // task 2126 (demo-audit fix C3) — startDeviceFlow() must surface a GitHub OAuth-App
4
+ // misconfig (the HTTP-200-with-{error} body: device_flow_disabled / bad client) as an
5
+ // ACTIONABLE thrown error, not silently drop the missing device_code into a broken flow
6
+ // (which the route then reported as an opaque 500 — the 2026-07-08 demo login failure).
7
+ //
8
+ // Hermetic: stubs globalThis.fetch (no network) + sets creds env so loadGithubCreds()
9
+ // succeeds. auth.js requires pool but never QUERIES it here (startDeviceFlow only fetches),
10
+ // so no DB. Run: node tests/device_flow_start.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+
15
+ // Creds must resolve before startDeviceFlow runs loadGithubCreds() — set legacy + prefixed.
16
+ for (const p of ['GDS', 'OTB', 'CLOUDBONGOS']) {
17
+ process.env[`${p}_GITHUB_CLIENT_ID`] = 'Iv1.testclientid00';
18
+ process.env[`${p}_GITHUB_CLIENT_SECRET`] = 'testsecret000000000000000000000000000000';
19
+ }
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const auth = require('../src/bongos/auth.js');
23
+
24
+ let passed = 0, failed = 0;
25
+ const realFetch = globalThis.fetch;
26
+ function stubFetch(status, jsonBody) {
27
+ globalThis.fetch = async () => ({
28
+ ok: status >= 200 && status < 300,
29
+ status,
30
+ json: async () => jsonBody,
31
+ text: async () => JSON.stringify(jsonBody),
32
+ });
33
+ }
34
+ async function t(name, fn) {
35
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
36
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
37
+ finally { globalThis.fetch = realFetch; }
38
+ }
39
+
40
+ await t('device_flow_disabled (HTTP 200 + error body) → throws an actionable, named error', async () => {
41
+ stubFetch(200, { error: 'device_flow_disabled', error_description: 'Device Flow is not enabled for this app.' });
42
+ await assert.rejects(
43
+ () => auth.startDeviceFlow(),
44
+ (err) => /device_flow_disabled/.test(err.message) && /device-flow start/i.test(err.message),
45
+ );
46
+ });
47
+
48
+ await t('incorrect_client_credentials (HTTP 200 + error) → throws, names the error', async () => {
49
+ stubFetch(200, { error: 'incorrect_client_credentials', error_description: 'The client_id is not valid.' });
50
+ await assert.rejects(() => auth.startDeviceFlow(), (err) => /incorrect_client_credentials/.test(err.message));
51
+ });
52
+
53
+ await t('happy path (valid device/code body) → returns the flow fields', async () => {
54
+ stubFetch(200, { device_code: 'dc_abc', user_code: 'WXYZ-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 });
55
+ const out = await auth.startDeviceFlow();
56
+ assert.equal(out.device_code, 'dc_abc');
57
+ assert.equal(out.user_code, 'WXYZ-1234');
58
+ assert.equal(out.verification_uri, 'https://github.com/login/device');
59
+ assert.equal(out.interval, 5);
60
+ });
61
+
62
+ await t('malformed 200 (no device_code, no error) → throws rather than a broken flow', async () => {
63
+ stubFetch(200, {});
64
+ await assert.rejects(() => auth.startDeviceFlow(), (err) => /device_code/.test(err.message));
65
+ });
66
+
67
+ await t('non-2xx from GitHub still throws (pre-existing guard preserved)', async () => {
68
+ stubFetch(404, { message: 'Not Found' });
69
+ await assert.rejects(() => auth.startDeviceFlow(), (err) => /device\/code failed \(404\)/.test(err.message));
70
+ });
71
+
72
+ console.log(`\ndevice-flow start (task 2126) tests: ${passed} passed, ${failed} failed`);
73
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,79 @@
1
+ // tests/first_run_engine_check.mjs
2
+ //
3
+ // The SessionStart first-run engine check (task 2105): a freshly-cloned standalone
4
+ // instance that hasn't run `npm install` has no `bongos`/tools, so a session used
5
+ // to improvise against the wrong instance (the 2026-07-08 demo-session failure).
6
+ // firstRunEngineCheck() detects that state and prints the two first-run commands.
7
+ // It reads package.json + config/branding.json directly (never requires the missing
8
+ // core), is inert on a source/monorepo checkout and once installed, and is injectable
9
+ // (projectDir + log) so it unit-tests standalone. Run: node tests/first_run_engine_check.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const { firstRunEngineCheck } = require('../.claude/hooks/session-start.js');
19
+
20
+ let passed = 0, failed = 0;
21
+ function t(name, fn) {
22
+ try { fn(); passed++; console.log(` ok ${name}`); }
23
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
24
+ }
25
+
26
+ // Build a temp instance dir; deps + whether node_modules/<dep> exists + branding are configurable.
27
+ function mkInstance({ dep, installed = false, branding } = {}) {
28
+ const dir = mkdtempSync(join(tmpdir(), 'fre-'));
29
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: dep ? { [dep]: 'file:vendor/x.tgz' } : { express: '^4' } }));
30
+ if (installed && dep) mkdirSync(join(dir, 'node_modules', dep), { recursive: true });
31
+ if (branding) { mkdirSync(join(dir, 'config'), { recursive: true }); writeFileSync(join(dir, 'config', 'branding.json'), JSON.stringify(branding)); }
32
+ return dir;
33
+ }
34
+ const run = (dir) => { const lines = []; const fired = firstRunEngineCheck(dir, { log: (s) => lines.push(s) }); return { fired, out: lines.join('\n') }; };
35
+
36
+ t('consumer instance, engine NOT installed → prints the two first-run commands + the login origin', () => {
37
+ const dir = mkInstance({ dep: '@cloudbongos/core', installed: false, branding: { envPrefix: 'CLOUDBONGOS', domains: { publicOrigin: 'https://demo.cloudbongos.com' } } });
38
+ const { fired, out } = run(dir);
39
+ assert.equal(fired, true);
40
+ assert.ok(out.includes("engine isn't installed"), 'names the problem');
41
+ assert.ok(out.includes('npm install'), 'step 1');
42
+ assert.ok(out.includes('npx bongos login https://demo.cloudbongos.com'), 'step 2 with the instance origin');
43
+ assert.ok(out.includes('[cloudbongos]'), 'tag from branding envPrefix');
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ t('source / monorepo checkout (no core dependency) → INERT (no output)', () => {
48
+ const dir = mkInstance({ dep: null });
49
+ const { fired, out } = run(dir);
50
+ assert.equal(fired, false);
51
+ assert.equal(out, '');
52
+ rmSync(dir, { recursive: true, force: true });
53
+ });
54
+
55
+ t('consumer instance, engine INSTALLED → INERT (nothing to say)', () => {
56
+ const dir = mkInstance({ dep: '@cloudbongos/core', installed: true, branding: { domains: { publicOrigin: 'https://demo.cloudbongos.com' } } });
57
+ const { fired } = run(dir);
58
+ assert.equal(fired, false);
59
+ rmSync(dir, { recursive: true, force: true });
60
+ });
61
+
62
+ t('accepts the @bongos/core and cloudbongos dependency names too', () => {
63
+ for (const dep of ['@bongos/core', 'cloudbongos']) {
64
+ const dir = mkInstance({ dep, installed: false, branding: { domains: { publicOrigin: 'https://x.example.com' } } });
65
+ assert.equal(run(dir).fired, true, `${dep} recognized as the core dep`);
66
+ rmSync(dir, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ t('missing config/branding.json → still fires, with a placeholder login target (never crashes)', () => {
71
+ const dir = mkInstance({ dep: '@cloudbongos/core', installed: false });
72
+ const { fired, out } = run(dir);
73
+ assert.equal(fired, true);
74
+ assert.ok(out.includes('npx bongos login <this-instance-url>'), 'placeholder when origin unknown');
75
+ rmSync(dir, { recursive: true, force: true });
76
+ });
77
+
78
+ console.log(`\nfirst-run engine check (task 2105): ${passed} passed, ${failed} failed`);
79
+ process.exit(failed ? 1 : 0);
@@ -40,6 +40,7 @@ const {
40
40
  authorizeCreateJoinRequest,
41
41
  authorizeInvitationResponse,
42
42
  authorizeJoinRequestResponse,
43
+ authorizeWithdrawJoinRequest,
43
44
  } = require('../modules/lifecycle/routes/goals.js');
44
45
  const { MODULE_EXPECTED_RANKS } = require('../src/bongos/route-rank-check.js');
45
46
 
@@ -310,12 +311,39 @@ await test('authorizeJoinRequestResponse: an Archon (non-member) resolves → AL
310
311
  assert.deepEqual(authorizeJoinRequestResponse({ goal: OWNER_GOAL, actorId: 42, actorRank: 'archon', actorMembership: null, request: REQUEST, goalId: 5 }), { ok: true });
311
312
  });
312
313
 
314
+ // authorizeWithdrawJoinRequest — only the requester withdraws their own pending
315
+ // join-request (task 1953), a symmetric mirror of authorizeInvitationResponse.
316
+ const REQUEST_FOR_WITHDRAW = { id: 11, goal_id: '5', builder_id: '2', kind: 'request', status: 'pending' };
317
+ await test('authorizeWithdrawJoinRequest: unknown request → 404', () => {
318
+ const d = authorizeWithdrawJoinRequest({ request: null, goalId: 5, actorId: 2 });
319
+ assert.equal(d.status, 404); assert.equal(d.body.error, 'request_not_found');
320
+ });
321
+ await test('authorizeWithdrawJoinRequest: request on a different goal → 404 (containment)', () => {
322
+ const d = authorizeWithdrawJoinRequest({ request: { ...REQUEST_FOR_WITHDRAW, goal_id: '99' }, goalId: 5, actorId: 2 });
323
+ assert.equal(d.status, 404);
324
+ });
325
+ await test('authorizeWithdrawJoinRequest: row is an invitation, not a request → 400', () => {
326
+ const d = authorizeWithdrawJoinRequest({ request: { ...REQUEST_FOR_WITHDRAW, kind: 'invite' }, goalId: 5, actorId: 2 });
327
+ assert.equal(d.status, 400); assert.equal(d.body.error, 'not_a_join_request');
328
+ });
329
+ await test('authorizeWithdrawJoinRequest: already resolved → 409 not_pending', () => {
330
+ const d = authorizeWithdrawJoinRequest({ request: { ...REQUEST_FOR_WITHDRAW, status: 'accepted' }, goalId: 5, actorId: 2 });
331
+ assert.equal(d.status, 409); assert.equal(d.body.error, 'not_pending');
332
+ });
333
+ await test('authorizeWithdrawJoinRequest: someone else withdrawing my request → 403 not_your_request', () => {
334
+ const d = authorizeWithdrawJoinRequest({ request: REQUEST_FOR_WITHDRAW, goalId: 5, actorId: 7 });
335
+ assert.equal(d.status, 403); assert.equal(d.body.error, 'not_your_request');
336
+ });
337
+ await test('authorizeWithdrawJoinRequest: the requester (string vs number id) → ALLOW', () => {
338
+ assert.deepEqual(authorizeWithdrawJoinRequest({ request: REQUEST_FOR_WITHDRAW, goalId: '5', actorId: 2 }), { ok: true });
339
+ });
340
+
313
341
  // =========================================================================
314
- // route-rank pins (task 1952) — the walls are in-handler, so the routes stay
315
- // 'any-builder'; an accidental requireRank re-gate must be a red build.
342
+ // route-rank pins (task 1952/1953) — the walls are in-handler, so the routes
343
+ // stay 'any-builder'; an accidental requireRank re-gate must be a red build.
316
344
  // =========================================================================
317
345
 
318
- await test('route-rank pins: the six new routes are pinned any-builder', () => {
346
+ await test('route-rank pins: the eight private-goal routes are pinned any-builder', () => {
319
347
  const pins = MODULE_EXPECTED_RANKS.lifecycle;
320
348
  for (const r of [
321
349
  'GET /goals/inbox',
@@ -324,6 +352,8 @@ await test('route-rank pins: the six new routes are pinned any-builder', () => {
324
352
  'POST /goals/:id/requests/:reqId/respond',
325
353
  'POST /goals/:id/invitations',
326
354
  'POST /goals/:id/invitations/:reqId/respond',
355
+ 'GET /goals/:id/requests',
356
+ 'POST /goals/:id/requests/:reqId/withdraw',
327
357
  ]) {
328
358
  assert.equal(pins[r], 'any-builder', `${r} must be pinned any-builder (wall is in-handler)`);
329
359
  }
@@ -0,0 +1,62 @@
1
+ // tests/hook_user_agents.mjs
2
+ //
3
+ // task 2122 (demo-audit fix B) — the scaffolded .claude/hooks must NOT hardcode a
4
+ // founder-instance brand in their outbound HTTP User-Agent. Those hooks are copied
5
+ // into a consumer instance by claude-materialize, whose rebrand maps domain HOSTS
6
+ // (amazonprimea.com), config dirs, and env prefixes — but NOT a bare brand token
7
+ // like `amazonprimea-gds-cli` (no TLD). So a branded UA there leaks the founder's
8
+ // host-brand into every scaffolded instance (criterion gcarry-prod-vanilla).
9
+ //
10
+ // Invariant: every `'User-Agent': '<value>'` literal in .claude/hooks/*.js uses the
11
+ // platform-neutral `cloud-bongos-` prefix (matching src/branding.js userAgent()'s
12
+ // neutral fallback), never an instance brand. Hermetic: reads files only, no DB/net.
13
+ //
14
+ // Run: node tests/hook_user_agents.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
22
+ const HOOKS_DIR = path.join(HERE, '..', '.claude', 'hooks');
23
+
24
+ // Every User-Agent request-header literal, single- or double-quoted.
25
+ const UA_RE = /['"]User-Agent['"]\s*:\s*['"]([^'"]+)['"]/g;
26
+ const NEUTRAL_PREFIX = 'cloud-bongos-';
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ function t(name, fn) {
31
+ try { fn(); passed++; console.log(` PASS ${name}`); }
32
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
33
+ }
34
+
35
+ const hookFiles = fs.existsSync(HOOKS_DIR)
36
+ ? fs.readdirSync(HOOKS_DIR).filter((f) => f.endsWith('.js')).sort()
37
+ : [];
38
+
39
+ t('.claude/hooks exists and has hook files to check', () => {
40
+ assert.ok(hookFiles.length > 0, `no hook .js files found under ${HOOKS_DIR}`);
41
+ });
42
+
43
+ t('every hook User-Agent uses the neutral cloud-bongos- prefix (no founder-brand leak)', () => {
44
+ const offenders = [];
45
+ let uaCount = 0;
46
+ for (const f of hookFiles) {
47
+ const src = fs.readFileSync(path.join(HOOKS_DIR, f), 'utf8');
48
+ for (const m of src.matchAll(UA_RE)) {
49
+ uaCount++;
50
+ const ua = m[1];
51
+ if (!ua.startsWith(NEUTRAL_PREFIX)) offenders.push(`${f}: '${ua}'`);
52
+ }
53
+ }
54
+ assert.ok(uaCount > 0, 'expected at least one User-Agent literal across the hooks (regex drift?)');
55
+ assert.equal(
56
+ offenders.length, 0,
57
+ `hook User-Agent(s) not neutral (must start with "${NEUTRAL_PREFIX}"):\n ${offenders.join('\n ')}`
58
+ );
59
+ });
60
+
61
+ console.log(`\n${passed} passed, ${failed} failed`);
62
+ process.exit(failed ? 1 : 0);
package/tests/init.mjs CHANGED
@@ -336,12 +336,12 @@ test('buildInstancePackageJson: no package.json → mints a private one with the
336
336
  assert.equal(pkg.scripts.start, init.INSTANCE_START_SCRIPT);
337
337
  assert.equal(pkg.scripts.migrate, init.INSTANCE_MIGRATE_SCRIPT);
338
338
  // the start/migrate scripts must target the installed core package so `npm ci` output is runnable
339
- assert.match(pkg.scripts.start, /node_modules\/@cloudbongos\/core\/src\/platform-server\.js$/);
340
- assert.match(pkg.scripts.migrate, /node_modules\/@cloudbongos\/core\/scripts\/migrate\.sh$/);
339
+ assert.match(pkg.scripts.start, /node_modules\/@bongos\/core\/src\/platform-server\.js$/);
340
+ assert.match(pkg.scripts.migrate, /node_modules\/@bongos\/core\/scripts\/migrate\.sh$/);
341
341
  });
342
342
 
343
343
  test('buildInstancePackageJson: coreDep (file: tarball pin) → the dep is the file: pin, not the caret', () => {
344
- const pin = 'file:vendor/cloudbongos-core-1.17.0.tgz';
344
+ const pin = 'file:vendor/bongos-core-1.17.0.tgz';
345
345
  const pkg = init.buildInstancePackageJson(null, { name: 'demo', coreDep: pin });
346
346
  assert.equal(pkg.dependencies[init.CORE_PKG], pin);
347
347
  assert.notEqual(pkg.dependencies[init.CORE_PKG], init.coreDepRange());
@@ -349,13 +349,13 @@ test('buildInstancePackageJson: coreDep (file: tarball pin) → the dep is the f
349
349
 
350
350
  test('buildInstancePackageJson: preserves existing fields + never clobbers a custom start/migrate', () => {
351
351
  const existing = { name: 'keep-me', version: '2.0.0', scripts: { start: 'node server.js' }, keywords: ['x'] };
352
- const pkg = init.buildInstancePackageJson(existing, { name: 'ignored', coreDep: 'file:vendor/cloudbongos-core-1.17.0.tgz' });
352
+ const pkg = init.buildInstancePackageJson(existing, { name: 'ignored', coreDep: 'file:vendor/bongos-core-1.17.0.tgz' });
353
353
  assert.equal(pkg.name, 'keep-me'); // existing name wins
354
354
  assert.equal(pkg.version, '2.0.0'); // untouched field preserved
355
355
  assert.deepEqual(pkg.keywords, ['x']);
356
356
  assert.equal(pkg.scripts.start, 'node server.js'); // custom start NOT clobbered
357
357
  assert.equal(pkg.scripts.migrate, init.INSTANCE_MIGRATE_SCRIPT); // missing one is filled
358
- assert.equal(pkg.dependencies[init.CORE_PKG], 'file:vendor/cloudbongos-core-1.17.0.tgz'); // dep (re)pinned
358
+ assert.equal(pkg.dependencies[init.CORE_PKG], 'file:vendor/bongos-core-1.17.0.tgz'); // dep (re)pinned
359
359
  });
360
360
 
361
361
  test('writeConfigs (greenfield): mints package.json with the caret core dep + start/migrate scripts', () => {
@@ -377,7 +377,7 @@ test('writeConfigs (greenfield): mints package.json with the caret core dep + st
377
377
  test('writeConfigs (greenfield, --vendor-core): package.json pins the file: tarball instead of the caret', () => {
378
378
  const dir = mkdtempSync(join(tmpdir(), 'greenfield-'));
379
379
  try {
380
- const pin = 'file:vendor/cloudbongos-core-1.17.0.tgz';
380
+ const pin = 'file:vendor/bongos-core-1.17.0.tgz';
381
381
  const res = init.writeConfigs(fullSpec(), { dir, force: false, dryRun: false, coreDep: pin, log: () => {} });
382
382
  const pkg = JSON.parse(readFileSync(res.pkgPath, 'utf8'));
383
383
  assert.equal(pkg.dependencies[init.CORE_PKG], pin);
@@ -456,7 +456,60 @@ async function runSeedTests() {
456
456
  });
457
457
  }
458
458
 
459
+ // ---- greenfield registry-install path (ADR 0134, task 2090) ----------------
460
+ // main() with --registry-core --dry-run only LOGS (no fs, no network), so we capture
461
+ // console output and assert the plan. The real write+install path is proven by the
462
+ // CLI scratch run in the session log; the pure gates live in tests/npmrc.mjs.
463
+ async function runRegistryCoreTests() {
464
+ const at = async (name, fn) => {
465
+ try { await fn(); passed++; console.log(` ok ${name}`); }
466
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
467
+ };
468
+ const specFile = () => {
469
+ const dir = mkdtempSync(join(tmpdir(), 'init-reg-'));
470
+ const p = join(dir, 'spec.json');
471
+ writeFileSync(p, JSON.stringify({
472
+ identity: { productName: 'Reg Test' }, repo: { owner: 'acme', name: 'reg-test' }, firstAdmin: 'acme',
473
+ currency: { label: 'credits' }, domains: { publicOrigin: 'http://localhost:3000' }, modules: [],
474
+ version: { id: 'V1', name: 'V1', done_when: 'loop works' },
475
+ }));
476
+ return { dir, p };
477
+ };
478
+ // Run init.main() with console captured; returns { code, out }.
479
+ const capture = async (argv) => {
480
+ const lines = [];
481
+ const origLog = console.log, origErr = console.error;
482
+ console.log = (...a) => lines.push(a.join(' '));
483
+ console.error = (...a) => lines.push(a.join(' '));
484
+ let code;
485
+ try { code = await init.main(['node', 'init', ...argv]); }
486
+ finally { console.log = origLog; console.error = origErr; }
487
+ return { code, out: lines.join('\n') };
488
+ };
489
+
490
+ await at('main --registry-core --dry-run: plans the env-fed .npmrc; does NOT vendor a tarball', async () => {
491
+ const { dir, p } = specFile();
492
+ try {
493
+ const { code, out } = await capture(['--from', p, '--dir', join(dir, 'out'), '--no-seed', '--registry-core', '--dry-run']);
494
+ assert.equal(code, 0);
495
+ assert.match(out, /would write \.npmrc \(env-fed @bongos auth\)/);
496
+ assert.ok(!/vendor/i.test(out), 'no vendored-tarball step on the registry path');
497
+ assert.equal(existsSync(join(dir, 'out', '.npmrc')), false, 'dry-run writes nothing');
498
+ } finally { rmSync(dir, { recursive: true, force: true }); }
499
+ });
500
+
501
+ await at('main: --vendor-core + --registry-core is refused (mutually exclusive) → exit 1', async () => {
502
+ const { dir, p } = specFile();
503
+ try {
504
+ const { code, out } = await capture(['--from', p, '--dir', join(dir, 'out'), '--no-seed', '--vendor-core', '/tmp/x.tgz', '--registry-core', '--dry-run']);
505
+ assert.equal(code, 1);
506
+ assert.match(out, /mutually exclusive/);
507
+ } finally { rmSync(dir, { recursive: true, force: true }); }
508
+ });
509
+ }
510
+
459
511
  await runSeedTests();
512
+ await runRegistryCoreTests();
460
513
 
461
514
  console.log('');
462
515
  console.log(`init tests: ${passed} passed, ${failed} failed`);
@@ -0,0 +1,101 @@
1
+ // tests/instance_mismatch_guard.mjs
2
+ //
3
+ // Unit test for the PURE fail-closed instance guard exported from
4
+ // scripts/gds/cli-lib.js (task 2102):
5
+ // - instanceOriginHost(origin) → host string, or null if unparseable
6
+ // - detectInstanceMismatch({ session, brand, env }) → { mismatch, expected, actual }
7
+ //
8
+ // The guard stops the CLI from silently borrowing a DIFFERENT instance's session
9
+ // (the 2026-07-08 demo-session failure, where a fresh demo clone used the
10
+ // founder's amazonprimea.com session and offered to write into the wrong live
11
+ // backlog). Both helpers are deterministic with no DB/network/fs, so they
12
+ // unit-test standalone. Run: node tests/instance_mismatch_guard.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const cli = require('../scripts/gds/cli-lib.js');
19
+
20
+ let passed = 0;
21
+ let failed = 0;
22
+ function test(name, fn) {
23
+ try { fn(); passed++; console.log(` ok ${name}`); }
24
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
25
+ }
26
+
27
+ const DEMO = { domains: { publicOrigin: 'https://demo.cloudbongos.com' } };
28
+ const OTB = { domains: { publicOrigin: 'https://amazonprimea.com' } };
29
+ const noEnv = {}; // no <PREFIX>_API_BASE override in scope
30
+
31
+ test('instanceOriginHost: extracts host, tolerates trailing slash', () => {
32
+ assert.equal(cli.instanceOriginHost('https://amazonprimea.com'), 'amazonprimea.com');
33
+ assert.equal(cli.instanceOriginHost('https://amazonprimea.com/'), 'amazonprimea.com');
34
+ assert.equal(cli.instanceOriginHost('https://demo.cloudbongos.com/api/gds'), 'demo.cloudbongos.com');
35
+ });
36
+
37
+ test('instanceOriginHost: unparseable input → null (edge)', () => {
38
+ assert.equal(cli.instanceOriginHost('not a url'), null);
39
+ assert.equal(cli.instanceOriginHost(''), null);
40
+ assert.equal(cli.instanceOriginHost(null), null);
41
+ });
42
+
43
+ test('match: same-host session + branding → no mismatch (the OTB/normal case)', () => {
44
+ const r = cli.detectInstanceMismatch({ session: { api_base: 'https://amazonprimea.com' }, brand: OTB, env: noEnv });
45
+ assert.equal(r.mismatch, false);
46
+ });
47
+
48
+ test('match: scheme/trailing-slash differences still match on host', () => {
49
+ const r = cli.detectInstanceMismatch({ session: { api_base: 'https://amazonprimea.com/' }, brand: OTB, env: noEnv });
50
+ assert.equal(r.mismatch, false);
51
+ });
52
+
53
+ test('mismatch: demo repo + founder amazonprimea session → REFUSE (the reported failure)', () => {
54
+ const r = cli.detectInstanceMismatch({ session: { api_base: 'https://amazonprimea.com' }, brand: DEMO, env: noEnv });
55
+ assert.equal(r.mismatch, true);
56
+ assert.equal(r.expected, 'https://demo.cloudbongos.com');
57
+ assert.equal(r.actual, 'https://amazonprimea.com');
58
+ });
59
+
60
+ test('mismatch: demo repo + cloudbongos.com session (sibling instance) → REFUSE', () => {
61
+ const r = cli.detectInstanceMismatch({ session: { api_base: 'https://cloudbongos.com' }, brand: DEMO, env: noEnv });
62
+ assert.equal(r.mismatch, true);
63
+ });
64
+
65
+ test('env-override: an explicit *_API_BASE override wins (dev/staging) → no mismatch', () => {
66
+ const r = cli.detectInstanceMismatch({
67
+ session: { api_base: 'https://amazonprimea.com' }, brand: DEMO,
68
+ env: { GDS_API_BASE: 'http://localhost:3000' },
69
+ });
70
+ assert.equal(r.mismatch, false);
71
+ });
72
+
73
+ test('no-branding: repo declares no publicOrigin → cannot assert a mismatch → no mismatch', () => {
74
+ assert.equal(cli.detectInstanceMismatch({ session: { api_base: 'https://amazonprimea.com' }, brand: {}, env: noEnv }).mismatch, false);
75
+ assert.equal(cli.detectInstanceMismatch({ session: { api_base: 'https://amazonprimea.com' }, brand: { domains: {} }, env: noEnv }).mismatch, false);
76
+ });
77
+
78
+ test('no-session: nothing to borrow → no mismatch (the no-session/401 path handles it)', () => {
79
+ assert.equal(cli.detectInstanceMismatch({ session: null, brand: DEMO, env: noEnv }).mismatch, false);
80
+ assert.equal(cli.detectInstanceMismatch({ session: {}, brand: DEMO, env: noEnv }).mismatch, false);
81
+ });
82
+
83
+ // task 2124 / demo-audit C2 — assertInstanceMatch is the exit-wrapper that RAW-fetch read
84
+ // commands (status.js) call so they honor the same guard apiCall/cliClient already enforce.
85
+ // Its decision reuses detectInstanceMismatch (covered above); here we lock the wrapper's
86
+ // contract + its deterministic opt-out branch (the exiting branch would process.exit).
87
+ test('assertInstanceMatch is exported (the guard raw-fetch reads must call)', () => {
88
+ assert.equal(typeof cli.assertInstanceMatch, 'function');
89
+ });
90
+
91
+ // Async opt-out branch — the sync test() helper can't await, so run it at top level.
92
+ // { allowInstanceMismatch: true } returns BEFORE loadSession/detect (no fs, no exit).
93
+ try {
94
+ await cli.assertInstanceMatch({ allowInstanceMismatch: true });
95
+ passed++; console.log(' ok assertInstanceMatch({allowInstanceMismatch:true}) resolves without exiting');
96
+ } catch (err) {
97
+ failed++; console.log(' FAIL assertInstanceMatch opt-out resolves without exiting'); console.log(` ${err.message}`);
98
+ }
99
+
100
+ console.log(`\ninstance-mismatch guard (task 2102 + C2 raw-read wrapper) tests: ${passed} passed, ${failed} failed`);
101
+ process.exit(failed > 0 ? 1 : 0);