@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.
- package/.bongos-core.json +154 -83
- package/CLAUDE.md +11 -238
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +4 -4
- package/clients/bongos-client/bongos-client.global.js +14 -1
- package/clients/bongos-client/index.cjs +14 -1
- package/clients/bongos-client/index.d.ts +19 -2
- package/clients/bongos-client/index.mjs +11 -1
- package/clients/bongos-client/package.json +1 -1
- package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -0
- package/docs/adr/0134-private-first-npm-distribution.md +20 -6
- package/docs/adr/0135-module-upstream-submission-interim-queue.md +34 -0
- package/docs/adr/README.md +5 -4
- package/docs/api/openapi.json +315 -6
- package/docs/api-reference.md +10 -5
- package/docs/architecture.md +1 -1
- package/docs/file-map.md +2 -2
- package/docs/modules-contract.md +2 -0
- package/docs/recipes/instance-deploy.md +4 -4
- package/docs/recipes/instance-first-run.md +51 -0
- package/docs/recipes/packaging-the-core.md +5 -5
- package/docs/recipes/private-npm-distribution.md +22 -12
- package/docs/recipes/self-host.md +1 -1
- package/docs/recipes/upgrading-the-core.md +8 -8
- package/migrations/core_191_module_submissions.sql +45 -0
- package/modules/autonomy/CLAUDE.md +11 -45
- package/modules/builder-settings/CLAUDE.md +11 -54
- package/modules/dev-box/CLAUDE.md +11 -34
- package/modules/discord/CLAUDE.md +11 -46
- package/modules/economy/CLAUDE.md +11 -44
- package/modules/grading/CLAUDE.md +11 -38
- package/modules/hall-ui/CLAUDE.md +11 -57
- package/modules/hall-ui/public/goals-page.js +227 -22
- package/modules/hall-ui/public/goals.html +16 -0
- package/modules/hall-ui/public/new-project-wizard.js +5 -4
- package/modules/ideas/CLAUDE.md +11 -33
- package/modules/lifecycle/CLAUDE.md +11 -59
- package/modules/lifecycle/routes/goals.js +80 -0
- package/modules/memory/CLAUDE.md +11 -36
- package/modules/onboarding/CLAUDE.md +11 -51
- package/modules/security/CLAUDE.md +11 -40
- package/modules/sessions/CLAUDE.md +11 -35
- package/modules/status-ui/CLAUDE.md +11 -43
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +58 -12
- package/public-landing/index.html +40 -21
- package/scripts/gds/CLAUDE.md +11 -140
- package/scripts/gds/claude-materialize.js +151 -18
- package/scripts/gds/claude-md-template.js +84 -0
- package/scripts/gds/cli-lib.js +112 -2
- package/scripts/gds/gen-api-client.js +10 -10
- package/scripts/gds/init.js +53 -13
- package/scripts/gds/migration-namespace.js +1 -1
- package/scripts/gds/module.js +118 -4
- package/scripts/gds/npmrc.js +93 -0
- package/scripts/gds/onboard.js +13 -24
- package/scripts/gds/package-core.js +56 -14
- package/scripts/gds/provision.js +102 -17
- package/scripts/gds/seed-adopt-tasks.js +1 -1
- package/scripts/gds/status.js +6 -1
- package/scripts/gds/task.js +166 -0
- package/scripts/gds/upgrade.js +58 -18
- package/src/bongos/CLAUDE.md +11 -60
- package/src/bongos/auth.js +11 -0
- package/src/bongos/module-submissions.js +56 -0
- package/src/bongos/route-rank-check.js +6 -0
- package/src/bongos/routes/CLAUDE.md +11 -39
- package/src/bongos/routes/auth.js +6 -1
- package/src/bongos/routes/modules.js +123 -21
- package/src/instance-config.js +1 -1
- package/src/module-api.js +13 -1
- package/tests/claude_materialize.mjs +105 -0
- package/tests/device_flow_start.mjs +73 -0
- package/tests/first_run_engine_check.mjs +79 -0
- package/tests/goal_membership_requests.mjs +33 -3
- package/tests/hook_user_agents.mjs +62 -0
- package/tests/init.mjs +59 -6
- package/tests/instance_mismatch_guard.mjs +101 -0
- package/tests/module_cli.mjs +130 -2
- package/tests/module_submissions.mjs +135 -0
- package/tests/npmrc.mjs +105 -0
- package/tests/onboard.mjs +49 -0
- package/tests/package_core.mjs +68 -22
- package/tests/platform_boot.mjs +5 -2
- package/tests/provision.mjs +107 -8
- package/tests/provisioning_github_app_routes.mjs +29 -2
- package/tests/served_paths_shipped.mjs +2 -2
- package/tests/task_cli.mjs +99 -0
- package/tests/upgrade.mjs +89 -25
package/src/instance-config.js
CHANGED
|
@@ -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/@
|
|
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.
|
|
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
|
|
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
|
|
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\/@
|
|
340
|
-
assert.match(pkg.scripts.migrate, /node_modules\/@
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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);
|