@bongos/core 1.19.1017 → 1.19.1018
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 +33 -23
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
- package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +7 -0
- package/migrations/core_254_access_requests_applicant_handle.sql +23 -0
- package/modules/onboarding/routes/access-requests.js +31 -4
- package/modules/platform-identity/hub-keys.js +51 -32
- package/modules/platform-identity/routes/my-projects.js +33 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-idp.js +93 -48
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +7 -1
- package/tests/application_echo.mjs +100 -1
- package/tests/application_lifecycle.mjs +145 -6
- package/tests/idp_device_exchange.mjs +174 -0
- package/tests/module_api.mjs +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1018",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/bongos/auth-idp.js
CHANGED
|
@@ -29,6 +29,7 @@ const { branding, userAgent } = require('../branding');
|
|
|
29
29
|
const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, openEnrollmentEnabled, reportActivityRollup, reportMembershipCheckIn, safeSyncRankRole } = require('./auth-admission.js');
|
|
30
30
|
const { loadIdpConfig } = require('./auth-config.js');
|
|
31
31
|
const { newSessionToken } = require('./auth-github.js');
|
|
32
|
+
const log = require('./logger').logger.child({ src: 'auth-idp' });
|
|
32
33
|
|
|
33
34
|
function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
|
|
34
35
|
const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/bongos/sso/authorize`);
|
|
@@ -149,32 +150,38 @@ function loadHubPublicKey() {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
// regardless of the header's `alg` field, so there is no algorithm-confusion attack.
|
|
158
|
-
// Returns the validated payload, or throws. Pure over its inputs (testable).
|
|
159
|
-
function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
153
|
+
// verifyHubToken — the envelope every hub token shares (hub-keys.js signHubToken,
|
|
154
|
+
// lockstep across the boundary, ADR 0091 §2): Ed25519 signature FIRST, alg hardcoded
|
|
155
|
+
// so there is no algorithm confusion, then issuer, audience (when given) and expiry.
|
|
156
|
+
// Which KIND of token it is, each verifier below decides from the claims.
|
|
157
|
+
function verifyHubToken(token, { publicKey, clientId, nowMs = Date.now(), label }) {
|
|
160
158
|
if (!publicKey) throw new Error('hub public key not configured');
|
|
161
159
|
const parts = String(token || '').split('.');
|
|
162
|
-
if (parts.length !== 3) throw new Error(
|
|
160
|
+
if (parts.length !== 3) throw new Error(`malformed ${label}`);
|
|
163
161
|
const [h, p, s] = parts;
|
|
164
162
|
let header;
|
|
165
163
|
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
166
|
-
catch { throw new Error(
|
|
167
|
-
if (!header || header.alg !== 'EdDSA') throw new Error(
|
|
164
|
+
catch { throw new Error(`malformed ${label} header`); }
|
|
165
|
+
if (!header || header.alg !== 'EdDSA') throw new Error(`unexpected ${label} alg`);
|
|
168
166
|
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
169
167
|
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
170
|
-
if (!ok) throw new Error(
|
|
168
|
+
if (!ok) throw new Error(`${label} signature invalid`);
|
|
171
169
|
let payload;
|
|
172
170
|
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
173
|
-
catch { throw new Error(
|
|
174
|
-
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error(
|
|
175
|
-
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error(
|
|
176
|
-
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error(
|
|
171
|
+
catch { throw new Error(`malformed ${label} payload`); }
|
|
172
|
+
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error(`unexpected ${label} issuer`);
|
|
173
|
+
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error(`${label} audience mismatch`);
|
|
174
|
+
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error(`${label} expired`);
|
|
175
|
+
return payload;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Verify a hub-signed LOGIN assertion: the envelope, identity, and NO `purpose` —
|
|
179
|
+
// any purpose, not a list, so no identity-bearing token kind can pass as a device
|
|
180
|
+
// sign-in (ADR 0208 D2, ADR 0333 D2).
|
|
181
|
+
function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
182
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'assertion' });
|
|
177
183
|
if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
|
|
184
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'purpose')) throw new Error('assertion carries a purpose — not a login assertion');
|
|
178
185
|
return payload;
|
|
179
186
|
}
|
|
180
187
|
|
|
@@ -195,41 +202,46 @@ const HUB_LOGOUT_PURPOSE = 'backchannel-logout';
|
|
|
195
202
|
// github_login (rejected by verifyHubAssertion). Returns the validated payload
|
|
196
203
|
// ({ sub: github_id, ... }) or throws. Pure over its inputs (testable, no I/O).
|
|
197
204
|
function verifyHubLogoutToken(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
198
|
-
|
|
199
|
-
const parts = String(token || '').split('.');
|
|
200
|
-
if (parts.length !== 3) throw new Error('malformed logout token');
|
|
201
|
-
const [h, p, s] = parts;
|
|
202
|
-
let header;
|
|
203
|
-
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
204
|
-
catch { throw new Error('malformed logout token header'); }
|
|
205
|
-
if (!header || header.alg !== 'EdDSA') throw new Error('unexpected logout token alg');
|
|
206
|
-
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
207
|
-
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
208
|
-
if (!ok) throw new Error('logout token signature invalid');
|
|
209
|
-
let payload;
|
|
210
|
-
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
211
|
-
catch { throw new Error('malformed logout token payload'); }
|
|
212
|
-
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected logout token issuer');
|
|
213
|
-
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('logout token audience mismatch');
|
|
214
|
-
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('logout token expired');
|
|
205
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'logout token' });
|
|
215
206
|
if (payload.purpose !== HUB_LOGOUT_PURPOSE) throw new Error('not a back-channel logout token');
|
|
216
207
|
if (payload.sub == null) throw new Error('logout token missing subject');
|
|
217
208
|
return payload;
|
|
218
209
|
}
|
|
219
210
|
|
|
211
|
+
// The purpose marker on a hub APPLICATION VOUCH — lockstep with hub-keys.js.
|
|
212
|
+
const HUB_APPLICATION_PURPOSE = 'application';
|
|
213
|
+
|
|
214
|
+
// Verify a hub-signed APPLICATION VOUCH (ADR 0333): purpose 'application', identity
|
|
215
|
+
// (sub + applicant_login), and — stricter than its siblings — a required audience
|
|
216
|
+
// (the envelope skips the check without one) and a jti (single use needs one).
|
|
217
|
+
function verifyHubApplicationVouch(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
218
|
+
if (clientId == null || clientId === '') throw new Error('application vouch audience not configured');
|
|
219
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'application vouch' });
|
|
220
|
+
if (payload.purpose !== HUB_APPLICATION_PURPOSE) throw new Error('not an application vouch');
|
|
221
|
+
if (payload.sub == null || !payload.applicant_login) throw new Error('application vouch missing identity');
|
|
222
|
+
if (!payload.jti) throw new Error('application vouch missing jti');
|
|
223
|
+
return payload;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// jtiStore — a single-use jti register. One per token kind, so no two kinds share
|
|
227
|
+
// dedupe state; entries expire with their token. In-memory per process.
|
|
228
|
+
function jtiStore() {
|
|
229
|
+
const seen = new Map(); // jti -> expiry ms
|
|
230
|
+
return function seenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
231
|
+
for (const [k, exp] of seen) if (exp < nowMs) seen.delete(k);
|
|
232
|
+
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
233
|
+
if (seen.has(jti)) return true;
|
|
234
|
+
seen.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
235
|
+
return false;
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
220
239
|
// Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
|
|
221
240
|
// aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
|
|
222
241
|
// assertion's own expiry, so a captured assertion can't be replayed into multiple
|
|
223
242
|
// sessions even inside its ~120s life. A restart clears it — acceptable given the
|
|
224
243
|
// short window and that the aud binding is the primary control.
|
|
225
|
-
const
|
|
226
|
-
function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
227
|
-
for (const [k, exp] of _seenAssertionJti) if (exp < nowMs) _seenAssertionJti.delete(k);
|
|
228
|
-
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
229
|
-
if (_seenAssertionJti.has(jti)) return true;
|
|
230
|
-
_seenAssertionJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
244
|
+
const _assertionJtiSeenAndRecord = jtiStore();
|
|
233
245
|
|
|
234
246
|
// Anti-replay for back-channel LOGOUT tokens (task 1002238, symmetric with the CLI
|
|
235
247
|
// assertion path above). Honor a logout token's jti ONCE: the FIRST use revokes,
|
|
@@ -241,13 +253,43 @@ function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
|
241
253
|
// share dedupe state. In-memory per process; a restart clears it (acceptable — the
|
|
242
254
|
// window is short and the aud binding is the primary control). Exported for the
|
|
243
255
|
// route (routes/auth.js) since the DB revoke lives there, not in the kernel.
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
const logoutTokenJtiSeenAndRecord = jtiStore();
|
|
257
|
+
|
|
258
|
+
// Anti-replay for APPLICATION VOUCHES — chosen, not inherited (ADR 0333 D3): one
|
|
259
|
+
// vouch vouches one application.
|
|
260
|
+
const vouchJtiSeenAndRecord = jtiStore();
|
|
261
|
+
|
|
262
|
+
// The hub's handle charset (platform-identity.js normalizeHandle — boundary-forced
|
|
263
|
+
// mirror, pinned by tests/idp_device_exchange.mjs). Anything else is stored as null.
|
|
264
|
+
const HUB_HANDLE_RE = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
|
|
265
|
+
|
|
266
|
+
// applicationVouchIdentity — { githubId, handle } from a verified, unspent vouch that
|
|
267
|
+
// names the request's own login, else null; never throws. Null is never a refusal
|
|
268
|
+
// (ADR 0201 D2.3): the route writes the unvouched row. Key + audience injected.
|
|
269
|
+
function applicationVouchIdentity(token, { githubLogin, publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
270
|
+
if (!token || !publicKey || !clientId) return null;
|
|
271
|
+
let payload;
|
|
272
|
+
try {
|
|
273
|
+
payload = verifyHubApplicationVouch(token, { publicKey, clientId, nowMs });
|
|
274
|
+
} catch (err) {
|
|
275
|
+
log.info({ reason: err.message }, 'application vouch not accepted — the request is written unvouched');
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
// The body's login is a string anyone may type: a vouch for another account is no vouch.
|
|
279
|
+
if (String(payload.applicant_login).toLowerCase() !== String(githubLogin || '').toLowerCase()) return null;
|
|
280
|
+
if (vouchJtiSeenAndRecord(payload.jti, payload.exp, nowMs)) return null;
|
|
281
|
+
return {
|
|
282
|
+
githubId: String(payload.sub),
|
|
283
|
+
handle: typeof payload.handle === 'string' && HUB_HANDLE_RE.test(payload.handle) ? payload.handle : null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// readApplicationVouch — the doorway port: supplies THIS instance's hub key and
|
|
288
|
+
// client_id itself, so a module can neither swap the key nor re-address the check.
|
|
289
|
+
function readApplicationVouch(token, { githubLogin } = {}) {
|
|
290
|
+
if (!token) return null;
|
|
291
|
+
const idp = loadIdpConfig();
|
|
292
|
+
return applicationVouchIdentity(token, { githubLogin, publicKey: idp && loadHubPublicKey(), clientId: idp && idp.clientId });
|
|
251
293
|
}
|
|
252
294
|
|
|
253
295
|
// Complete a CLI federation exchange: verify the hub assertion, then run the SAME
|
|
@@ -318,6 +360,7 @@ async function completeDeviceExchange({ assertion, userAgent: ua }) {
|
|
|
318
360
|
// without creds or network.
|
|
319
361
|
|
|
320
362
|
module.exports = {
|
|
363
|
+
applicationVouchIdentity,
|
|
321
364
|
buildIdpAuthorizeUrl,
|
|
322
365
|
completeDeviceExchange,
|
|
323
366
|
completeIdpFlow,
|
|
@@ -325,6 +368,8 @@ module.exports = {
|
|
|
325
368
|
idpAuthorizeUrl,
|
|
326
369
|
loadHubPublicKey,
|
|
327
370
|
logoutTokenJtiSeenAndRecord,
|
|
371
|
+
readApplicationVouch,
|
|
372
|
+
verifyHubApplicationVouch,
|
|
328
373
|
verifyHubAssertion,
|
|
329
374
|
verifyHubLogoutToken,
|
|
330
375
|
};
|
package/src/bongos/auth.js
CHANGED
|
@@ -57,7 +57,7 @@ const { branding, userAgent } = require('../branding');
|
|
|
57
57
|
const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
|
|
58
58
|
const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
|
|
59
59
|
const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
|
|
60
|
-
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
60
|
+
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
61
61
|
const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
|
|
62
62
|
|
|
63
63
|
const MAX_AUTH_TOKEN_CANDIDATES = 6;
|
|
@@ -901,6 +901,7 @@ module.exports = {
|
|
|
901
901
|
openEnrollmentEnabled,
|
|
902
902
|
joinabilityMode,
|
|
903
903
|
readApplicantProfileFromHub,
|
|
904
|
+
readApplicationVouch,
|
|
904
905
|
firstTimeAdmissionBlocked,
|
|
905
906
|
instanceNeedsFirstAdmin,
|
|
906
907
|
webAdmissionStatus,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1018'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -346,6 +346,12 @@ module.exports = {
|
|
|
346
346
|
// re-address it. Fails soft to null on every error, so a hub outage costs
|
|
347
347
|
// the profile block and never the queue.
|
|
348
348
|
get readApplicantProfileFromHub() { return require('./bongos/auth').readApplicantProfileFromHub; },
|
|
349
|
+
// readApplicationVouch(token, { githubLogin }) — the write-side twin (task
|
|
350
|
+
// 1002285, ADR 0201 D2): verify the hub's application vouch on a public
|
|
351
|
+
// access request → { githubId, handle } or null. Core supplies this
|
|
352
|
+
// instance's hub key and client_id itself, so a module can neither
|
|
353
|
+
// substitute a key nor re-address the check. Null is never a refusal.
|
|
354
|
+
get readApplicationVouch() { return require('./bongos/auth').readApplicationVouch; },
|
|
349
355
|
// userAgent — the instance's HTTP User-Agent string (branding-derived). The
|
|
350
356
|
// carved lifecycle module's github-push.js stamps every GitHub API request with
|
|
351
357
|
// it instead of reaching into src/branding.js directly (BV1.R86).
|
|
@@ -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
|
-
|
|
168
|
-
|
|
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:
|
|
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
|
// ===========================================================================
|