@bongos/core 1.18.0 → 1.18.3

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 (113) hide show
  1. package/.bongos-core.json +132 -322
  2. package/README.md +1 -1
  3. package/config/branding.neutral.json +5 -1
  4. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  5. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  6. package/docs/adr/README.md +5 -2
  7. package/docs/api/openapi.json +1789 -450
  8. package/docs/api-reference.md +52 -5
  9. package/docs/file-map.md +7 -7
  10. package/docs/recipes/packaging-the-core.md +13 -0
  11. package/docs/recipes/upgrading-the-core.md +5 -2
  12. package/migrations/039_newcomer_friendly_column.sql +5 -0
  13. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  14. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  15. package/modules/hall-ui/public/my-projects.html +78 -0
  16. package/modules/hall-ui/public/my-projects.js +142 -0
  17. package/modules/hall-ui/public/scouting.html +45 -0
  18. package/modules/hall-ui/public/scouting.js +73 -0
  19. package/modules/status-ui/public/explore.html +54 -0
  20. package/modules/status-ui/public/explore.js +67 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/public-landing/create.html +211 -14
  24. package/scripts/gds/cli-lib.js +11 -5
  25. package/scripts/gds/lint-migrations.js +102 -0
  26. package/scripts/gds/login.js +42 -10
  27. package/scripts/gds/mirror-redact.js +5 -0
  28. package/scripts/gds/package-core.js +263 -4
  29. package/scripts/gds/provision.js +145 -7
  30. package/scripts/gds/publish-manifest.js +15 -0
  31. package/scripts/gds/run-unit-tests.js +38 -14
  32. package/scripts/gds/setup.js +41 -0
  33. package/scripts/gds/ship.js +115 -31
  34. package/scripts/gds/upgrade.js +147 -5
  35. package/scripts/migrate.sh +10 -0
  36. package/src/bongos/auth.js +362 -5
  37. package/src/bongos/db.js +66 -0
  38. package/src/bongos/github-repos.js +93 -0
  39. package/src/bongos/routes/auth.js +93 -11
  40. package/src/bongos/routes/github.js +90 -0
  41. package/src/bongos/routes/instance.js +15 -3
  42. package/src/bongos/routes.js +2 -0
  43. package/src/branding.js +17 -2
  44. package/src/instance-config.js +16 -0
  45. package/src/module-api.js +29 -1
  46. package/tests/branding.mjs +29 -23
  47. package/tests/github_repos_helpers.mjs +91 -0
  48. package/tests/github_token_store.mjs +121 -0
  49. package/tests/idp_device_exchange.mjs +105 -0
  50. package/tests/idp_federation.mjs +100 -0
  51. package/tests/migrations_forward_safe.mjs +86 -0
  52. package/tests/module_api.mjs +1 -1
  53. package/tests/package_core.mjs +246 -1
  54. package/tests/provision.mjs +157 -0
  55. package/tests/pruned_lockfile_integrity.mjs +79 -0
  56. package/tests/ship_ci_deploy.mjs +60 -0
  57. package/tests/upgrade.mjs +254 -5
  58. package/.gitignore +0 -91
  59. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
  60. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
  61. package/scripts/hall-preview/fixtures/access-requests.json +0 -20
  62. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  63. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  64. package/scripts/hall-preview/fixtures/audit-log.json +0 -68
  65. package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
  66. package/scripts/hall-preview/fixtures/box__me.json +0 -5
  67. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
  68. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  69. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  70. package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
  71. package/scripts/hall-preview/fixtures/goals.json +0 -502
  72. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  73. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  74. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  75. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  76. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  77. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  78. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  79. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  80. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
  81. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  82. package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
  83. package/scripts/hall-preview/fixtures/instance.json +0 -124
  84. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  85. package/scripts/hall-preview/fixtures/me.json +0 -476
  86. package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
  87. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
  88. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  89. package/scripts/hall-preview/fixtures/me__render.json +0 -5
  90. package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
  91. package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
  92. package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
  93. package/scripts/hall-preview/fixtures/override-requests.json +0 -15
  94. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  95. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  96. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  97. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  98. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  99. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  100. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  101. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  102. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  103. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  104. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  105. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  106. package/scripts/hall-preview/fixtures/security__docs.json +0 -10
  107. package/scripts/hall-preview/fixtures/security__reports.json +0 -31
  108. package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
  109. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  110. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  111. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  112. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  113. package/scripts/hall-preview/fixtures/versions.json +0 -89
@@ -19,12 +19,15 @@ const NEUTRAL = {
19
19
  envPrefix: 'CLOUDBONGOS',
20
20
  };
21
21
 
22
- const OTB = {
23
- identity: { productName: 'Off the Boats', worldName: 'Amazonprimea', company: { name: 'Hermeslines', site: 'https://hermeslines.com' } },
24
- llmProjectName: 'Off the Boats',
25
- domains: { publicOrigin: 'https://amazonprimea.com', cookieDomain: '.amazonprimea.com' },
26
- currency: { label: 'drachmae' },
27
- envPrefix: 'OTB',
22
+ // A GENERIC sample instance pack — exercises the resolver's override/merge/precedence logic
23
+ // without baking any real instance's identity into the (published) test. (task 1002219 / audit H5:
24
+ // swept the former customer-1 sample — Off the Boats / amazonprimea / Hermeslines — to neutral.)
25
+ const SAMPLE = {
26
+ identity: { productName: 'Acme Rockets', worldName: 'Acme', company: { name: 'Acme Corp', site: 'https://acme.example' } },
27
+ llmProjectName: 'Acme Rockets',
28
+ domains: { publicOrigin: 'https://acme.example', cookieDomain: '.acme.example' },
29
+ currency: { label: 'tokens' },
30
+ envPrefix: 'ACME',
28
31
  };
29
32
 
30
33
  test('neutral starter resolves to the plain identity (no host leak)', () => {
@@ -34,33 +37,33 @@ test('neutral starter resolves to the plain identity (no host leak)', () => {
34
37
  });
35
38
 
36
39
  test('instance config overrides the neutral starter, deep-merged', () => {
37
- const b = resolveBranding({ neutral: NEUTRAL, instance: OTB });
38
- assert.equal(b.identity.worldName, 'Amazonprimea');
39
- assert.equal(b.identity.company.name, 'Hermeslines');
40
- assert.equal(b.domains.cookieDomain, '.amazonprimea.com');
40
+ const b = resolveBranding({ neutral: NEUTRAL, instance: SAMPLE });
41
+ assert.equal(b.identity.worldName, 'Acme');
42
+ assert.equal(b.identity.company.name, 'Acme Corp');
43
+ assert.equal(b.domains.cookieDomain, '.acme.example');
41
44
  // a field present only in neutral survives the merge:
42
45
  assert.equal(b.domains.buildersOrigin, 'http://localhost:3000');
43
46
  });
44
47
 
45
48
  test('env override beats instance config (precedence)', () => {
46
- const b = resolveBranding({ neutral: NEUTRAL, instance: OTB, env: { OTB_PUBLIC_ORIGIN: 'https://staging.example.com' } });
49
+ const b = resolveBranding({ neutral: NEUTRAL, instance: SAMPLE, env: { ACME_PUBLIC_ORIGIN: 'https://staging.example.com' } });
47
50
  assert.equal(b.domains.publicOrigin, 'https://staging.example.com');
48
51
  });
49
52
 
50
53
  test('env override is ignored when empty', () => {
51
- const b = resolveBranding({ neutral: NEUTRAL, instance: OTB, env: { OTB_PUBLIC_ORIGIN: '' } });
52
- assert.equal(b.domains.publicOrigin, 'https://amazonprimea.com');
54
+ const b = resolveBranding({ neutral: NEUTRAL, instance: SAMPLE, env: { ACME_PUBLIC_ORIGIN: '' } });
55
+ assert.equal(b.domains.publicOrigin, 'https://acme.example');
53
56
  });
54
57
 
55
58
  test('provisioningOrigin: optional, passes through config, env-overridable (task 2101)', () => {
56
59
  // Not required — unset resolves to undefined, and callers fall back to publicOrigin.
57
- assert.equal(resolveBranding({ neutral: NEUTRAL, instance: OTB }).domains.provisioningOrigin, undefined);
60
+ assert.equal(resolveBranding({ neutral: NEUTRAL, instance: SAMPLE }).domains.provisioningOrigin, undefined);
58
61
  // Set in the instance pack, it survives the deep-merge.
59
- const inst = resolveBranding({ neutral: NEUTRAL, instance: { ...OTB, domains: { ...OTB.domains, provisioningOrigin: 'https://get.cloudbongos.com' } } });
60
- assert.equal(inst.domains.provisioningOrigin, 'https://get.cloudbongos.com');
62
+ const inst = resolveBranding({ neutral: NEUTRAL, instance: { ...SAMPLE, domains: { ...SAMPLE.domains, provisioningOrigin: 'https://get.acme.example' } } });
63
+ assert.equal(inst.domains.provisioningOrigin, 'https://get.acme.example');
61
64
  // And <PREFIX>_PROVISIONING_ORIGIN beats config (same precedence as the other origins).
62
- const env = resolveBranding({ neutral: NEUTRAL, instance: OTB, env: { OTB_PROVISIONING_ORIGIN: 'https://get.cloudbongos.com' } });
63
- assert.equal(env.domains.provisioningOrigin, 'https://get.cloudbongos.com');
65
+ const env = resolveBranding({ neutral: NEUTRAL, instance: SAMPLE, env: { ACME_PROVISIONING_ORIGIN: 'https://get.acme.example' } });
66
+ assert.equal(env.domains.provisioningOrigin, 'https://get.acme.example');
64
67
  });
65
68
 
66
69
  test('missing required field throws', () => {
@@ -80,16 +83,19 @@ test('deepMerge does not mutate its inputs', () => {
80
83
  assert.deepEqual(out, { a: { x: 1, y: 2 } });
81
84
  });
82
85
 
83
- test('the committed neutral + OTB packs load and resolve (this repo = customer 1)', () => {
86
+ test('the committed neutral pack loads + resolves to the neutral identity (the trunk carries no instance branding.json)', () => {
87
+ // The neutral trunk ships config/branding.neutral.json only — no config/branding.json — so
88
+ // loadBranding resolves to the plain Cloud Bongos identity (task 1002219 / audit H5: this used to
89
+ // assert the customer-1 OTB identity, stale since the amazonprimea→cloudbongos rebrand, ADR 0139/0140).
84
90
  const b = loadBranding({ env: {} });
85
- assert.equal(b.identity.productName, 'Off the Boats');
86
- assert.ok(b.domains.publicOrigin.startsWith('https://amazonprimea.com'));
87
- assert.equal(b.identity.worldName, 'Amazonprimea');
91
+ assert.equal(b.identity.productName, 'Cloud Bongos');
92
+ assert.equal(b.identity.worldName, 'Cloud Bongos');
93
+ assert.ok(b.domains.publicOrigin.startsWith('http://localhost'));
88
94
  });
89
95
 
90
96
  test('resolveBranding does not freeze its input objects (no shared refs)', () => {
91
97
  const neutral = structuredClone(NEUTRAL);
92
- const instance = structuredClone(OTB);
98
+ const instance = structuredClone(SAMPLE);
93
99
  resolveBranding({ neutral, instance });
94
100
  assert.equal(Object.isFrozen(neutral), false);
95
101
  assert.equal(Object.isFrozen(neutral.domains), false);
@@ -0,0 +1,91 @@
1
+ // tests/github_repos_helpers.mjs — DB-free unit tests for the onboarding repo
2
+ // picker's pure/near-pure pieces (task 1002099 / ADR 0141):
3
+ // • auth.scopeForWebFlow — the security-critical scope string
4
+ // • github-repos listUserRepos/createUserRepo — with a mock fetch (no network)
5
+ // • routes/auth.safeReturnTo — regression: the ?repos change didn't weaken the
6
+ // open-redirect guard.
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { test } from 'node:test';
10
+ import { createRequire } from 'node:module';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
17
+ const routesAuth = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
18
+ const gh = require(path.join(ROOT, 'src/bongos/github-repos.js'));
19
+
20
+ test('scopeForWebFlow: identity-only by default, adds public_repo ONLY when repos=true', () => {
21
+ assert.equal(auth.scopeForWebFlow(), 'read:user');
22
+ assert.equal(auth.scopeForWebFlow({ repos: false }), 'read:user');
23
+ assert.equal(auth.scopeForWebFlow({ repos: true }), 'read:user public_repo');
24
+ // Never the all-or-nothing `repo` scope (ADR 0141) — public_repo only.
25
+ assert.ok(!auth.scopeForWebFlow({ repos: true }).split(' ').includes('repo'));
26
+ });
27
+
28
+ test('safeReturnTo still guards after the ?repos change: relative ok, cross-origin/protocol-relative blocked', () => {
29
+ assert.equal(routesAuth.safeReturnTo('/create'), '/create');
30
+ assert.equal(routesAuth.safeReturnTo('https://evil.example/steal'), null);
31
+ assert.equal(routesAuth.safeReturnTo('//evil.example'), null);
32
+ assert.equal(routesAuth.safeReturnTo('/create?x=1'), '/create?x=1'); // a query on the return itself is fine
33
+ });
34
+
35
+ // ---- github-repos helpers driven by a mock fetch ----
36
+ function res(status, body) {
37
+ return {
38
+ status,
39
+ ok: status >= 200 && status < 300,
40
+ json: async () => body,
41
+ text: async () => JSON.stringify(body),
42
+ };
43
+ }
44
+
45
+ test('listUserRepos: requests affiliation=owner, maps rows, filters by q', async () => {
46
+ let calledUrl = '';
47
+ const fetchImpl = async (url) => {
48
+ calledUrl = url;
49
+ return res(200, [
50
+ { full_name: 'me/mercury', name: 'mercury', owner: { login: 'me' }, private: false },
51
+ { full_name: 'me/venus', name: 'venus', owner: { login: 'me' }, private: true },
52
+ ]);
53
+ };
54
+ const all = await gh.listUserRepos('tok', { fetchImpl });
55
+ assert.match(calledUrl, /affiliation=owner/);
56
+ assert.match(calledUrl, /per_page=100/);
57
+ assert.deepEqual(all.map((r) => r.full_name), ['me/mercury', 'me/venus']);
58
+ assert.equal(all[0].owner, 'me');
59
+ assert.equal(all[1].private, true);
60
+ const filtered = await gh.listUserRepos('tok', { q: 'MERC', fetchImpl }); // case-insensitive
61
+ assert.deepEqual(filtered.map((r) => r.full_name), ['me/mercury']);
62
+ });
63
+
64
+ test('listUserRepos: 401 → typed github_unauthorized', async () => {
65
+ const fetchImpl = async () => res(401, { message: 'Bad credentials' });
66
+ await assert.rejects(
67
+ () => gh.listUserRepos('tok', { fetchImpl }),
68
+ (e) => e.code === 'github_unauthorized' && e.status === 401,
69
+ );
70
+ });
71
+
72
+ test('createUserRepo: sends auto_init:false + private:false, returns full_name/html_url', async () => {
73
+ let sentBody = null;
74
+ const fetchImpl = async (url, init) => {
75
+ sentBody = JSON.parse(init.body);
76
+ return res(201, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury' });
77
+ };
78
+ const out = await gh.createUserRepo('tok', { name: 'mercury', fetchImpl });
79
+ assert.equal(sentBody.auto_init, false, 'empty repo — no README/first commit');
80
+ assert.equal(sentBody.private, false, 'public_repo scope → public');
81
+ assert.equal(sentBody.name, 'mercury');
82
+ assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury' });
83
+ });
84
+
85
+ test('createUserRepo: 422 → typed name_taken (the route maps this to a friendly 409)', async () => {
86
+ const fetchImpl = async () => res(422, { message: 'name already exists on this account' });
87
+ await assert.rejects(
88
+ () => gh.createUserRepo('tok', { name: 'dup', fetchImpl }),
89
+ (e) => e.code === 'name_taken' && e.status === 422,
90
+ );
91
+ });
@@ -0,0 +1,121 @@
1
+ // tests/github_token_store.mjs — DB-free tests for the encrypted repo-token store
2
+ // and the /github/repos gating (task 1002099 / ADR 0141). No real Postgres: db
3
+ // helpers take an injectable pool `p`; no real GitHub: the route's 503/403 branches
4
+ // don't reach GitHub. secret-box reads BUILDER_SECRET_KEY fresh each call, so we set
5
+ // a valid key up front and toggle it per test.
6
+
7
+ import { strict as assert } from 'node:assert';
8
+ import { test } from 'node:test';
9
+ import { createRequire } from 'node:module';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ // A valid 32-byte key (64 hex chars) BEFORE requiring the modules — secret-box configured.
14
+ process.env.BUILDER_SECRET_KEY = 'ab'.repeat(32);
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const db = require(path.join(ROOT, 'src/bongos/db.js'));
19
+ const secretBox = require(path.join(ROOT, 'src/bongos/secret-box.js'));
20
+ const buildGithubRouter = require(path.join(ROOT, 'src/bongos/routes/github.js'));
21
+ const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
22
+
23
+ // Mock pool: records every query, returns programmed rows for SELECTs.
24
+ function mockPool(selectRows) {
25
+ const calls = [];
26
+ return {
27
+ calls,
28
+ query: async (text, params) => {
29
+ calls.push({ text, params });
30
+ return { rows: /^\s*SELECT/i.test(text) ? (selectRows || []) : [] };
31
+ },
32
+ };
33
+ }
34
+
35
+ test('secret-box is configured for this test file', () => {
36
+ assert.equal(secretBox.isConfigured(), true);
37
+ });
38
+
39
+ test('upsertGithubToken: stores an ENCRYPTED blob (never plaintext) + a future expires_at', async () => {
40
+ const p = mockPool();
41
+ const out = await db.upsertGithubToken({ builderId: 7, token: 'ghs_secret', scope: 'read:user public_repo', p });
42
+ assert.equal(out.stored, true);
43
+ const insert = p.calls.find((c) => /INSERT INTO github_oauth_tokens/i.test(c.text));
44
+ assert.ok(insert, 'issued an INSERT');
45
+ const enc = insert.params[1];
46
+ assert.notEqual(enc, 'ghs_secret', 'the plaintext token must never be stored');
47
+ assert.equal(secretBox.decrypt(enc), 'ghs_secret', 'the stored blob round-trips back to the token');
48
+ const expiresAt = insert.params[3];
49
+ assert.ok(expiresAt instanceof Date && expiresAt.getTime() > Date.now(), 'has a future expires_at');
50
+ });
51
+
52
+ test('getGithubToken: decrypts a live (unexpired) row', async () => {
53
+ const enc = secretBox.encrypt('ghs_live');
54
+ const p = mockPool([{ token_enc: enc, scope: 'x', expires_at: new Date(Date.now() + 60_000) }]);
55
+ const row = await db.getGithubToken({ builderId: 7, p });
56
+ assert.equal(row.token, 'ghs_live');
57
+ });
58
+
59
+ test('getGithubToken: an EXPIRED row returns null AND is swept (DELETE issued)', async () => {
60
+ const enc = secretBox.encrypt('ghs_old');
61
+ const p = mockPool([{ token_enc: enc, scope: 'x', expires_at: new Date(Date.now() - 60_000) }]);
62
+ const row = await db.getGithubToken({ builderId: 7, p });
63
+ assert.equal(row, null);
64
+ assert.ok(p.calls.some((c) => /DELETE FROM github_oauth_tokens/i.test(c.text)), 'lazily swept the expired row');
65
+ });
66
+
67
+ test('secret-box UNCONFIGURED: upsert no-ops (no plaintext ever written), get returns null', async () => {
68
+ const saved = process.env.BUILDER_SECRET_KEY;
69
+ delete process.env.BUILDER_SECRET_KEY;
70
+ try {
71
+ const p = mockPool();
72
+ const out = await db.upsertGithubToken({ builderId: 7, token: 'ghs_secret', p });
73
+ assert.equal(out.stored, false);
74
+ assert.equal(p.calls.length, 0, 'no query issued when secret-box is off');
75
+ assert.equal(await db.getGithubToken({ builderId: 7, p }), null);
76
+ } finally {
77
+ process.env.BUILDER_SECRET_KEY = saved;
78
+ }
79
+ });
80
+
81
+ // ---- route gating: the 503 / 403 signals the wizard branches on ----
82
+ function fakeRes() {
83
+ const res = { _status: 200, _body: null };
84
+ res.status = (c) => { res._status = c; return res; };
85
+ res.json = (b) => { res._body = b; return res; };
86
+ attachFail({}, res, () => {}); // installs res.fail exactly as the router does
87
+ return res;
88
+ }
89
+ function finalHandler(router, method, pth) {
90
+ const layer = router.stack.find((l) => l.route && l.route.path === pth && l.route.methods[method]);
91
+ const stack = layer.route.stack;
92
+ return stack[stack.length - 1].handle; // the handler after requireBuilder
93
+ }
94
+
95
+ test('GET /github/repos → 503 github_repos_unavailable when secret-box is unconfigured', async () => {
96
+ const saved = process.env.BUILDER_SECRET_KEY;
97
+ delete process.env.BUILDER_SECRET_KEY;
98
+ try {
99
+ const handler = finalHandler(buildGithubRouter(), 'get', '/github/repos');
100
+ const res = fakeRes();
101
+ await handler({ builder: { id: 1 }, query: {} }, res);
102
+ assert.equal(res._status, 503);
103
+ assert.equal(res._body.error.code, 'github_repos_unavailable');
104
+ } finally {
105
+ process.env.BUILDER_SECRET_KEY = saved;
106
+ }
107
+ });
108
+
109
+ test('GET /github/repos → 403 repo_scope_required when configured but no stored token', async () => {
110
+ const orig = db.getGithubToken;
111
+ db.getGithubToken = async () => null; // configured, but this builder never granted repo access
112
+ try {
113
+ const handler = finalHandler(buildGithubRouter(), 'get', '/github/repos');
114
+ const res = fakeRes();
115
+ await handler({ builder: { id: 1 }, query: {} }, res);
116
+ assert.equal(res._status, 403);
117
+ assert.equal(res._body.error.code, 'repo_scope_required');
118
+ } finally {
119
+ db.getGithubToken = orig;
120
+ }
121
+ });
@@ -0,0 +1,105 @@
1
+ // tests/idp_device_exchange.mjs
2
+ //
3
+ // CLI federation crypto (ADR 0141 §1, task #1002108): the hub mints an Ed25519
4
+ // assertion (modules/platform-identity/hub-keys.js) and the instance verifies it
5
+ // OFFLINE (src/bongos/auth.js verifyHubAssertion). The two live across the
6
+ // core<->module boundary and cannot share code, so this test drives a REAL
7
+ // mint→verify roundtrip with a generated keypair and then tries to break it:
8
+ // tamper, wrong audience, expiry, wrong key, algorithm-confusion, malformed.
9
+ // DB-free + network-free.
10
+ //
11
+ // Run: node tests/idp_device_exchange.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import crypto from 'node:crypto';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const auth = require('../src/bongos/auth.js');
19
+ const hubKeys = require('../modules/platform-identity/hub-keys.js');
20
+
21
+ let passed = 0, failed = 0;
22
+ function t(name, fn) {
23
+ try { fn(); passed++; console.log(` PASS ${name}`); }
24
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
25
+ }
26
+
27
+ // A hub keypair (base64 DER), as generateHubKeypair produces for env placement.
28
+ const { privateKeyB64, publicKeyB64 } = hubKeys.generateHubKeypair();
29
+ const signingKey = crypto.createPrivateKey({ key: Buffer.from(privateKeyB64, 'base64'), format: 'der', type: 'pkcs8' });
30
+ const publicKey = crypto.createPublicKey({ key: Buffer.from(publicKeyB64, 'base64'), format: 'der', type: 'spki' });
31
+
32
+ function mint(overrides = {}, opts = {}) {
33
+ return hubKeys.mintHubAssertion(
34
+ { clientId: 'client-x', githubId: 42, githubLogin: 'octo', displayName: 'Octo', avatarUrl: 'a', ...overrides },
35
+ { signingKey, ...opts },
36
+ );
37
+ }
38
+
39
+ console.log('hub assertion mint → instance verify (roundtrip):');
40
+
41
+ t('a valid assertion verifies, and carries IDENTITY ONLY (no rank)', () => {
42
+ const p = auth.verifyHubAssertion(mint(), { publicKey, clientId: 'client-x' });
43
+ assert.equal(p.sub, '42');
44
+ assert.equal(p.github_login, 'octo');
45
+ assert.equal(p.aud, 'client-x');
46
+ assert.equal(p.iss, 'cloudbongos-hub');
47
+ assert.equal(p.rank, undefined, 'assertion must never carry a rank');
48
+ assert.equal(p.credits, undefined);
49
+ });
50
+
51
+ console.log('\nadversarial — every one of these MUST be rejected:');
52
+
53
+ t('wrong audience (a code for client A cannot sign you into client B)', () => {
54
+ assert.throws(() => auth.verifyHubAssertion(mint(), { publicKey, clientId: 'other-client' }), /audience/);
55
+ });
56
+
57
+ t('expired assertion (minted 10 min ago; TTL is ~120s)', () => {
58
+ const stale = mint({}, { nowMs: Date.now() - 10 * 60 * 1000 });
59
+ assert.throws(() => auth.verifyHubAssertion(stale, { publicKey, clientId: 'client-x' }), /expired/);
60
+ });
61
+
62
+ t('tampered payload (escalate sub to another github_id) fails the signature', () => {
63
+ const [h, p, s] = mint().split('.');
64
+ const payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
65
+ payload.sub = '999';
66
+ const forged = `${h}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.${s}`;
67
+ assert.throws(() => auth.verifyHubAssertion(forged, { publicKey, clientId: 'client-x' }), /signature/);
68
+ });
69
+
70
+ t('a different signing key is rejected (one instance cannot forge for another)', () => {
71
+ const otherPub = crypto.generateKeyPairSync('ed25519').publicKey;
72
+ assert.throws(() => auth.verifyHubAssertion(mint(), { publicKey: otherPub, clientId: 'client-x' }), /signature/);
73
+ });
74
+
75
+ t('algorithm-confusion (header alg swapped to "none") is rejected', () => {
76
+ const [h, p, s] = mint().split('.');
77
+ const hdr = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'));
78
+ hdr.alg = 'none';
79
+ const forged = `${Buffer.from(JSON.stringify(hdr)).toString('base64url')}.${p}.${s}`;
80
+ assert.throws(() => auth.verifyHubAssertion(forged, { publicKey, clientId: 'client-x' }), /alg|signature/);
81
+ });
82
+
83
+ t('malformed / empty tokens are rejected', () => {
84
+ assert.throws(() => auth.verifyHubAssertion('not-a-jwt', { publicKey, clientId: 'client-x' }), /malformed/);
85
+ assert.throws(() => auth.verifyHubAssertion('', { publicKey, clientId: 'client-x' }), /malformed/);
86
+ assert.throws(() => auth.verifyHubAssertion('a.b.c.d', { publicKey, clientId: 'client-x' }), /malformed/);
87
+ });
88
+
89
+ t('a missing public key never verifies (fail closed)', () => {
90
+ assert.throws(() => auth.verifyHubAssertion(mint(), { publicKey: null, clientId: 'client-x' }), /public key/);
91
+ });
92
+
93
+ t('mintHubAssertion refuses without a signing key', () => {
94
+ assert.throws(() => hubKeys.mintHubAssertion({ clientId: 'c', githubId: 1, githubLogin: 'x' }, {}), /signing key/);
95
+ });
96
+
97
+ t('generateHubKeypair yields distinct keypairs', () => {
98
+ const a = hubKeys.generateHubKeypair();
99
+ const b = hubKeys.generateHubKeypair();
100
+ assert.notEqual(a.privateKeyB64, b.privateKeyB64);
101
+ assert.notEqual(a.publicKeyB64, b.publicKeyB64);
102
+ });
103
+
104
+ console.log(`\nidp_device_exchange: ${passed} passed, ${failed} failed`);
105
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,100 @@
1
+ // tests/idp_federation.mjs
2
+ //
3
+ // Instance-side platform-IdP federation (ADR 0141 §1, task #1002107): when
4
+ // auth.idp is set, web sign-in federates through the Cloud Bongos hub instead of
5
+ // github.com. DB-free + network-free — tests the pure authorize-URL builder and
6
+ // the DORMANCY invariant (federation stays OFF, so the direct-GitHub path is
7
+ // unchanged, unless the branding auth.idp flag is explicitly true). The identity→
8
+ // admission→session logic is the SAME shared primitives completeWebFlow uses
9
+ // (firstTimeAdmissionBlocked/upsertBuilder/createSession — covered elsewhere), so
10
+ // it can't drift; the network exchange (exchangeIdpCode) is an integration path.
11
+ //
12
+ // Run: node tests/idp_federation.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 auth = require('../src/bongos/auth.js');
19
+
20
+ let passed = 0, failed = 0;
21
+ function t(name, fn) {
22
+ try { fn(); passed++; console.log(` PASS ${name}`); }
23
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
24
+ }
25
+
26
+ console.log('idpAuthorizeUrl (pure hub authorize-URL builder):');
27
+
28
+ t('builds the hub /sso/authorize URL with client_id, redirect_uri, state', () => {
29
+ const url = auth.idpAuthorizeUrl('https://cloudbongos.com', 'client-abc', 'st8', 'https://proj.example.com/api/gds/auth/web/callback');
30
+ const u = new URL(url);
31
+ assert.equal(u.origin, 'https://cloudbongos.com');
32
+ assert.equal(u.pathname, '/api/gds/sso/authorize');
33
+ assert.equal(u.searchParams.get('client_id'), 'client-abc');
34
+ assert.equal(u.searchParams.get('redirect_uri'), 'https://proj.example.com/api/gds/auth/web/callback');
35
+ assert.equal(u.searchParams.get('state'), 'st8');
36
+ });
37
+
38
+ t('trims a trailing slash on the hub origin', () => {
39
+ const url = auth.idpAuthorizeUrl('https://cloudbongos.com/', 'c', 's', 'https://p/cb');
40
+ assert.equal(new URL(url).pathname, '/api/gds/sso/authorize');
41
+ assert.ok(!url.includes('com//api'), 'no double slash between origin and path');
42
+ });
43
+
44
+ t('percent-encodes redirect_uri + state (no query injection)', () => {
45
+ const url = auth.idpAuthorizeUrl('https://hub', 'c', 'a&b=evil', 'https://p/cb?x=1');
46
+ const u = new URL(url);
47
+ // The raw string must not let an injected &b=evil become its own param.
48
+ assert.equal(u.searchParams.get('state'), 'a&b=evil');
49
+ assert.equal(u.searchParams.get('redirect_uri'), 'https://p/cb?x=1');
50
+ assert.equal(u.searchParams.get('b'), null);
51
+ });
52
+
53
+ console.log('\ndormancy invariant (federation OFF by default → direct-GitHub fallback):');
54
+
55
+ // The neutral branding pack ships auth.idp=false, and this repo has no
56
+ // config/branding.json, so idp is off. Even with the hub env creds present,
57
+ // idpConfigured() must stay false — the branding flag is the gate, so a stray env
58
+ // var can never silently flip a self-hosted instance into federation.
59
+ function withHubEnv(fn) {
60
+ const keys = ['CLOUDBONGOS_HUB_ORIGIN', 'CLOUDBONGOS_HUB_CLIENT_ID', 'CLOUDBONGOS_HUB_CLIENT_SECRET'];
61
+ const prev = keys.map((k) => [k, process.env[k]]);
62
+ process.env.CLOUDBONGOS_HUB_ORIGIN = 'https://cloudbongos.com';
63
+ process.env.CLOUDBONGOS_HUB_CLIENT_ID = 'client-abc';
64
+ process.env.CLOUDBONGOS_HUB_CLIENT_SECRET = 'x'.repeat(64);
65
+ try { fn(); }
66
+ finally { for (const [k, v] of prev) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }
67
+ }
68
+
69
+ t('idpConfigured() is false by default even with hub env present (flag gates it)', () => {
70
+ withHubEnv(() => {
71
+ assert.equal(auth.idpConfigured(), false);
72
+ assert.equal(auth.loadIdpConfig(), null);
73
+ assert.equal(auth.idpEnabled(), false);
74
+ });
75
+ });
76
+
77
+ t('buildIdpAuthorizeUrl throws when the IdP is not configured (never silently misroutes)', () => {
78
+ withHubEnv(() => {
79
+ assert.throws(() => auth.buildIdpAuthorizeUrl('s', 'https://p/cb'), /not configured/);
80
+ });
81
+ });
82
+
83
+ console.log('\nmembership check-in role (§3 — coarse, never a rank):');
84
+
85
+ t('membershipRole is builder by default, owner for the configured firstAdmin', () => {
86
+ const prev = process.env.CLOUDBONGOS_FIRST_ADMIN;
87
+ process.env.CLOUDBONGOS_FIRST_ADMIN = 'ownerlogin';
88
+ try {
89
+ assert.equal(auth.membershipRole({ githubLogin: 'ownerlogin' }), 'owner');
90
+ assert.equal(auth.membershipRole({ githubLogin: 'OwnerLogin' }), 'owner', 'case-insensitive');
91
+ assert.equal(auth.membershipRole({ githubLogin: 'someone-else' }), 'builder');
92
+ assert.equal(auth.membershipRole({}), 'builder');
93
+ } finally {
94
+ if (prev === undefined) delete process.env.CLOUDBONGOS_FIRST_ADMIN;
95
+ else process.env.CLOUDBONGOS_FIRST_ADMIN = prev;
96
+ }
97
+ });
98
+
99
+ console.log(`\nidp_federation: ${passed} passed, ${failed} failed`);
100
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,86 @@
1
+ // tests/migrations_forward_safe.mjs
2
+ //
3
+ // task 1002223 / audit H10 (finding F6) — the FAIL-CLOSED forward-safety gate for core migrations.
4
+ // `bongos upgrade` auto-rollback reverts the code pin but NOT the DB, so a core migration must be
5
+ // forward-safe (old code still runs on the new schema) — or explicitly acknowledge the exception
6
+ // with a `-- forward-unsafe: <reason>` pragma. This runs in the DB-free unit gate every PR: add a
7
+ // DROP COLUMN / RENAME / ALTER TYPE without the pragma and this REDs, long before a field rollback
8
+ // strands old code on new schema. (The migrate.sh notice is advisory; THIS is the enforcement.)
9
+ //
10
+ // Run: node tests/migrations_forward_safe.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import { join, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const lint = require('../scripts/gds/lint-migrations.js');
19
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ let passed = 0, failed = 0;
22
+ function t(name, fn) {
23
+ try { fn(); passed++; console.log(` PASS ${name}`); }
24
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
25
+ }
26
+
27
+ // ---- THE GATE: the committed core migrations must be forward-safe (or acknowledged) ----
28
+ t('core migrations: zero UNACKNOWLEDGED forward-unsafe operations', () => {
29
+ const { violations, acknowledged, scanned } = lint.scanMigrationsDir(join(REPO_ROOT, 'migrations'));
30
+ assert.ok(scanned > 0, 'expected to scan some core migrations');
31
+ assert.equal(violations.length, 0,
32
+ `unacknowledged forward-unsafe migration(s): ${violations.map((v) => `${v.file} [${v.unsafe.join(',')}]`).join('; ')}\n`
33
+ + ' Add a top-of-file `-- forward-unsafe: <reason>` comment if the operation is intentional + safe here.');
34
+ // sanity: the two known historical exceptions stay acknowledged (not silently dropped)
35
+ assert.ok(acknowledged.some((a) => a.file.startsWith('039_')), 'expected 039 to remain an acknowledged exception');
36
+ assert.ok(acknowledged.some((a) => a.file.startsWith('102_')), 'expected 102 to remain an acknowledged exception');
37
+ });
38
+
39
+ // ---- the scanner classifies operations correctly ----
40
+ t('scanMigrationSql: flags each genuinely forward-unsafe operation', () => {
41
+ const cases = {
42
+ 'drop-table': 'DROP TABLE widgets;',
43
+ 'drop-column': 'ALTER TABLE t DROP COLUMN c;',
44
+ 'rename-column': 'ALTER TABLE t RENAME COLUMN a TO b;',
45
+ 'rename-table': 'ALTER TABLE t RENAME TO t2;',
46
+ 'alter-column-type': 'ALTER TABLE t ALTER COLUMN c TYPE bigint;',
47
+ 'set-not-null': 'ALTER TABLE t ALTER COLUMN c SET NOT NULL;',
48
+ };
49
+ for (const [expected, sql] of Object.entries(cases)) {
50
+ const r = lint.scanMigrationSql(sql);
51
+ assert.ok(r.unsafe.includes(expected), `expected ${expected} for: ${sql} — got ${JSON.stringify(r.unsafe)}`);
52
+ }
53
+ });
54
+
55
+ t('scanMigrationSql: does NOT flag additive / recreate-in-place operations', () => {
56
+ const safe = [
57
+ 'CREATE TABLE t (id int);',
58
+ 'ALTER TABLE t ADD COLUMN c text;',
59
+ 'CREATE INDEX idx ON t (c);',
60
+ 'DROP INDEX IF EXISTS idx; CREATE INDEX idx ON t (c);', // recreate-in-place
61
+ 'DROP TRIGGER IF EXISTS trg ON t; CREATE TRIGGER trg BEFORE INSERT ON t ...;',
62
+ 'CREATE OR REPLACE VIEW v AS SELECT 1;',
63
+ 'DROP VIEW IF EXISTS v; CREATE VIEW v AS SELECT 1;',
64
+ 'DROP FUNCTION IF EXISTS f(); CREATE FUNCTION f() RETURNS void ...;',
65
+ "INSERT INTO t (id) VALUES (1);",
66
+ ];
67
+ for (const sql of safe) {
68
+ const r = lint.scanMigrationSql(sql);
69
+ assert.equal(r.unsafe.length, 0, `false positive on safe SQL: ${sql} — got ${JSON.stringify(r.unsafe)}`);
70
+ }
71
+ });
72
+
73
+ t('scanMigrationSql: the acknowledgement pragma flips an unsafe op to acknowledged', () => {
74
+ const r = lint.scanMigrationSql('-- forward-unsafe: intentional, two-step deploy\nALTER TABLE t DROP COLUMN c;');
75
+ assert.ok(r.unsafe.includes('drop-column'));
76
+ assert.equal(r.acknowledged, true);
77
+ assert.match(r.ackReason, /two-step/);
78
+ });
79
+
80
+ t('scanMigrationSql: an unsafe keyword only inside a comment does NOT count', () => {
81
+ const r = lint.scanMigrationSql('-- this migration does NOT drop table anything\nCREATE TABLE t (id int);');
82
+ assert.equal(r.unsafe.length, 0, `comment text must not trip the scan — got ${JSON.stringify(r.unsafe)}`);
83
+ });
84
+
85
+ console.log(`\nmigrations_forward_safe: ${passed} passed, ${failed} failed`);
86
+ process.exit(failed ? 1 : 0);
@@ -20,7 +20,7 @@ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
20
20
  // was added; no enabled module at its pinned coreVersion lost a capability.
21
21
  const PUBLISHED_SURFACE = [
22
22
  'CORE_VERSION',
23
- 'requireBuilder', 'requireRank', 'rankMeetsThreshold', 'requireBfgPrincipal', 'allowBoxScope',
23
+ 'requireBuilder', 'requireRank', 'rankMeetsThreshold', 'requireBfgPrincipal', 'allowBoxScope', 'resolveSession',
24
24
  'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
25
25
  'secretBox', // Gemini-key storage relocated to modules/art-pipeline + its 'art.getBuilderGeminiKeyRow' port (ADR 0102)
26
26
  'routeRankCheck', 'permissionPathCheck', // BV1.R73: kernel trust classifiers (1.6.0)