@bongos/core 1.19.423 → 1.19.425

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 (31) hide show
  1. package/.bongos-core.json +66 -31
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +164 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +10 -5
  6. package/docs/api-reference.md +2 -2
  7. package/docs/copy-inventory.md +208 -208
  8. package/docs/copy-registry.json +260 -260
  9. package/docs/design/mocks/hub/wizard.html +421 -0
  10. package/docs/design/projects-hub-direction-v2.md +26 -0
  11. package/docs/file-map.md +2 -0
  12. package/docs/module-api-changelog.md +4 -0
  13. package/migrations/core_230_access_requests_applicant_vouch.sql +79 -0
  14. package/modules/discord/discord-approvals.js +7 -2
  15. package/modules/hall-ui/public/watch.js +10 -1
  16. package/modules/onboarding/routes/access-requests.js +115 -11
  17. package/modules/platform-identity/application-echo.js +115 -0
  18. package/modules/platform-identity/migrations/platform_identity_016_application_echo.sql +68 -0
  19. package/modules/platform-identity/routes/my-projects.js +57 -2
  20. package/modules/ui-design/kit/serve.js +3 -1
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/public-landing/assets/cosmos.css +63 -0
  24. package/public-landing/projects.html +207 -199
  25. package/public-landing/projects.probes.json +7 -1
  26. package/public-landing/projects.states.json +7 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/application_echo.mjs +446 -0
  29. package/tests/application_lifecycle.mjs +634 -0
  30. package/tests/own_scope_guard.mjs +5 -0
  31. package/tests/projects_hub.mjs +56 -0
@@ -0,0 +1,634 @@
1
+ // tests/application_lifecycle.mjs — the application lifecycle EXECUTED: apply,
2
+ // list, approve, decline, rescind, and the two things that must never happen —
3
+ // a builder without the owner's authority moving the queue, and a VOUCH buying
4
+ // admission (task 1002284, R08 of goal 1000045; ADR 0201 D1/D3.2/D4, ADR 0016).
5
+ //
6
+ // THE MECHANISM, copied from tests/connections_ownership.mjs and
7
+ // tests/own_session_revoke.mjs: the pool below is a real in-memory
8
+ // `access_requests` table plus a mini-interpreter that builds each statement's
9
+ // row filter FROM THAT STATEMENT'S OWN WHERE CLAUSE. Drop the transition guard
10
+ // from the PATCH and the filter genuinely widens, so a declined applicant
11
+ // genuinely becomes admitted, and these tests genuinely fail. The interpreter
12
+ // REFUSES any statement whose placeholders it did not all consume, so a
13
+ // predicate it cannot read is a loud error rather than a silently weaker filter
14
+ // — the failure mode that makes a fake pool worthless.
15
+ //
16
+ // Everything above the storage is REAL: the real router, the REAL
17
+ // `requirePermission('access_request.review')` gate resolving against the REAL
18
+ // government catalog's rank seed, the real error envelope, real HTTP. The
19
+ // admission read is the REAL `hasInvitedAccessRequest` out of src/bongos/db-kernel.js,
20
+ // pointed at the same in-memory table through a stubbed ./pool — so "does this
21
+ // row admit anyone?" is answered by the function that actually admits people,
22
+ // not by a paraphrase of it.
23
+ //
24
+ // WHY THE PERMISSION GATE *IS* THE OWNERSHIP TEST. ADR 0201 D3 decided there is
25
+ // no cross-project owner authority: the instance IS the project (ADR 0108), so
26
+ // "the project's owner" is that instance's own Archon and the check is the atom
27
+ // that already guards this act — `access_request.review`, floor archon. A
28
+ // builder who is not the owner is therefore a builder whose live rank does not
29
+ // carry that atom, and the proof is that the real gate refuses them and the row
30
+ // does not move. No new permission is minted by this task.
31
+ //
32
+ // WHY THE VOUCH PROOF LOOKS LIKE THIS. R08 ships no vouch WRITER (the assertion
33
+ // and the header are R09, task 1002285), so a vouched row is seeded directly and
34
+ // then asked to behave: the approve path and the admission read must be
35
+ // BYTE-IDENTICAL to the unvouched case, and neither statement may so much as
36
+ // mention the vouch columns. A vouch is storage the gate never consults. The day
37
+ // somebody wires `applicant_github_id` into admission, this file goes red.
38
+ //
39
+ // DB-free. Runs in the unit gate that executes on a PR.
40
+ //
41
+ // Run: node --test tests/application_lifecycle.mjs
42
+
43
+ import assert from 'node:assert/strict';
44
+ import { test, before, after } from 'node:test';
45
+ import http from 'node:http';
46
+ import { createRequire } from 'node:module';
47
+
48
+ const require = createRequire(import.meta.url);
49
+ const express = require('express');
50
+
51
+ // ===========================================================================
52
+ // The in-memory access_requests table + its statement interpreter
53
+ // ===========================================================================
54
+
55
+ const HOUR = 60 * 60 * 1000;
56
+
57
+ let rows = [];
58
+ let builders = [];
59
+ let statements = [];
60
+ let nextId = 100;
61
+
62
+ // Two PENDING rows that differ in exactly one thing — whether the hub vouched
63
+ // for who is asking — so "the vouch changes nothing" can be asserted by
64
+ // comparing them rather than by reading the code and believing it.
65
+ function seed() {
66
+ const now = Date.now();
67
+ rows = [
68
+ { id: 1, github_login: 'ada', display_name: 'Ada', note: 'I build tools', status: 'pending', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
69
+ { id: 2, github_login: 'bo', display_name: 'Bo', note: 'I build tools', status: 'pending', applicant_github_id: '9002', vouched_at: now - 3 * HOUR, resolved_by_builder_id: null, resolved_at: null, created_at: now - 3 * HOUR },
70
+ { id: 3, github_login: 'cy', display_name: 'Cy', note: null, status: 'invited', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - 2 * HOUR, created_at: now - 4 * HOUR },
71
+ { id: 4, github_login: 'dee', display_name: 'Dee', note: null, status: 'dismissed', applicant_github_id: null, vouched_at: null, resolved_by_builder_id: 7, resolved_at: now - HOUR, created_at: now - 5 * HOUR },
72
+ ];
73
+ builders = [{ github_login: 'existing' }];
74
+ statements = [];
75
+ nextId = 100;
76
+ }
77
+ const row = (id) => rows.find((r) => r.id === id);
78
+ const isBuilder = (login) =>
79
+ builders.some((b) => String(b.github_login).toLowerCase() === String(login).toLowerCase());
80
+
81
+ /**
82
+ * Build a row filter from a statement's own WHERE text. Every placeholder the
83
+ * clause mentions must be consumed by a fragment below; one that is not throws,
84
+ * so a predicate this cannot read can never degrade into "no constraint".
85
+ */
86
+ function predicateFor(rawWhere, params) {
87
+ let where = rawWhere;
88
+ const tests = [];
89
+ const consumed = new Set();
90
+ const p = (n) => { consumed.add(String(n)); return params[Number(n) - 1]; };
91
+
92
+ // The PATCH's transition guard, matched and STRIPPED first so the bare
93
+ // `status = '...'` matcher below cannot re-read half of it as a one-sided
94
+ // filter. This disjunction IS the rescind rule (ADR 0201 D4.2): resolve from
95
+ // pending, or step invited → dismissed and nothing else.
96
+ const transition = /\(status = 'pending' OR \(status = 'invited' AND \$(\d+)::text = 'dismissed' AND NOT EXISTS \(SELECT 1 FROM builders b WHERE lower\(b\.github_login\) = lower\(access_requests\.github_login\)\)\)\)/.exec(where);
97
+ if (transition) {
98
+ const next = p(transition[1]);
99
+ tests.push((r) => r.status === 'pending'
100
+ || (r.status === 'invited' && next === 'dismissed' && !isBuilder(r.github_login)));
101
+ where = where.replace(transition[0], ' TRUE ');
102
+ }
103
+
104
+ let m;
105
+ const eat = () => { where = where.replace(m[0], ' TRUE '); };
106
+ if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
107
+ if ((m = /\blower\(github_login\) = lower\(\$(\d+)\)/.exec(where))) {
108
+ const v = String(p(m[1])).toLowerCase();
109
+ tests.push((r) => String(r.github_login).toLowerCase() === v);
110
+ eat();
111
+ }
112
+ if ((m = /\b(?:ar\.)?status = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => r.status === v); eat(); }
113
+ if ((m = /\b(?:ar\.)?status = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
114
+
115
+ for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
116
+ if (!consumed.has(ph[1])) {
117
+ throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
118
+ }
119
+ }
120
+
121
+ // Every fragment matched above blanks itself to TRUE, so whatever is LEFT is a
122
+ // predicate this interpreter did not model. The $-sweep above cannot catch a
123
+ // LITERAL-ONLY one (`AND resolved_at IS NULL`, `AND status <> 'dismissed'`) —
124
+ // it binds nothing — and such a predicate silently narrows or widens the real
125
+ // query while every test stays green. That hole was found by review after it
126
+ // had already hidden one real filter, so the residual is swept too: this is
127
+ // what makes "refuses a predicate it cannot read" true of ALL predicates
128
+ // rather than only parameterised ones.
129
+ const residual = where
130
+ .replace(/\bTRUE\b/gi, ' ')
131
+ .replace(/\b(?:AND|OR)\b/gi, ' ')
132
+ .replace(/[()\s]/g, '');
133
+ if (residual) {
134
+ throw new Error(`fake pool: unmodelled predicate "${residual}" in "${rawWhere}" — a filter the interpreter cannot read must be a loud error, not a silent no-op`);
135
+ }
136
+ return (r) => tests.every((fn) => fn(r));
137
+ }
138
+
139
+ const pool = {
140
+ query: async (text, params = []) => {
141
+ const sql = String(text).replace(/\s+/g, ' ').trim();
142
+ statements.push({ sql, params });
143
+ let m;
144
+
145
+ // Is this login already a builder here?
146
+ if ((m = /^SELECT 1 FROM builders WHERE (.+?) LIMIT 1$/.exec(sql))) {
147
+ return { rows: builders.filter(predicateFor(m[1], params)).slice(0, 1).map(() => ({ ok: 1 })) };
148
+ }
149
+
150
+ // The one-row existence probes over access_requests — the public POST's
151
+ // "already approved?" check AND db-kernel's hasInvitedAccessRequest, which
152
+ // issue the identical statement. That identity is the point: the admission
153
+ // read has no special case for a vouched row because it has no case at all.
154
+ if ((m = /^SELECT 1 FROM access_requests WHERE (.+?) LIMIT 1$/.exec(sql))) {
155
+ return { rows: rows.filter(predicateFor(m[1], params)).slice(0, 1).map(() => ({ ok: 1 })) };
156
+ }
157
+
158
+ // The public write. The partial unique index on lower(github_login) WHERE
159
+ // status='pending' is modelled for real, so a duplicate application raises a
160
+ // genuine 23505 and the route's "re-applying is not an error" branch is
161
+ // reached the way Postgres would reach it.
162
+ if (/^INSERT INTO access_requests \(github_login, display_name, note\)/.test(sql)) {
163
+ const [login, displayName, note] = params;
164
+ if (rows.some((r) => r.status === 'pending' && String(r.github_login).toLowerCase() === String(login).toLowerCase())) {
165
+ const err = new Error('duplicate key value violates unique constraint "uniq_access_requests_open_per_login"');
166
+ err.code = '23505';
167
+ throw err;
168
+ }
169
+ const fresh = {
170
+ id: nextId++, github_login: login, display_name: displayName, note, status: 'pending',
171
+ applicant_github_id: null, vouched_at: null, resolved_by_builder_id: null, resolved_at: null,
172
+ created_at: Date.now(),
173
+ };
174
+ rows.push(fresh);
175
+ return { rows: [{ id: fresh.id, github_login: fresh.github_login, status: fresh.status, created_at: fresh.created_at }] };
176
+ }
177
+
178
+ // The owner's queue.
179
+ if ((m = /^SELECT ar\.id, .* FROM access_requests ar .* WHERE (.+?) ORDER BY/.exec(sql))) {
180
+ const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
181
+ return {
182
+ rows: hit.map((r) => ({
183
+ id: r.id, github_login: r.github_login, display_name: r.display_name, note: r.note,
184
+ status: r.status, created_at: r.created_at, resolved_at: r.resolved_at,
185
+ applicant_github_id: r.applicant_github_id, vouched_at: r.vouched_at,
186
+ vouched: r.applicant_github_id !== null,
187
+ resolved_by_login: null,
188
+ })),
189
+ };
190
+ }
191
+
192
+ // The resolve — one UPDATE, one WHERE, and that WHERE is the whole rule.
193
+ // The SET list is matched EXACTLY, not as `.*?`: the fake applies its effect
194
+ // from JS, so a `.*?` would let `resolved_by_builder_id = $2` be deleted from
195
+ // the real statement — an audit-trail regression — with every test still green.
196
+ if ((m = /^UPDATE access_requests SET status = \$1, resolved_by_builder_id = \$2, resolved_at = now\(\) WHERE (.+?) RETURNING/.exec(sql))) {
197
+ const hit = rows.filter(predicateFor(m[1], params));
198
+ for (const r of hit) { r.status = params[0]; r.resolved_by_builder_id = params[1]; r.resolved_at = Date.now(); }
199
+ return { rows: hit.map((r) => ({ id: r.id, github_login: r.github_login, status: r.status, resolved_at: r.resolved_at })) };
200
+ }
201
+
202
+ // The explain-only read behind a refusal. It decides nothing — but it does
203
+ // carry the membership fact the refusal needs, so the EXISTS is modelled.
204
+ if ((m = /^SELECT ar\.id, ar\.github_login, ar\.status, ar\.resolved_at, EXISTS \(SELECT 1 FROM builders b WHERE lower\(b\.github_login\) = lower\(ar\.github_login\)\) AS is_member FROM access_requests ar WHERE (.+?)$/.exec(sql))) {
205
+ const hit = rows.filter(predicateFor(m[1].replace(/\bar\./g, ''), params));
206
+ return {
207
+ rows: hit.map((r) => ({
208
+ id: r.id, github_login: r.github_login, status: r.status, resolved_at: r.resolved_at,
209
+ is_member: isBuilder(r.github_login),
210
+ })),
211
+ };
212
+ }
213
+
214
+ throw new Error(`fake pool: unsupported statement — ${sql}`);
215
+ },
216
+ };
217
+
218
+ // ===========================================================================
219
+ // The real router, the real permission gate, the real admission read
220
+ // ===========================================================================
221
+
222
+ // db-kernel reaches Postgres through ./pool; pointing that at the same in-memory
223
+ // table is what lets the REAL hasInvitedAccessRequest answer these tests.
224
+ const poolPath = require.resolve('../src/bongos/pool.js');
225
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
226
+
227
+ // Discord broadcast is fire-and-forget on the public write; stubbed so the suite
228
+ // is hermetic (it is best-effort in production and never affects the queue).
229
+ const broadcastPath = require.resolve('../modules/onboarding/approval-broadcast.js');
230
+ require.cache[broadcastPath] = {
231
+ id: broadcastPath, filename: broadcastPath, loaded: true,
232
+ exports: { postMemberApprovalRequest: async () => ({ posted: false, skipped: 'test' }) },
233
+ };
234
+
235
+ const apiPath = require.resolve('../src/module-api.js');
236
+ const realApi = require(apiPath);
237
+ const seams = require('../src/module-seams.js');
238
+ const catalog = require('../modules/government/catalog.js');
239
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
240
+ const dbKernel = require('../src/bongos/db-kernel.js');
241
+
242
+ // Who is calling, and at what LIVE rank. The rank is resolved through the real
243
+ // government port on every request — never cached — exactly as production does.
244
+ let actingRank = 'archon';
245
+
246
+ require.cache[apiPath].exports = {
247
+ ...realApi,
248
+ pool,
249
+ joinabilityMode: () => 'apply',
250
+ logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
251
+ requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
252
+ // requirePermission is NOT stubbed: realApi's own gate is used, so the denial
253
+ // below is the production denial.
254
+ };
255
+
256
+ seams._reset();
257
+ seams.registerProvider('government', {
258
+ // The compat seed DERIVED from the catalog's own floors — so this test moves
259
+ // with a floor change instead of pinning a stale rank.
260
+ resolveBuilderPermissions: async () => catalog.seedForRank(actingRank),
261
+ permissionFloor: (key) => (catalog.byKey(key) || {}).floor || null,
262
+ });
263
+
264
+ const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
265
+
266
+ const app = express();
267
+ app.use(express.json());
268
+ app.use(attachFail);
269
+ app.use('/api/gds', buildAccessRequestsRouter());
270
+
271
+ let server, port;
272
+ before(async () => {
273
+ server = http.createServer(app);
274
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
275
+ port = server.address().port;
276
+ });
277
+ after(async () => { await new Promise((r) => server.close(r)); });
278
+
279
+ function call(method, pathname, body) {
280
+ return new Promise((resolve, reject) => {
281
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
282
+ const req = http.request({
283
+ host: '127.0.0.1', port, method, path: pathname,
284
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
285
+ }, (res) => {
286
+ const chunks = [];
287
+ res.on('data', (c) => chunks.push(c));
288
+ res.on('end', () => {
289
+ const text = Buffer.concat(chunks).toString('utf8');
290
+ let json = null; try { json = JSON.parse(text); } catch { /* empty body */ }
291
+ resolve({ status: res.statusCode, json });
292
+ });
293
+ });
294
+ req.on('error', reject);
295
+ req.end(payload);
296
+ });
297
+ }
298
+ const reset = (rank = 'archon') => { seed(); actingRank = rank; };
299
+
300
+ // ===========================================================================
301
+ // The interpreter is the instrument, so it is calibrated first
302
+ // ===========================================================================
303
+
304
+ test('the interpreter refuses a predicate it cannot read, rather than ignoring it', () => {
305
+ assert.throws(
306
+ () => predicateFor('id = $1 AND some_new_column = $2', [1, 2]),
307
+ /unread predicate on \$2/,
308
+ 'a filter the fake cannot model must be a loud error — otherwise a rewritten WHERE silently widens',
309
+ );
310
+ });
311
+
312
+ test('the interpreter refuses a LITERAL-ONLY predicate too — the hole review found', () => {
313
+ assert.throws(
314
+ () => predicateFor("id = $1 AND resolved_at IS NULL", [1]),
315
+ /unmodelled predicate/,
316
+ 'a filter that binds nothing is invisible to the placeholder sweep, so the residual is swept as well'
317
+ );
318
+ // and the modelled shapes still parse clean
319
+ assert.doesNotThrow(() => predicateFor("lower(github_login) = lower($1) AND status = 'invited'", ['ada']));
320
+ });
321
+
322
+ test('the interpreter really does narrow on the transition guard', () => {
323
+ seed();
324
+ const guarded = "id = $3 AND (status = 'pending' OR (status = 'invited' AND $1::text = 'dismissed' AND NOT EXISTS (SELECT 1 FROM builders b WHERE lower(b.github_login) = lower(access_requests.github_login))))";
325
+ // Approving a DISMISSED row: the guard must match nothing...
326
+ assert.equal(rows.filter(predicateFor(guarded, ['invited', 42, 4])).length, 0);
327
+ // ...where the same statement without the guard matches it happily.
328
+ assert.equal(rows.filter(predicateFor('id = $1', [4])).length, 1,
329
+ 'without the status guard the same shape reaches a declined request');
330
+ // ...and the member half narrows too: cy is invited, so rescinding matches —
331
+ // until cy is a builder, at which point the same statement matches nothing.
332
+ assert.equal(rows.filter(predicateFor(guarded, ['dismissed', 42, 3])).length, 1);
333
+ builders.push({ github_login: 'CY' });
334
+ assert.equal(rows.filter(predicateFor(guarded, ['dismissed', 42, 3])).length, 0,
335
+ 'case-insensitively, and by the applicant login rather than the row id');
336
+ });
337
+
338
+ // ===========================================================================
339
+ // Apply — the one public, unauthenticated write
340
+ // ===========================================================================
341
+
342
+ test('apply: a stranger files a request and it lands pending', async () => {
343
+ reset();
344
+ const r = await call('POST', '/api/gds/access-requests', { github_login: '@Newcomer', display_name: 'New', note: 'hello' });
345
+ assert.equal(r.status, 201);
346
+ assert.equal(r.json.request.status, 'pending');
347
+ const landed = rows.find((x) => x.github_login === 'Newcomer');
348
+ assert.ok(landed, 'the row is really in the table');
349
+ assert.equal(landed.applicant_github_id, null, 'an application off the public write is UNVOUCHED — the row the project has always had');
350
+ });
351
+
352
+ test('apply: re-applying is a success, not an error (ADR 0201 D1.4)', async () => {
353
+ reset();
354
+ const before = rows.length;
355
+ const r = await call('POST', '/api/gds/access-requests', { github_login: 'ada', note: 'again' });
356
+ assert.equal(r.status, 200, 'a double-clicked Apply must never tell the applicant they failed');
357
+ assert.equal(r.json.already_pending, true);
358
+ assert.equal(rows.length, before, 'and the unique index means no second open row');
359
+ // The hub relay reads any 2xx as "requested", which is what makes the
360
+ // idempotent answer reach the applicant as a filed application.
361
+ const { joinRelayOutcome } = await import('../modules/platform-identity/routes/my-projects.js');
362
+ assert.equal(joinRelayOutcome(r.status, r.json).status, 'requested');
363
+ });
364
+
365
+ test('apply: an already-approved login is told to sign in, and nothing is written', async () => {
366
+ reset();
367
+ const before = JSON.stringify(rows);
368
+ const r = await call('POST', '/api/gds/access-requests', { github_login: 'cy' });
369
+ assert.equal(r.status, 409);
370
+ assert.equal(r.json.error.code, 'already_approved');
371
+ assert.equal(JSON.stringify(rows), before);
372
+ });
373
+
374
+ // ===========================================================================
375
+ // The queue — and the vouched/unvouched distinction it must carry
376
+ // ===========================================================================
377
+
378
+ test('list-pending: the owner sees the open queue, newest first', async () => {
379
+ reset();
380
+ const r = await call('GET', '/api/gds/access-requests');
381
+ assert.equal(r.status, 200);
382
+ assert.deepEqual(r.json.requests.map((x) => x.github_login), ['ada', 'bo']);
383
+ });
384
+
385
+ test('list-pending: every row says whether it is VOUCHED — the impersonation guard', async () => {
386
+ reset();
387
+ const r = await call('GET', '/api/gds/access-requests');
388
+ const byLogin = Object.fromEntries(r.json.requests.map((x) => [x.github_login, x]));
389
+ assert.equal(byLogin.ada.vouched, false, 'a name anyone could have typed must not read as vouched');
390
+ assert.equal(byLogin.ada.applicant_github_id, null);
391
+ assert.equal(byLogin.bo.vouched, true);
392
+ assert.equal(byLogin.bo.applicant_github_id, '9002');
393
+ assert.ok(byLogin.bo.vouched_at, 'when the vouch was verified rides along as audit data');
394
+ });
395
+
396
+ // The interpreter builds its filter from the placeholders a WHERE mentions, so a
397
+ // predicate carrying NONE of them — `AND ar.applicant_github_id IS NOT NULL`, say —
398
+ // would be invisible to it and the queue tests would stay green while production
399
+ // silently hid every unvouched row. The queue is the one read where that matters
400
+ // (it is also the one legitimately allowed to NAME the vouch columns, in its SELECT
401
+ // list), so its filter is pinned exactly rather than inferred.
402
+ test('list-pending: the queue filters on status and NOTHING else', async () => {
403
+ reset();
404
+ await call('GET', '/api/gds/access-requests');
405
+ const queue = statements.find((s) => /FROM access_requests ar/.test(s.sql));
406
+ const where = /WHERE (.+?) ORDER BY/.exec(queue.sql)[1];
407
+ assert.equal(where, 'ar.status = $1',
408
+ 'an extra predicate here would hide rows from the owner, and a placeholder-free one is invisible to the fake pool');
409
+ });
410
+
411
+ test('list-pending: ?status=invited|dismissed reads the audit trail, and a bad status is refused', async () => {
412
+ reset();
413
+ const invited = await call('GET', '/api/gds/access-requests?status=invited');
414
+ assert.deepEqual(invited.json.requests.map((x) => x.github_login), ['cy']);
415
+ const dismissed = await call('GET', '/api/gds/access-requests?status=dismissed');
416
+ assert.deepEqual(dismissed.json.requests.map((x) => x.github_login), ['dee']);
417
+ const bad = await call('GET', '/api/gds/access-requests?status=deleted');
418
+ assert.equal(bad.status, 400);
419
+ });
420
+
421
+ // ===========================================================================
422
+ // Approve / decline / rescind
423
+ // ===========================================================================
424
+
425
+ test('approve: pending → invited, and THAT is what opens the door', async () => {
426
+ reset();
427
+ assert.equal(await dbKernel.hasInvitedAccessRequest('ada'), false, 'a pending row admits nobody');
428
+ const r = await call('PATCH', '/api/gds/access-requests/1', { status: 'invited' });
429
+ assert.equal(r.status, 200);
430
+ assert.equal(r.json.request.status, 'invited');
431
+ assert.equal(row(1).resolved_by_builder_id, 42, 'the audit trail records who acted');
432
+ assert.equal(await dbKernel.hasInvitedAccessRequest('ada'), true, 'the REAL admission read now admits them');
433
+ });
434
+
435
+ test('decline: pending → dismissed, and the door stays shut', async () => {
436
+ reset();
437
+ const r = await call('PATCH', '/api/gds/access-requests/1', { status: 'dismissed' });
438
+ assert.equal(r.status, 200);
439
+ assert.equal(row(1).status, 'dismissed');
440
+ assert.equal(await dbKernel.hasInvitedAccessRequest('ada'), false);
441
+ });
442
+
443
+ test('rescind: invited → dismissed re-locks a door opened by mistake (D4.2)', async () => {
444
+ reset();
445
+ assert.equal(await dbKernel.hasInvitedAccessRequest('cy'), true, 'precondition: cy was approved');
446
+ const r = await call('PATCH', '/api/gds/access-requests/3', { status: 'dismissed' });
447
+ assert.equal(r.status, 200, 'this was the gap: the PATCH resolved only from pending');
448
+ assert.equal(row(3).status, 'dismissed');
449
+ assert.equal(await dbKernel.hasInvitedAccessRequest('cy'), false, 'hasInvitedAccessRequest stops matching — the door is shut again');
450
+ });
451
+
452
+ // THE FINDING THIS PAIR EXISTS FOR (adversarial review, 2026-08-29). Nothing
453
+ // flips an `invited` row when its applicant signs in, and firstTimeAdmissionBlocked
454
+ // short-circuits on an existing `builders` row — so `invited` + a live member is a
455
+ // permanently reachable state, and dismissing that row changes NOTHING about their
456
+ // access. Before this guard the route answered 200 and the queue reported a removal
457
+ // that had not happened: a false negative on an admission control, which is worse
458
+ // than a refusal because the owner stops looking.
459
+ test('rescind: a request whose applicant ALREADY SIGNED IN is refused, not silently flipped', async () => {
460
+ reset();
461
+ builders.push({ github_login: 'cy' });
462
+ const before = { ...row(3) };
463
+ const r = await call('PATCH', '/api/gds/access-requests/3', { status: 'dismissed' });
464
+ assert.equal(r.status, 409);
465
+ assert.equal(r.json.error.code, 'already_member');
466
+ assert.match(r.json.error.message, /builder-exit/, 'the refusal names the act that DOES remove a member');
467
+ assert.deepEqual(row(3), before, 'the row does not move — a dismissed row here would be a lie about a member');
468
+ assert.equal(await dbKernel.hasInvitedAccessRequest('cy'), true,
469
+ 'and nothing pretended their access changed, because it did not');
470
+ });
471
+
472
+ test('rescind: the member check is on the APPLICANT, case-insensitively — not on the acting builder', async () => {
473
+ reset();
474
+ // The acting archon is a builder by definition; only the APPLICANT's own
475
+ // membership may block a rescind, or no rescind would ever be possible.
476
+ builders.push({ github_login: 'owner' });
477
+ const r = await call('PATCH', '/api/gds/access-requests/3', { status: 'dismissed' });
478
+ assert.equal(r.status, 200, 'the archon being a builder must not block rescinding somebody else');
479
+ assert.equal(row(3).status, 'dismissed');
480
+
481
+ reset();
482
+ builders.push({ github_login: 'CY' });
483
+ const cased = await call('PATCH', '/api/gds/access-requests/3', { status: 'dismissed' });
484
+ assert.equal(cased.status, 409, 'GitHub logins are case-insensitive here as everywhere else in this file');
485
+ });
486
+
487
+ test('decline: a PENDING request from someone who is already a member is still dismissable', async () => {
488
+ reset();
489
+ builders.push({ github_login: 'ada' });
490
+ const r = await call('PATCH', '/api/gds/access-requests/1', { status: 'dismissed' });
491
+ assert.equal(r.status, 200, 'a stale pending row is housekeeping, not a removal — only the RESCIND is guarded');
492
+ assert.equal(row(1).status, 'dismissed');
493
+ });
494
+
495
+ test('a declined request is NOT revived by a PATCH — that path is the invite route (D4.2)', async () => {
496
+ reset();
497
+ const before = { ...row(4) };
498
+ const r = await call('PATCH', '/api/gds/access-requests/4', { status: 'invited' });
499
+ assert.equal(r.status, 409);
500
+ assert.equal(r.json.error.code, 'already_dismissed');
501
+ assert.match(r.json.error.message, /access-requests\/invite/, 'a refusal must name the action available there (ADR 0116)');
502
+ assert.deepEqual(row(4), before, 'and the declined row does not move');
503
+ assert.equal(await dbKernel.hasInvitedAccessRequest('dee'), false);
504
+ });
505
+
506
+ test('idempotent: approving an already-invited request returns the row, not a 409 (D4.4)', async () => {
507
+ reset();
508
+ const before = { ...row(3) };
509
+ const r = await call('PATCH', '/api/gds/access-requests/3', { status: 'invited' });
510
+ assert.equal(r.status, 200, 'a double-clicked queue button is safe');
511
+ assert.equal(r.json.unchanged, true);
512
+ assert.equal(r.json.request.status, 'invited');
513
+ assert.deepEqual(row(3), before, 'and it does NOT re-stamp resolved_by/resolved_at — the audit trail records who actually resolved it');
514
+ });
515
+
516
+ test('idempotent: declining an already-dismissed request returns the row (D4.4)', async () => {
517
+ reset();
518
+ const before = { ...row(4) };
519
+ const r = await call('PATCH', '/api/gds/access-requests/4', { status: 'dismissed' });
520
+ assert.equal(r.status, 200);
521
+ assert.equal(r.json.unchanged, true);
522
+ assert.deepEqual(row(4), before);
523
+ });
524
+
525
+ test('a request that does not exist is 404, and nothing in the table moves', async () => {
526
+ reset();
527
+ const snapshot = JSON.stringify(rows);
528
+ const r = await call('PATCH', '/api/gds/access-requests/999', { status: 'invited' });
529
+ assert.equal(r.status, 404);
530
+ assert.equal(r.json.error.code, 'request_not_found');
531
+ assert.equal(JSON.stringify(rows), snapshot);
532
+ });
533
+
534
+ test('only invited|dismissed are accepted, and a rejected status writes nothing', async () => {
535
+ reset();
536
+ const snapshot = JSON.stringify(rows);
537
+ for (const status of ['pending', 'approved', '']) {
538
+ const r = await call('PATCH', '/api/gds/access-requests/1', { status });
539
+ assert.equal(r.status, 400, `status=${JSON.stringify(status)}`);
540
+ }
541
+ assert.equal(JSON.stringify(rows), snapshot);
542
+ });
543
+
544
+ // ===========================================================================
545
+ // Authority — "a builder cannot approve or decline for a project they don't own"
546
+ // ===========================================================================
547
+
548
+ for (const rank of ['xenos', 'thetes', 'metic']) {
549
+ test(`a ${rank} cannot approve — the real access_request.review gate refuses and the row does not move`, async () => {
550
+ reset(rank);
551
+ const before = { ...row(1) };
552
+ const r = await call('PATCH', '/api/gds/access-requests/1', { status: 'invited' });
553
+ assert.equal(r.status, 403);
554
+ assert.equal(r.json.error.code, 'permission_forbidden');
555
+ assert.deepEqual(r.json.error.details.required, ['access_request.review']);
556
+ assert.deepEqual(row(1), before, 'the queue did not move for someone without the owner authority');
557
+ assert.equal(await dbKernel.hasInvitedAccessRequest('ada'), false, 'and nobody was admitted');
558
+ });
559
+
560
+ test(`a ${rank} cannot decline, and cannot even READ the queue`, async () => {
561
+ reset(rank);
562
+ const before = { ...row(1) };
563
+ const declined = await call('PATCH', '/api/gds/access-requests/1', { status: 'dismissed' });
564
+ assert.equal(declined.status, 403);
565
+ assert.deepEqual(row(1), before);
566
+ const listed = await call('GET', '/api/gds/access-requests');
567
+ assert.equal(listed.status, 403, 'the queue exposes would-be builders’ usernames — the read is gated too');
568
+ });
569
+ }
570
+
571
+ test('the gate is pinned to the atom ADR 0201 D3.2 names, at the floor the catalog holds', () => {
572
+ const atom = catalog.byKey('access_request.review');
573
+ assert.ok(atom, 'access_request.review must still exist — this task mints no new permission');
574
+ assert.equal(atom.floor, 'archon');
575
+ assert.ok(catalog.seedForRank('archon').includes('access_request.review'));
576
+ assert.ok(!catalog.seedForRank('metic').includes('access_request.review'),
577
+ 'if this ever becomes metic-held, it is the console wave widening the READ half — revisit these tests deliberately');
578
+ });
579
+
580
+ // ===========================================================================
581
+ // The vouch carries NO authority (ADR 0016) — the whole point of the goal
582
+ // ===========================================================================
583
+
584
+ test('a vouched application is approved by the byte-identical statement as an unvouched one', async () => {
585
+ reset();
586
+ // Everything that is allowed to differ: identity, the vouch fields, and the
587
+ // two clocks. What is left — status and who resolved it — is the whole of what
588
+ // approving does, and it must be the same on both rows.
589
+ const strip = (r) => ({
590
+ ...r, id: undefined, github_login: undefined, display_name: undefined,
591
+ applicant_github_id: undefined, vouched_at: undefined, resolved_at: undefined, created_at: undefined,
592
+ });
593
+
594
+ seed(); statements = [];
595
+ const unvouched = await call('PATCH', '/api/gds/access-requests/1', { status: 'invited' });
596
+ const unvouchedRow = { ...row(1) };
597
+ const unvouchedSql = statements.map((s) => s.sql);
598
+
599
+ seed(); statements = [];
600
+ const vouched = await call('PATCH', '/api/gds/access-requests/2', { status: 'invited' });
601
+ const vouchedRow = { ...row(2) };
602
+ const vouchedSql = statements.map((s) => s.sql);
603
+
604
+ assert.equal(unvouched.status, vouched.status);
605
+ assert.deepEqual(Object.keys(unvouched.json.request).sort(), Object.keys(vouched.json.request).sort());
606
+ assert.deepEqual(strip(unvouchedRow), strip(vouchedRow), 'the resulting rows differ ONLY in identity + the vouch fields the write never touched');
607
+ assert.deepEqual(unvouchedSql, vouchedSql, 'the SAME SQL ran — there is no vouched branch to take');
608
+ assert.equal(vouchedRow.applicant_github_id, '9002', 'and the vouch itself is left exactly as it was found');
609
+ assert.equal(vouchedRow.vouched_at, rows.find((r) => r.id === 2).vouched_at);
610
+ });
611
+
612
+ test('no statement on the approve path or the admission read so much as mentions the vouch columns', async () => {
613
+ seed(); statements = [];
614
+ await call('PATCH', '/api/gds/access-requests/2', { status: 'invited' });
615
+ await dbKernel.hasInvitedAccessRequest('bo');
616
+ assert.ok(statements.length >= 2, 'the approve and the admission read both ran');
617
+ for (const { sql } of statements) {
618
+ assert.ok(!/applicant_github_id|vouched_at/.test(sql),
619
+ `a write or read on the admission path referenced a vouch column: ${sql}`);
620
+ }
621
+ });
622
+
623
+ test('a vouch admits nobody on its own — only status=invited ever does', async () => {
624
+ seed();
625
+ // Row 2 is vouched and PENDING. The strongest form of the claim: the hub has
626
+ // confirmed exactly who this is, and the door is still shut.
627
+ assert.equal(row(2).applicant_github_id, '9002');
628
+ assert.equal(await dbKernel.hasInvitedAccessRequest('bo'), false,
629
+ 'a confirmed identity is not an admission — descriptive analytics never become authority (ADR 0016)');
630
+ // And an UNVOUCHED invited row admits, which is the same statement proving the
631
+ // vouch is not a second, quieter gate hiding beside the first.
632
+ assert.equal(row(3).applicant_github_id, null);
633
+ assert.equal(await dbKernel.hasInvitedAccessRequest('cy'), true);
634
+ });
@@ -99,6 +99,11 @@ const OWN_SCOPED_SURFACES = [
99
99
  why: 'the connection graph (ADR 0202, task 1002295). The ACTING account is always the session; a counterparty is named by public handle or by the connection row id, never by an account identifier — and the degree label is a viewer-relative own-scope read (D4 §4).',
100
100
  crossOwner: [],
101
101
  },
102
+ {
103
+ file: 'modules/platform-identity/routes/my-projects.js',
104
+ why: "the caller's OWN cross-project memberships, invitations, applications and leave (ADR 0141 §3; the application echo, ADR 0201 D1.3, task 1002284) — every route keyed on req.builder.github_id / github_login. The only path param is :clientId, which names a PROJECT and never a builder: a project is the resource being acted on, and the actor is always the session, which is why join can relay an application for nobody but the caller.",
105
+ crossOwner: [],
106
+ },
102
107
  {
103
108
  file: 'modules/memory/routes/memory.js',
104
109
  why: 'per-builder memory (ADR 0024 §4). The self endpoints are own-scoped; the five cross-builder endpoints name their target in the path and are gate-verified below.',