@bongos/core 1.19.1017 → 1.19.1019

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.
@@ -31,6 +31,7 @@
31
31
  import assert from 'node:assert/strict';
32
32
  import { test, before, after } from 'node:test';
33
33
  import http from 'node:http';
34
+ import crypto from 'node:crypto';
34
35
  import { createRequire } from 'node:module';
35
36
 
36
37
  // ESM import so the knip ratchet credits the constant this file consumes
@@ -103,6 +104,8 @@ let echoes = [];
103
104
  let memberships = [];
104
105
  let echoFails = false;
105
106
  let statements = [];
107
+ let account = null;
108
+ let accountFails = false;
106
109
 
107
110
  function seed() {
108
111
  const now = Date.now();
@@ -126,6 +129,12 @@ function seed() {
126
129
  ];
127
130
  echoFails = false;
128
131
  statements = [];
132
+ // An account that has cleared the consent floor: active, and the platform
133
+ // terms accepted (account-visibility.js isAccountActive).
134
+ account = { github_id: ME, github_login: 'octo', handle: 'octo-h', profile_state: 'active', terms_accepted_at: now - 9 * DAY, account_visibility: 'public', hide_stats: false };
135
+ accountFails = false;
136
+ hubSigningKeyB64 = null;
137
+ relayed = null;
129
138
  }
130
139
  const echo = (clientId) => echoes.find((e) => e.github_id === ME && e.client_id === clientId);
131
140
 
@@ -198,6 +207,13 @@ const pool = {
198
207
  return { rows: c ? [c] : [] };
199
208
  }
200
209
 
210
+ // getAccount — the relay's one read of the applicant's own account, to decide
211
+ // whether their handle may ride the vouch (task 1002285).
212
+ if (/FROM platform_identity_accounts WHERE github_id = \$1$/.test(sql)) {
213
+ if (accountFails) throw new Error('accounts table is on fire');
214
+ return { rows: account && String(params[0]) === String(ME) ? [account] : [] };
215
+ }
216
+
201
217
  if (/FROM platform_identity_project_memberships m/.test(sql)) {
202
218
  m = /WHERE (.+?) ORDER BY/.exec(sql);
203
219
  // The pending exclusion is MODELLED, not stripped. It used to be deleted
@@ -254,11 +270,21 @@ require.cache[statsPath] = {
254
270
  exports: { getProjectStats: async () => ({}) },
255
271
  };
256
272
 
273
+ // The hub's signing key is read from env on every mint, so a test can switch the
274
+ // hub between "provisioned" and "never provisioned" by setting this one value.
275
+ // Generated here rather than by hub-keys.js, which must first load AFTER the
276
+ // doorway stub below (it binds resolveEnv when it is required).
277
+ const HUB_KEYPAIR = crypto.generateKeyPairSync('ed25519');
278
+ const HUB_SIGNING_KEY_B64 = HUB_KEYPAIR.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64');
279
+ const hubPublicKey = HUB_KEYPAIR.publicKey;
280
+ let hubSigningKeyB64 = null;
281
+
257
282
  const apiPath = require.resolve('../src/module-api.js');
258
283
  const realApi = require(apiPath);
259
284
  require.cache[apiPath].exports = {
260
285
  ...realApi,
261
286
  pool,
287
+ resolveEnv: (suffix) => (suffix === 'HUB_SIGNING_KEY' ? hubSigningKeyB64 : realApi.resolveEnv(suffix)),
262
288
  logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
263
289
  requireBuilder: (req, _res, next) => {
264
290
  req.builder = { id: 1, github_id: ME, github_login: 'octo', display_name: 'Octo' };
@@ -272,9 +298,11 @@ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
272
298
  // The project's own admission surface, scripted. This is the only thing that
273
299
  // decides whether an application happened.
274
300
  let relay = { status: 201, body: { ok: true } };
301
+ let relayed = null; // the last request the relay sent the project — url + init
275
302
  const realFetch = globalThis.fetch;
276
- globalThis.fetch = async (url) => {
303
+ globalThis.fetch = async (url, init) => {
277
304
  if (String(url).endsWith('/api/bongos/access-requests')) {
305
+ relayed = { url: String(url), init };
278
306
  if (relay === null) throw new Error('connection refused');
279
307
  return { status: relay.status, json: async () => relay.body };
280
308
  }
@@ -386,6 +414,77 @@ test('a failed echo write never turns a filed application into a reported failur
386
414
  assert.equal(r.json.status, 'requested');
387
415
  });
388
416
 
417
+ // ── the vouch the relay carries (R09, task 1002285; ADR 0201 D2) ─────────────
418
+ // The ECHO above is what authorizes the project's later profile read
419
+ // (applicationBacksProfileRead); the VOUCH is what tells the project whose
420
+ // profile to ask for. The relay is the only minter of either, which is why the
421
+ // two are written by this one route and a vouch never arrives without an echo.
422
+
423
+ const VOUCH_HEADER = 'X-Bongos-Platform-Assertion';
424
+ const { verifyHubApplicationVouch } = require('../src/bongos/auth-idp.js');
425
+ const relayedVouch = () => {
426
+ const headers = relayed && relayed.init && relayed.init.headers;
427
+ return headers ? headers[VOUCH_HEADER] : undefined;
428
+ };
429
+
430
+ test('a provisioned hub relays a vouch the TARGET project verifies: this account, this project', async () => {
431
+ seed();
432
+ echoes = echoes.filter((e) => e.client_id !== 'proj-open');
433
+ hubSigningKeyB64 = HUB_SIGNING_KEY_B64;
434
+ relay = { status: 201, body: { ok: true } };
435
+ const r = await join('please');
436
+ assert.equal(r.json.status, 'requested');
437
+ const p = verifyHubApplicationVouch(relayedVouch(), { publicKey: hubPublicKey, clientId: 'proj-open' });
438
+ assert.equal(p.sub, String(ME), 'the hub session’s own account — never a body field');
439
+ assert.equal(p.applicant_login, 'octo', 'the same login the relayed body names, which the project checks');
440
+ assert.equal(p.handle, 'octo-h');
441
+ assert.throws(() => verifyHubApplicationVouch(relayedVouch(), { publicKey: hubPublicKey, clientId: 'proj-other' }), /audience/,
442
+ 'audience-scoped to the project it was relayed to');
443
+ assert.ok(echo('proj-open'), 'and the echo the profile read is gated on was written by the same relay');
444
+ });
445
+
446
+ test('a handle rides only past the consent floor — a provisional or no-terms account vouches with none', async () => {
447
+ for (const unconsented of [{ profile_state: 'provisional' }, { terms_accepted_at: null }]) {
448
+ seed();
449
+ hubSigningKeyB64 = HUB_SIGNING_KEY_B64;
450
+ account = { ...account, ...unconsented };
451
+ relay = { status: 201, body: { ok: true } };
452
+ await join();
453
+ const p = verifyHubApplicationVouch(relayedVouch(), { publicKey: hubPublicKey, clientId: 'proj-open' });
454
+ assert.equal(p.sub, String(ME), 'the identity is still vouched — it comes from the session, not the account row');
455
+ assert.equal(p.handle, null, `${JSON.stringify(unconsented)} discloses no handle, as on every other surface (ADR 0260 D2)`);
456
+ }
457
+ });
458
+
459
+ test('a PRIVATE account still vouches its handle — applying is the consent, as for the D3 sliver', async () => {
460
+ seed();
461
+ hubSigningKeyB64 = HUB_SIGNING_KEY_B64;
462
+ account = { ...account, account_visibility: 'private', hide_stats: true };
463
+ await join();
464
+ const p = verifyHubApplicationVouch(relayedVouch(), { publicKey: hubPublicKey, clientId: 'proj-open' });
465
+ assert.equal(p.handle, 'octo-h', 'the applicant view shows a private applicant’s handle too; the vouch shows no more');
466
+ });
467
+
468
+ test('a failed account read still relays, vouched, with no handle — the application never waits on it', async () => {
469
+ seed();
470
+ hubSigningKeyB64 = HUB_SIGNING_KEY_B64;
471
+ accountFails = true;
472
+ relay = { status: 201, body: { ok: true } };
473
+ const r = await join();
474
+ assert.equal(r.json.status, 'requested');
475
+ const p = verifyHubApplicationVouch(relayedVouch(), { publicKey: hubPublicKey, clientId: 'proj-open' });
476
+ assert.equal(p.handle, null);
477
+ });
478
+
479
+ test('a hub that never provisioned a signing key relays exactly as before: no header, no account read', async () => {
480
+ seed();
481
+ relay = { status: 201, body: { ok: true } };
482
+ const r = await join();
483
+ assert.equal(r.json.status, 'requested');
484
+ assert.equal(relayedVouch(), undefined, 'the project then writes the unvouched row it always wrote');
485
+ assert.ok(!statements.some((s) => /platform_identity_accounts/.test(s.sql)), 'nothing is read for a vouch that cannot be minted');
486
+ });
487
+
389
488
  // ── the read-back ────────────────────────────────────────────────────────────
390
489
 
391
490
  test('GET /my-projects reports what was applied to and when — and no status', async () => {
@@ -43,6 +43,7 @@
43
43
  import assert from 'node:assert/strict';
44
44
  import { test, before, after } from 'node:test';
45
45
  import http from 'node:http';
46
+ import crypto from 'node:crypto';
46
47
  import { createRequire } from 'node:module';
47
48
 
48
49
  const require = createRequire(import.meta.url);
@@ -111,6 +112,7 @@ function predicateFor(rawWhere, params) {
111
112
  }
112
113
  if ((m = /\b(?:ar\.)?status = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => r.status === v); eat(); }
113
114
  if ((m = /\b(?:ar\.)?status = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
115
+ if ((m = /\bapplicant_github_id IS NULL\b/.exec(where))) { tests.push((r) => r.applicant_github_id == null); eat(); }
114
116
 
115
117
  for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
116
118
  if (!consumed.has(ph[1])) {
@@ -163,9 +165,12 @@ const pool = {
163
165
  // The public write. The partial unique index on lower(github_login) WHERE
164
166
  // status='pending' is modelled for real, so a duplicate application raises a
165
167
  // genuine 23505 and the route's "re-applying is not an error" branch is
166
- // reached the way Postgres would reach it.
167
- if (/^INSERT INTO access_requests \(github_login, display_name, note\)/.test(sql)) {
168
- const [login, displayName, note] = params;
168
+ // reached the way Postgres would reach it. The column list and VALUES are
169
+ // matched EXACTLY and each value lands from its own placeholder, so the three
170
+ // vouch columns cannot be reordered or dropped from the real statement with
171
+ // every test still green (task 1002285).
172
+ if (/^INSERT INTO access_requests \(github_login, display_name, note, applicant_github_id, applicant_handle, vouched_at\) VALUES \(\$1, \$2, \$3, \$4, \$5, \$6\) RETURNING/.test(sql)) {
173
+ const [login, displayName, note, applicantGithubId, applicantHandle, vouchedAt] = params;
169
174
  if (rows.some((r) => r.status === 'pending' && String(r.github_login).toLowerCase() === String(login).toLowerCase())) {
170
175
  const err = new Error('duplicate key value violates unique constraint "uniq_access_requests_open_per_login"');
171
176
  err.code = '23505';
@@ -173,7 +178,8 @@ const pool = {
173
178
  }
174
179
  const fresh = {
175
180
  id: nextId++, github_login: login, display_name: displayName, note, status: 'pending',
176
- applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null,
181
+ applicant_github_id: applicantGithubId, applicant_handle: applicantHandle, vouched_at: vouchedAt,
182
+ resolved_by_builder_id: null, resolved_at: null,
177
183
  created_at: Date.now(),
178
184
  };
179
185
  rows.push(fresh);
@@ -188,12 +194,25 @@ const pool = {
188
194
  id: r.id, github_login: r.github_login, display_name: r.display_name, note: r.note,
189
195
  status: r.status, created_at: r.created_at, resolved_at: r.resolved_at,
190
196
  applicant_github_id: r.applicant_github_id, vouched_at: r.vouched_at,
197
+ // Returned only when the real SELECT names it, so the queue test below
198
+ // proves the route reads the column rather than that this fake invents it.
199
+ ...(/\bar\.applicant_handle\b/.test(sql) ? { applicant_handle: r.applicant_handle ?? null } : {}),
191
200
  vouched: r.applicant_github_id !== null,
192
201
  resolved_by_login: null,
193
202
  })),
194
203
  };
195
204
  }
196
205
 
206
+ // A vouched re-application taking over an unvouched pending row (ADR 0333 D4).
207
+ // The SET list is matched exactly, like the resolve's below.
208
+ if ((m = /^UPDATE access_requests SET display_name = \$2, note = \$3, applicant_github_id = \$4, applicant_handle = \$5, vouched_at = \$6 WHERE (.+?)$/.exec(sql))) {
209
+ const hit = rows.filter(predicateFor(m[1], params));
210
+ for (const r of hit) {
211
+ [r.display_name, r.note, r.applicant_github_id, r.applicant_handle, r.vouched_at] = params.slice(1, 6);
212
+ }
213
+ return { rows: [], rowCount: hit.length };
214
+ }
215
+
197
216
  // The resolve — one UPDATE, one WHERE, and that WHERE is the whole rule.
198
217
  // The SET list is matched EXACTLY, not as `.*?`: the fake applies its effect
199
218
  // from JS, so a `.*?` would let `resolved_by_builder_id = $2` be deleted from
@@ -237,6 +256,20 @@ require.cache[broadcastPath] = {
237
256
  exports: { postMemberApprovalRequest: async () => ({ posted: false, skipped: 'test' }) },
238
257
  };
239
258
 
259
+ // The hub's side of the vouch, for real: a generated Ed25519 keypair, the real
260
+ // minter, and the real verifier behind the doorway port — only the key and this
261
+ // project's client id are injected, since a test process has no hub config.
262
+ // Required (not imported) so it loads AFTER the ./pool stub above: an ESM import
263
+ // is hoisted, and auth-idp → db-kernel would capture the real pool first.
264
+ const { applicationVouchIdentity } = require('../src/bongos/auth-idp.js');
265
+ const hubKeys = require('../modules/platform-identity/hub-keys.js');
266
+ const HUB_KEYS = hubKeys.generateHubKeypair();
267
+ const hubSigningKey = crypto.createPrivateKey({ key: Buffer.from(HUB_KEYS.privateKeyB64, 'base64'), format: 'der', type: 'pkcs8' });
268
+ const hubPublicKey = crypto.createPublicKey({ key: Buffer.from(HUB_KEYS.publicKeyB64, 'base64'), format: 'der', type: 'spki' });
269
+ const SELF_CLIENT_ID = 'proj-self';
270
+ const vouchFor = (githubLogin, { githubId = 7001, handle = 'newcomer-h', clientId = SELF_CLIENT_ID, nowMs } = {}) =>
271
+ hubKeys.mintHubApplicationVouch({ clientId, githubId, githubLogin, handle }, { signingKey: hubSigningKey, nowMs });
272
+
240
273
  const apiPath = require.resolve('../src/module-api.js');
241
274
  const realApi = require(apiPath);
242
275
  const seams = require('../src/module-seams.js');
@@ -257,6 +290,8 @@ require.cache[apiPath].exports = {
257
290
  requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
258
291
  // requirePermission is NOT stubbed: realApi's own gate is used, so the denial
259
292
  // below is the production denial.
293
+ readApplicationVouch: (token, { githubLogin } = {}) =>
294
+ applicationVouchIdentity(token, { githubLogin, publicKey: hubPublicKey, clientId: SELF_CLIENT_ID }),
260
295
  };
261
296
 
262
297
  seams._reset();
@@ -282,12 +317,12 @@ before(async () => {
282
317
  });
283
318
  after(async () => { await new Promise((r) => server.close(r)); });
284
319
 
285
- function call(method, pathname, body) {
320
+ function call(method, pathname, body, extraHeaders = {}) {
286
321
  return new Promise((resolve, reject) => {
287
322
  const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
288
323
  const req = http.request({
289
324
  host: '127.0.0.1', port, method, path: pathname,
290
- headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
325
+ headers: { ...(payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {}), ...extraHeaders },
291
326
  }, (res) => {
292
327
  const chunks = [];
293
328
  res.on('data', (c) => chunks.push(c));
@@ -383,6 +418,110 @@ test('apply: an already-approved login is told to sign in, and nothing is writte
383
418
  assert.equal(JSON.stringify(rows), before);
384
419
  });
385
420
 
421
+ // ===========================================================================
422
+ // The vouch WRITER (R09, task 1002285; ADR 0201 D2) — the hub's relay carries a
423
+ // signed application vouch in a HEADER, and the row lands vouched only when it
424
+ // verifies here AND names the very login the body names.
425
+ // ===========================================================================
426
+
427
+ const VOUCH_HEADER = 'X-Bongos-Platform-Assertion';
428
+ const applyAs = (login, headers = {}) =>
429
+ call('POST', '/api/gds/access-requests', { github_login: login, display_name: 'New', note: 'hello (via platform My Projects)' }, headers);
430
+ const landed = (login) => rows.find((x) => x.github_login === login);
431
+
432
+ test('apply with a verified hub vouch: the row lands VOUCHED — github id, handle and when, together', async () => {
433
+ reset();
434
+ const r = await applyAs('Newcomer', { [VOUCH_HEADER]: vouchFor('newcomer') });
435
+ assert.equal(r.status, 201);
436
+ const row = landed('Newcomer');
437
+ assert.equal(row.applicant_github_id, '7001', 'the hub’s own account id, as text to join builders.github_id');
438
+ assert.equal(row.applicant_handle, 'newcomer-h', 'the handle rides with the identity it belongs to (ADR 0201 D2.6)');
439
+ assert.ok(row.vouched_at, 'and when it was verified, written in the same statement');
440
+ assert.equal(row.status, 'pending', 'a vouch files an application — it decides nothing');
441
+ assert.equal(await dbKernel.hasInvitedAccessRequest('Newcomer'), false,
442
+ 'a vouch written by the REAL writer still admits nobody (ADR 0016)');
443
+ });
444
+
445
+ test('apply: the vouch columns are written together or not at all', async () => {
446
+ reset();
447
+ await applyAs('Newcomer', { [VOUCH_HEADER]: vouchFor('newcomer') });
448
+ await applyAs('plain');
449
+ for (const row of [landed('Newcomer'), landed('plain')]) {
450
+ const set = [row.applicant_github_id, row.applicant_handle, row.vouched_at].map((v) => v != null);
451
+ assert.ok(set.every(Boolean) || set.every((v) => !v), `mixed vouch columns on @${row.github_login}: ${JSON.stringify(set)}`);
452
+ }
453
+ });
454
+
455
+ test('apply: a vouch for a DIFFERENT account than the body names lands the row unvouched', async () => {
456
+ reset();
457
+ const r = await applyAs('victim', { [VOUCH_HEADER]: vouchFor('attacker') });
458
+ assert.equal(r.status, 201, 'never a refusal (D2.3) — the row is simply the one this project always had');
459
+ assert.equal(landed('victim').applicant_github_id, null,
460
+ 'otherwise any vouch could be stapled to a request naming a well-known builder');
461
+ assert.equal(landed('victim').applicant_handle, null);
462
+ });
463
+
464
+ test('apply: a forged, expired, foreign or garbage vouch is never a refusal — it lands unvouched (D2.3)', async () => {
465
+ const otherKey = crypto.generateKeyPairSync('ed25519').privateKey;
466
+ const bad = {
467
+ garbage: 'not.a.vouch',
468
+ expired: vouchFor('newcomer', { nowMs: Date.now() - 10 * 60 * 1000 }),
469
+ 'another project’s': vouchFor('newcomer', { clientId: 'proj-elsewhere' }),
470
+ forged: hubKeys.mintHubApplicationVouch({ clientId: SELF_CLIENT_ID, githubId: 7001, githubLogin: 'newcomer' }, { signingKey: otherKey }),
471
+ 'a login assertion': hubKeys.mintHubAssertion({ clientId: SELF_CLIENT_ID, githubId: 7001, githubLogin: 'newcomer' }, { signingKey: hubSigningKey }),
472
+ };
473
+ for (const [kind, token] of Object.entries(bad)) {
474
+ reset();
475
+ const r = await applyAs('Newcomer', { [VOUCH_HEADER]: token });
476
+ assert.equal(r.status, 201, `${kind}: an attacker must not be able to make applications fail by sending garbage`);
477
+ assert.equal(landed('Newcomer').applicant_github_id, null, `${kind} vouch must not vouch`);
478
+ }
479
+ });
480
+
481
+ test('apply: one vouch vouches ONE application — the same header replayed lands unvouched', async () => {
482
+ reset();
483
+ const token = vouchFor('newcomer');
484
+ await applyAs('Newcomer', { [VOUCH_HEADER]: token });
485
+ rows = rows.filter((x) => x.github_login !== 'Newcomer'); // free the open-row index for the replay
486
+ await applyAs('Newcomer', { [VOUCH_HEADER]: token });
487
+ assert.equal(landed('Newcomer').applicant_github_id, null, 'the replay carried a spent jti');
488
+ });
489
+
490
+ test('a vouched re-application takes over an UNVOUCHED pending row for its own login — name and note too', async () => {
491
+ reset();
492
+ // ada's pending row is unvouched: filed off the landing page, or by anyone typing her login.
493
+ const r = await applyAs('ada', { [VOUCH_HEADER]: vouchFor('ada', { githubId: 8001, handle: 'ada-h' }) });
494
+ assert.equal(r.status, 200);
495
+ assert.equal(r.json.already_pending, true, 'still one open request — the index holds');
496
+ assert.equal(rows.filter((x) => x.github_login === 'ada').length, 1);
497
+ assert.equal(row(1).applicant_github_id, '8001', 'a row someone else filed under the login cannot pre-empt the real applicant');
498
+ assert.equal(row(1).applicant_handle, 'ada-h');
499
+ assert.ok(row(1).vouched_at);
500
+ assert.equal(row(1).display_name, 'New', 'and the words beside "Identity vouched" are now the vouched applicant’s own');
501
+ assert.match(row(1).note, /via platform My Projects/);
502
+ assert.equal(row(1).status, 'pending', 'and it decides nothing');
503
+ });
504
+
505
+ test('a re-application never re-stamps an ALREADY vouched row, and an unvouched one changes nothing', async () => {
506
+ reset();
507
+ const bo = { ...row(2) };
508
+ await applyAs('bo', { [VOUCH_HEADER]: vouchFor('bo', { githubId: 9999 }) });
509
+ assert.deepEqual(row(2), bo, 'the first vouch stands');
510
+ const ada = { ...row(1) };
511
+ await applyAs('ada');
512
+ assert.deepEqual(row(1), ada, 'an unvouched re-apply is the idempotent success it always was');
513
+ });
514
+
515
+ test('list-pending: the queue carries the handle the vouch brought, beside the vouch mark', async () => {
516
+ reset();
517
+ await applyAs('Newcomer', { [VOUCH_HEADER]: vouchFor('newcomer') });
518
+ const r = await call('GET', '/api/gds/access-requests');
519
+ const got = r.json.requests.find((x) => x.github_login === 'Newcomer');
520
+ assert.equal(got.vouched, true);
521
+ assert.equal(got.applicant_handle, 'newcomer-h');
522
+ assert.equal(r.json.requests.find((x) => x.github_login === 'ada').applicant_handle, null, 'an unvouched row has no handle');
523
+ });
524
+
386
525
  // ===========================================================================
387
526
  // The queue — and the vouched/unvouched distinction it must carry
388
527
  // ===========================================================================
@@ -13,6 +13,9 @@
13
13
  import { strict as assert } from 'node:assert';
14
14
  import { createRequire } from 'node:module';
15
15
  import crypto from 'node:crypto';
16
+ // ESM import so the knip ratchet credits these test-facing exports (a
17
+ // createRequire() import does not credit a CJS export).
18
+ import { applicationVouchIdentity, verifyHubApplicationVouch } from '../src/bongos/auth-idp.js';
16
19
 
17
20
  const require = createRequire(import.meta.url);
18
21
  const auth = require('../src/bongos/auth.js');
@@ -194,5 +197,176 @@ t('a logout jti is fresh on first use, seen on replay, and evicted after expiry'
194
197
  assert.equal(auth.logoutTokenJtiSeenAndRecord(undefined, exp, now), false, 'missing jti is never "seen"');
195
198
  });
196
199
 
200
+ // --- Application vouch (ADR 0201 D2, ADR 0208 D2, task 1002285) -------------
201
+ // The hub's join relay carries a THIRD token kind to the target project: an
202
+ // application vouch — identity AND purpose 'application'. ADR 0208 D2 found the
203
+ // hole this closes: verifyHubAssertion never looked at `purpose`, so any
204
+ // identity payload verified AS A LOGIN, and a captured vouch would have been
205
+ // replayable into POST /auth/device/exchange as a device sign-in.
206
+
207
+ function mintVouch(overrides = {}, opts = {}) {
208
+ return hubKeys.mintHubApplicationVouch(
209
+ { clientId: 'client-x', githubId: 42, githubLogin: 'octo', handle: 'octo', ...overrides },
210
+ { signingKey, ...opts },
211
+ );
212
+ }
213
+
214
+ // Sign an ARBITRARY payload with the real hub key — the only way to ask "what
215
+ // does a verifier do with a shape no minter emits today?", which is exactly the
216
+ // window ADR 0208 D2 says to close.
217
+ function signRaw(payload) {
218
+ const b64u = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
219
+ const input = `${b64u({ alg: 'EdDSA', typ: 'JWT' })}.${b64u(payload)}`;
220
+ return `${input}.${crypto.sign(null, Buffer.from(input), signingKey).toString('base64url')}`;
221
+ }
222
+ const nowS = () => Math.floor(Date.now() / 1000);
223
+
224
+ console.log('\nhub application vouch mint → instance verify (roundtrip):');
225
+
226
+ t('a valid vouch verifies, carrying identity + purpose + handle and NO rank', () => {
227
+ const p = verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: 'client-x' });
228
+ assert.equal(p.sub, '42');
229
+ assert.equal(p.applicant_login, 'octo');
230
+ assert.equal(p.github_login, undefined,
231
+ 'the login rides as applicant_login: every login verifier any core ever shipped demands github_login, '
232
+ + 'so a core that predates the purpose refusal still cannot take a vouch as a device sign-in');
233
+ assert.equal(p.handle, 'octo');
234
+ assert.equal(p.purpose, 'application');
235
+ assert.equal(p.aud, 'client-x');
236
+ assert.ok(p.jti, 'a vouch always carries a jti — single use is chosen, not optional');
237
+ assert.equal(p.rank, undefined, 'a vouch is identity, never authority (ADR 0016)');
238
+ assert.equal(p.display_name, undefined, 'and not the login assertion’s profile fields either');
239
+ });
240
+
241
+ console.log('\nadversarial (vouch) — every one of these MUST be rejected:');
242
+
243
+ t('wrong audience (a vouch for project A cannot vouch at project B)', () => {
244
+ assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: 'other-client' }), /audience/);
245
+ });
246
+
247
+ t('NO audience configured never verifies — the envelope would otherwise skip the aud check', () => {
248
+ assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: null }), /audience/);
249
+ });
250
+
251
+ t('expired vouch (minted 10 min ago; TTL is ~120s)', () => {
252
+ const stale = mintVouch({}, { nowMs: Date.now() - 10 * 60 * 1000 });
253
+ assert.throws(() => verifyHubApplicationVouch(stale, { publicKey, clientId: 'client-x' }), /expired/);
254
+ });
255
+
256
+ t('tampered payload (swap sub to another github_id) fails the signature', () => {
257
+ const [h, p, s] = mintVouch().split('.');
258
+ const payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
259
+ payload.sub = '999';
260
+ const forged = `${h}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.${s}`;
261
+ assert.throws(() => verifyHubApplicationVouch(forged, { publicKey, clientId: 'client-x' }), /signature/);
262
+ });
263
+
264
+ t('a different signing key, alg "none", malformed and a missing public key are all rejected', () => {
265
+ const otherPub = crypto.generateKeyPairSync('ed25519').publicKey;
266
+ assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey: otherPub, clientId: 'client-x' }), /signature/);
267
+ const [h, p, s] = mintVouch().split('.');
268
+ const hdr = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'));
269
+ hdr.alg = 'none';
270
+ const confused = `${Buffer.from(JSON.stringify(hdr)).toString('base64url')}.${p}.${s}`;
271
+ assert.throws(() => verifyHubApplicationVouch(confused, { publicKey, clientId: 'client-x' }), /alg|signature/);
272
+ assert.throws(() => verifyHubApplicationVouch('not-a-jwt', { publicKey, clientId: 'client-x' }), /malformed/);
273
+ assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey: null, clientId: 'client-x' }), /public key/);
274
+ });
275
+
276
+ t('a vouch with no jti is rejected — one vouch, one application needs something to count', () => {
277
+ const noJti = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60 });
278
+ assert.throws(() => verifyHubApplicationVouch(noJti, { publicKey, clientId: 'client-x' }), /jti/);
279
+ });
280
+
281
+ t('a vouch with no applicant identity is rejected by the verifier itself, not only by the login match', () => {
282
+ for (const drop of ['sub', 'applicant_login']) {
283
+ const claims = { iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60, jti: `no-${drop}` };
284
+ delete claims[drop];
285
+ assert.throws(() => verifyHubApplicationVouch(signRaw(claims), { publicKey, clientId: 'client-x' }), /identity/, `missing ${drop}`);
286
+ }
287
+ });
288
+
289
+ t('mintHubApplicationVouch refuses without a signing key', () => {
290
+ assert.throws(() => hubKeys.mintHubApplicationVouch({ clientId: 'c', githubId: 1, githubLogin: 'x' }, {}), /signing key/);
291
+ });
292
+
293
+ console.log('\nlogin / logout / vouch are mutually NON-interchangeable:');
294
+
295
+ t('an APPLICATION VOUCH is NOT accepted as a login assertion — the replay ADR 0208 D2 found open', () => {
296
+ assert.throws(() => auth.verifyHubAssertion(mintVouch(), { publicKey, clientId: 'client-x' }), /identity|purpose/);
297
+ // And the purpose refusal holds on its own: a vouch-shaped token that ALSO carried
298
+ // github_login (the shape a bolted-on purpose would have produced) still fails.
299
+ const both = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', github_login: 'octo', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60, jti: 'both' });
300
+ assert.throws(() => auth.verifyHubAssertion(both, { publicKey, clientId: 'client-x' }), /purpose/);
301
+ });
302
+
303
+ t('verifyHubAssertion refuses ANY purpose on an identity payload, not only this one', () => {
304
+ for (const purpose of ['application', 'anything', '', null]) {
305
+ const token = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', github_login: 'octo', purpose, iat: nowS(), exp: nowS() + 60, jti: `p-${purpose}` });
306
+ assert.throws(() => auth.verifyHubAssertion(token, { publicKey, clientId: 'client-x' }), /purpose/,
307
+ `purpose=${JSON.stringify(purpose)} must not verify as a login`);
308
+ }
309
+ });
310
+
311
+ t('a LOGIN assertion is NOT accepted as an application vouch (no purpose)', () => {
312
+ assert.throws(() => verifyHubApplicationVouch(mint(), { publicKey, clientId: 'client-x' }), /application vouch/);
313
+ });
314
+
315
+ t('a LOGOUT token is NOT accepted as an application vouch, nor a vouch as a logout', () => {
316
+ assert.throws(() => verifyHubApplicationVouch(mintLogout(), { publicKey, clientId: 'client-x' }), /application vouch/);
317
+ assert.throws(() => auth.verifyHubLogoutToken(mintVouch(), { publicKey, clientId: 'client-x' }), /logout/);
318
+ });
319
+
320
+ console.log('\napplicationVouchIdentity — what a project may take from a vouch:');
321
+
322
+ const vouchOpts = (githubLogin, extra = {}) => ({ githubLogin, publicKey, clientId: 'client-x', ...extra });
323
+
324
+ t('a vouch for the login on the request yields its github id and handle (login case-insensitive)', () => {
325
+ assert.deepEqual(applicationVouchIdentity(mintVouch(), vouchOpts('Octo')), { githubId: '42', handle: 'octo' });
326
+ });
327
+
328
+ t('a vouch naming a DIFFERENT account than the request is no vouch at all', () => {
329
+ assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('someone-else')), null,
330
+ 'otherwise one account’s vouch could be stapled to a request naming another');
331
+ });
332
+
333
+ t('one vouch vouches ONE application — the same token twice is vouched once', () => {
334
+ const token = mintVouch();
335
+ assert.ok(applicationVouchIdentity(token, vouchOpts('octo')));
336
+ assert.equal(applicationVouchIdentity(token, vouchOpts('octo')), null, 'the replay is unvouched');
337
+ });
338
+
339
+ t('an instance with no hub public key or no client id vouches nothing', () => {
340
+ assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('octo', { publicKey: null })), null);
341
+ assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('octo', { clientId: null })), null);
342
+ });
343
+
344
+ t('absent, garbage, forged and login-shaped tokens are null — never a throw', () => {
345
+ for (const token of [undefined, null, '', 'garbage', mint(), mintLogout()]) {
346
+ assert.equal(applicationVouchIdentity(token, vouchOpts('octo')), null);
347
+ }
348
+ });
349
+
350
+ t('LOCKSTEP: every handle the hub can hold survives the instance’s check, and nothing else does', () => {
351
+ // normalizeHandle is the hub's own rule (platform-identity.js); the instance keeps a
352
+ // boundary-forced mirror of it. Drive both over the same inputs so a change to
353
+ // either side that the other does not follow reds here, instead of handles
354
+ // silently arriving as null.
355
+ const { normalizeHandle } = require('../modules/platform-identity/platform-identity.js');
356
+ const inputs = ['octo', 'a', '9', 'a-b', 'builder-123456', 'x'.repeat(39), 'x'.repeat(40), '-a', 'a-', 'a_b', 'A', 'o.o', ''];
357
+ for (const raw of inputs) {
358
+ const hubHolds = normalizeHandle(raw) === raw;
359
+ const got = applicationVouchIdentity(mintVouch({ handle: raw }), vouchOpts('octo'));
360
+ assert.equal(got.handle, hubHolds ? raw : null, `handle ${JSON.stringify(raw)}: hub holds=${hubHolds}`);
361
+ }
362
+ });
363
+
364
+ t('a handle outside the canonical charset rides as null — the identity still stands', () => {
365
+ for (const handle of ['<b>octo</b>', 'Octo Cat', 'x'.repeat(60), 42, null]) {
366
+ assert.deepEqual(applicationVouchIdentity(mintVouch({ handle }), vouchOpts('octo')), { githubId: '42', handle: null },
367
+ `handle=${JSON.stringify(handle)}`);
368
+ }
369
+ });
370
+
197
371
  console.log(`\nidp_device_exchange: ${passed} passed, ${failed} failed`);
198
372
  if (failed > 0) process.exit(1);
@@ -47,6 +47,7 @@ const PUBLISHED_SURFACE = [
47
47
  'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
48
48
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
49
49
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
50
+ 'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience
50
51
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
51
52
  'isModuleEnabled', // task 1003881: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
52
53
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',