@bongos/core 1.20.27 → 1.20.28

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 (32) hide show
  1. package/.bongos-core.json +59 -34
  2. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -1
  3. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
  4. package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/api/openapi.json +2 -2
  7. package/docs/copy-inventory.md +20 -19
  8. package/docs/copy-registry.json +34 -25
  9. package/docs/module-api-changelog.md +3 -1
  10. package/docs/page-readings.json +9 -9
  11. package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
  12. package/modules/hall-ui/public/watch.js +9 -3
  13. package/modules/onboarding/routes/access-requests.js +43 -20
  14. package/modules/platform-identity/platform-identity.js +12 -8
  15. package/modules/platform-identity/tests/platform-identity.mjs +4 -2
  16. package/package-lock.json +2 -2
  17. package/package.json +1 -1
  18. package/release-notes.json +10 -0
  19. package/scripts/gds/fitness.js +4 -0
  20. package/scripts/gds/login.js +6 -0
  21. package/scripts/gds/run-unit-tests.js +4 -0
  22. package/src/bongos/db-kernel.js +6 -3
  23. package/src/bongos/invite-expiry.js +47 -0
  24. package/src/module-api.js +10 -1
  25. package/tests/access_requests_invite.mjs +2 -1
  26. package/tests/application_lifecycle.mjs +164 -5
  27. package/tests/bongos_login.mjs +2 -0
  28. package/tests/hub_invite_notices.mjs +5 -2
  29. package/tests/invite_expiry.mjs +37 -0
  30. package/tests/invite_expiry_db.mjs +196 -0
  31. package/tests/module_api.mjs +1 -0
  32. package/tests/project_admin_console_model.mjs +12 -5
@@ -49,6 +49,8 @@ function pollDecision(body) {
49
49
  if (body.admitted === true) return 'admitted';
50
50
  if (body.status === 'member' || body.status === 'invited') return 'admitted';
51
51
  if (body.status === 'dismissed') return 'dismissed';
52
+ // An invite past its 30 days (task 1002969): the door is shut until they invite again.
53
+ if (body.status === 'expired') return 'expired';
52
54
  return 'wait';
53
55
  }
54
56
 
@@ -302,6 +304,10 @@ async function main() {
302
304
  console.error('\n✖ your access request was declined.');
303
305
  process.exit(1);
304
306
  }
307
+ if (decision === 'expired') {
308
+ console.error('\n✖ your invite has expired — ask the project to invite you again.');
309
+ process.exit(1);
310
+ }
305
311
  if (Date.now() > admitDeadline) {
306
312
  console.error('\n✖ timed out waiting for approval — re-run `bongos login` once an Archon has approved you.');
307
313
  process.exit(1);
@@ -208,6 +208,10 @@ const INTEGRATION = new Set([
208
208
  // prove the CHECK actually refuses a stored fourth state, that the backfill lands on a
209
209
  // pre-migration row, and that publishableSql and projectPublishability agree.
210
210
  'project_visibility_db',
211
+ // task 1002969 (ADR 0356): the invite expiry against real SQL — the router's and the
212
+ // admission reads' statements run on Postgres, and the hub's notice restamp and
213
+ // listing. The DB-free sibling (application_lifecycle) interprets the same WHEREs.
214
+ 'invite_expiry_db',
211
215
  // task 1004408: migration platform_identity_028 deletes the old hub invite's
212
216
  // pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
213
217
  // engine runs the DO block, the numeric version compare and the one-shot guard.
@@ -25,6 +25,7 @@
25
25
 
26
26
  const crypto = require('node:crypto');
27
27
  const { pool } = require('./pool');
28
+ const { inviteLiveSql } = require('./invite-expiry');
28
29
  const seams = require('../module-seams');
29
30
 
30
31
  // withTx(fn, {pool}) — run fn(client) inside BEGIN/COMMIT, ROLLBACK+rethrow on
@@ -232,11 +233,12 @@ async function getBuilderByGithubId(githubId) {
232
233
 
233
234
  // hasInvitedAccessRequest — has an Archon approved this login (an access_requests
234
235
  // row at status='invited', case-insensitive)? The approval signal that lets a
235
- // first-time web sign-in through.
236
+ // first-time web sign-in through. An invite older than 30 days no longer counts
237
+ // (inviteLiveSql, task 1002969 / ADR 0356); an approved application never expires.
236
238
  async function hasInvitedAccessRequest(login) {
237
239
  const { rows } = await pool.query(
238
240
  `SELECT 1 FROM access_requests
239
- WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`,
241
+ WHERE lower(github_login) = lower($1) AND status = 'invited' AND ${inviteLiveSql()} LIMIT 1`,
240
242
  [login]
241
243
  );
242
244
  return rows.length > 0;
@@ -259,7 +261,8 @@ async function accessStateByLogin(login) {
259
261
  EXISTS (SELECT 1 FROM builders
260
262
  WHERE lower(github_login) = lower($1)) AS member,
261
263
  EXISTS (SELECT 1 FROM access_requests
262
- WHERE lower(github_login) = lower($1) AND status = 'invited') AS invited,
264
+ WHERE lower(github_login) = lower($1) AND status = 'invited'
265
+ AND ${inviteLiveSql()}) AS invited,
263
266
  EXISTS (SELECT 1 FROM access_requests
264
267
  WHERE lower(github_login) = lower($1) AND status = 'pending') AS pending`,
265
268
  [login]
@@ -0,0 +1,47 @@
1
+ // src/bongos/invite-expiry.js — a recruit invite's door closes 30 days after it is
2
+ // sent, decided when it is READ (task 1002969; privacy spec D4, ADR 0356).
3
+ //
4
+ // There is no sweeper and no stored "expired" state. Every read that asks "does this
5
+ // invite still open the door" computes the answer from created_at, so the sign-in
6
+ // gate, the queues and the hub notice cannot disagree about the day it closed, and a
7
+ // re-invite (a fresh row) opens a fresh door.
8
+ //
9
+ // ONLY `kind = 'invite'` ROWS EXPIRE. An approved application stays open: its
10
+ // applicant asked, and spec D4 is about invites. Reading `kind` here makes it part
11
+ // of admission for the first time (core_255 called it descriptive only), which is
12
+ // safe for one reason: the predicate can only CLOSE a door. A row that is anything
13
+ // but an invite behaves exactly as it did before this rule, so a mis-kinded row fails
14
+ // toward the old gate, never past it (ADR 0356).
15
+ 'use strict';
16
+
17
+ // The owner-signed window (spec D4). One constant, not per-instance config.
18
+ const INVITE_EXPIRY_DAYS = 30;
19
+
20
+ const ALIAS_RE = /^[a-z_][a-z0-9_]*$/;
21
+ function columnPrefix(alias) {
22
+ if (alias == null || alias === '') return '';
23
+ if (!ALIAS_RE.test(alias)) throw new Error(`invite-expiry: bad table alias ${JSON.stringify(alias)}`);
24
+ return `${alias}.`;
25
+ }
26
+
27
+ // SQL: this access_requests row is an invite whose door has closed. `alias` names the
28
+ // table in the caller's statement ('ar' → ar.kind); omit it for an unaliased table.
29
+ function inviteExpiredSql(alias) {
30
+ const a = columnPrefix(alias);
31
+ return `(${a}kind = 'invite' AND ${a}created_at <= now() - interval '${INVITE_EXPIRY_DAYS} days')`;
32
+ }
33
+
34
+ // SQL: this row has not expired. ANDed beside `status = 'invited'` wherever the
35
+ // question is "does it still admit".
36
+ function inviteLiveSql(alias) {
37
+ return `NOT ${inviteExpiredSql(alias)}`;
38
+ }
39
+
40
+ // SQL: when this row's door closes if it is an invite, else NULL (an application
41
+ // never expires). What a queue shows beside `expired`.
42
+ function inviteExpiresAtSql(alias) {
43
+ const a = columnPrefix(alias);
44
+ return `(CASE WHEN ${a}kind = 'invite' THEN ${a}created_at + interval '${INVITE_EXPIRY_DAYS} days' END)`;
45
+ }
46
+
47
+ module.exports = { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql, inviteExpiresAtSql };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.27'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.28'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -375,6 +375,15 @@ module.exports = {
375
375
  // instance's hub key and client_id itself, so a module can neither
376
376
  // substitute a key nor re-address the check. Null is never a refusal.
377
377
  get readApplicationVouch() { return require('./bongos/auth').readApplicationVouch; },
378
+ // inviteExpiry — { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql }: a recruit
379
+ // invite's door closes 30 days after it is sent, computed at read time (task
380
+ // 1002969, ADR 0356). The sign-in gate, the onboarding queues and the hub's
381
+ // notice list all read this one definition, so no two can close on different days.
382
+ // Destructured rather than returned whole so knip can see which exports are used.
383
+ get inviteExpiry() {
384
+ const { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql, inviteExpiresAtSql } = require('./bongos/invite-expiry');
385
+ return { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql, inviteExpiresAtSql };
386
+ },
378
387
  // userAgent — the instance's HTTP User-Agent string (branding-derived). The
379
388
  // carved lifecycle module's github-push.js stamps every GitHub API request with
380
389
  // it instead of reaching into src/branding.js directly (BV1.R86).
@@ -225,7 +225,8 @@ test('invites list ?mine=1: only the caller’s own invites', async () => {
225
225
  const r = await get('/api/gds/access-requests/invites?mine=1');
226
226
  assert.equal(r.status, 200);
227
227
  assert.deepEqual(r.json.invites, []);
228
- assert.match(calls[0].sql, /AND ar\.created_by = \$1/);
228
+ // Outside the expiry window (task 1002969), so another sender's live re-invite still counts.
229
+ assert.match(calls[0].sql, /\) inv\s+WHERE NOT \(inv\.expired AND inv\.login_has_live\)\s+AND inv\.created_by = \$1/);
229
230
  assert.deepEqual(calls[0].params, [42], 'narrowed to the acting builder, never to a caller-supplied id');
230
231
  });
231
232
 
@@ -54,6 +54,12 @@ const express = require('express');
54
54
  // ===========================================================================
55
55
 
56
56
  const HOUR = 60 * 60 * 1000;
57
+ const DAY = 24 * HOUR;
58
+
59
+ // An invite's door closes `days` after it was sent (task 1002969, ADR 0356). The
60
+ // interpreter reads the window out of the statement itself, so the constant lives in
61
+ // one place: src/bongos/invite-expiry.js.
62
+ const inviteExpired = (r, days) => r.kind === 'invite' && r.created_at <= Date.now() - days * DAY;
57
63
 
58
64
  let rows = [];
59
65
  let builders = [];
@@ -114,6 +120,16 @@ function predicateFor(rawWhere, params) {
114
120
  where = where.replace(notMember[0], ' TRUE ');
115
121
  }
116
122
 
123
+ // The expiry itself (inviteLiveSql): the row is not an invite past its window.
124
+ // One unit, like the transition guard, so its inner `kind = 'invite'` is not
125
+ // re-read below as a filter of its own.
126
+ let live;
127
+ while ((live = /NOT \((?:(\w+)\.)?kind = 'invite' AND (?:\1\.)?created_at <= now\(\) - interval '(\d+) days'\)/.exec(where))) {
128
+ const days = Number(live[2]);
129
+ tests.push((r) => !inviteExpired(r, days));
130
+ where = where.replace(live[0], ' TRUE ');
131
+ }
132
+
117
133
  let m;
118
134
  const eat = () => { where = where.replace(m[0], ' TRUE '); };
119
135
  if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
@@ -225,12 +241,24 @@ const pool = {
225
241
  return { rows: hit.map((r) => ({ id: r.id, github_login: r.github_login, status: r.status, kind: r.kind, resolved_at: r.resolved_at })) };
226
242
  }
227
243
 
228
- // The outstanding-invite list. Its sender is joined through created_by.
229
- if ((m = /^SELECT ar\.id, ar\.github_login, ar\.note, ar\.created_at, ib\.github_login AS invited_by_login FROM access_requests ar LEFT JOIN builders ib ON ib\.id = ar\.created_by WHERE (.+?) ORDER BY ar\.created_at DESC LIMIT 200$/.exec(sql))) {
230
- const hit = rows.filter(predicateFor(m[1], params)).sort((a, b) => b.created_at - a.created_at);
244
+ // The outstanding-invite list. Its sender is joined through created_by. Since task
245
+ // 1002969 each row says when its door closes and whether it has, and an expired
246
+ // row is dropped when its login has a live invite: a window over the inner scan
247
+ // (`login_has_live`), with the sender filter outside it. Modelled as exactly that.
248
+ if ((m = /^SELECT inv\.id, inv\.github_login, inv\.note, inv\.created_at, inv\.expires_at, inv\.expired, inv\.invited_by_login FROM \(SELECT ar\.id, ar\.github_login, ar\.note, ar\.created_at, ar\.created_by, \(CASE WHEN ar\.kind = 'invite' THEN ar\.created_at \+ interval '(\d+) days' END\) AS expires_at, \(ar\.kind = 'invite' AND ar\.created_at <= now\(\) - interval '\1 days'\) AS expired, bool_or\(NOT \(ar\.kind = 'invite' AND ar\.created_at <= now\(\) - interval '\1 days'\)\) OVER \(PARTITION BY lower\(ar\.github_login\)\) AS login_has_live, ib\.github_login AS invited_by_login FROM access_requests ar LEFT JOIN builders ib ON ib\.id = ar\.created_by WHERE (.+?)\) inv WHERE NOT \(inv\.expired AND inv\.login_has_live\)(?: AND inv\.created_by = \$(\d+))? ORDER BY inv\.created_at DESC LIMIT 200$/.exec(sql))) {
249
+ const days = Number(m[1]);
250
+ const inner = rows.filter(predicateFor(m[2], params));
251
+ const key = (r) => String(r.github_login).toLowerCase();
252
+ const liveLogins = new Set(inner.filter((r) => !inviteExpired(r, days)).map(key));
253
+ const sender = m[3] ? params[Number(m[3]) - 1] : undefined;
254
+ const hit = inner
255
+ .filter((r) => !(inviteExpired(r, days) && liveLogins.has(key(r))))
256
+ .filter((r) => sender === undefined || String(r.created_by) === String(sender))
257
+ .sort((a, b) => b.created_at - a.created_at);
231
258
  return {
232
259
  rows: hit.map((r) => ({
233
260
  id: r.id, github_login: r.github_login, note: r.note, created_at: r.created_at,
261
+ expires_at: r.created_at + days * DAY, expired: inviteExpired(r, days),
234
262
  invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null,
235
263
  })),
236
264
  };
@@ -248,6 +276,9 @@ const pool = {
248
276
  // proves the route reads the column rather than that this fake invents it.
249
277
  ...(/\bar\.applicant_handle\b/.test(sql) ? { applicant_handle: r.applicant_handle ?? null } : {}),
250
278
  ...(/\bar\.kind\b/.test(sql) ? { kind: r.kind } : {}),
279
+ ...((m = /\(ar\.status = 'invited' AND \(ar\.kind = 'invite' AND ar\.created_at <= now\(\) - interval '(\d+) days'\)\) AS expired, \(CASE WHEN ar\.kind = 'invite' THEN ar\.created_at \+ interval '\1 days' END\) AS expires_at/.exec(sql))
280
+ ? { expired: r.status === 'invited' && inviteExpired(r, Number(m[1])), expires_at: r.kind === 'invite' ? r.created_at + Number(m[1]) * DAY : null }
281
+ : {}),
251
282
  ...(/\bib\.github_login AS invited_by_login\b/.test(sql) && /\bON ib\.id = ar\.created_by\b/.test(sql)
252
283
  ? { invited_by_login: (builders.find((b) => b.id === r.created_by) || {}).github_login || null } : {}),
253
284
  vouched: r.applicant_github_id !== null,
@@ -256,6 +287,27 @@ const pool = {
256
287
  };
257
288
  }
258
289
 
290
+ // db-kernel's accessStateByLogin — the waiting page's three facts in one read.
291
+ // Each EXISTS is filtered by its own WHERE, so the expiry unit in `invited` is
292
+ // read by the interpreter like every other predicate.
293
+ if ((m = /^SELECT EXISTS \(SELECT 1 FROM builders WHERE (.+?)\) AS member, EXISTS \(SELECT 1 FROM access_requests WHERE (.+?)\) AS invited, EXISTS \(SELECT 1 FROM access_requests WHERE (.+?)\) AS pending$/.exec(sql))) {
294
+ return { rows: [{
295
+ member: builders.some(predicateFor(m[1], params)),
296
+ invited: rows.some(predicateFor(m[2], params)),
297
+ pending: rows.some(predicateFor(m[3], params)),
298
+ }] };
299
+ }
300
+
301
+ // The public status probe (task 1002969): an invite that still admits sorts first,
302
+ // then the newest request; an invite past its window reports itself expired.
303
+ if ((m = /^SELECT status, \(status = 'invited' AND \(kind = 'invite' AND created_at <= now\(\) - interval '(\d+) days'\)\) AS expired FROM access_requests WHERE (.+?) ORDER BY \(status = 'invited' AND NOT \(kind = 'invite' AND created_at <= now\(\) - interval '\1 days'\)\) DESC, created_at DESC LIMIT 1$/.exec(sql))) {
304
+ const days = Number(m[1]);
305
+ const admits = (r) => r.status === 'invited' && !inviteExpired(r, days);
306
+ const hit = rows.filter(predicateFor(m[2], params))
307
+ .sort((a, b) => (admits(b) - admits(a)) || (b.created_at - a.created_at));
308
+ return { rows: hit.slice(0, 1).map((r) => ({ status: r.status, expired: r.status === 'invited' && inviteExpired(r, days) })) };
309
+ }
310
+
259
311
  // A vouched re-application taking over an unvouched pending row (ADR 0333 D4).
260
312
  // The SET list is matched exactly, like the resolve's below.
261
313
  if ((m = /^UPDATE access_requests SET display_name = \$2, note = \$3, applicant_github_id = \$4, applicant_handle = \$5, vouched_at = \$6 WHERE (.+?)$/.exec(sql))) {
@@ -809,8 +861,12 @@ test('invite: a login that never applied signs straight in, and the row says it
809
861
  assert.equal(eve.note, 'Come build the parser', 'the inviter’s words, and kind says whose');
810
862
  statements = [];
811
863
  assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), true, 'the REAL admission read admits her — signing in is the acceptance act');
812
- assert.ok(statements.length > 0 && statements.every(({ sql }) => !/\bkind\b|\bcreated_by\b/.test(sql)),
813
- 'and it admitted her without reading either new column (D2.5)');
864
+ // D2.5 as amended by ADR 0356 (task 1002969): admission never reads created_by, and
865
+ // reads `kind` ONLY inside the expiry unit, which can close a door but never open one.
866
+ const EXPIRY_UNIT = /NOT \(kind = 'invite' AND created_at <= now\(\) - interval '\d+ days'\)/g;
867
+ assert.ok(statements.length > 0 && statements.every(({ sql }) => !/\bcreated_by\b/.test(sql)
868
+ && !/\bkind\b/.test(sql.replace(EXPIRY_UNIT, ''))),
869
+ 'and it admitted her reading kind only to ask whether her invite has expired (D2.5, ADR 0356)');
814
870
  });
815
871
 
816
872
  test('rescind: an invite keeps its sender through a rescind by a different archon, and the door re-locks', async () => {
@@ -873,6 +929,109 @@ test('the queue history tells an invite from an approved application, and names
873
929
  assert.equal(byLogin.ada.invite_note, null);
874
930
  });
875
931
 
932
+ // ===========================================================================
933
+ // Expiry — an invite's door closes 30 days after it is sent, read-time (task
934
+ // 1002969, privacy spec D4, ADR 0356)
935
+ // ===========================================================================
936
+
937
+ // Backdate a row as if it had been written `days` ago (an invite's resolved_at is
938
+ // its created_at: the route writes both in one statement).
939
+ const age = (row, days) => {
940
+ row.created_at = Date.now() - days * DAY;
941
+ if (row.kind === 'invite') row.resolved_at = row.created_at;
942
+ };
943
+
944
+ test('expiry: an invite admits for 30 days and not after; an approved application never expires', async () => {
945
+ reset();
946
+ builders.push(...ARCHONS);
947
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
948
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
949
+ age(rows.find((x) => x.github_login === 'eve'), 29);
950
+ age(rows.find((x) => x.github_login === 'fay'), 31);
951
+ age(row(3), 60); // cy: an application approved long ago
952
+ assert.equal(await dbKernel.hasInvitedAccessRequest('eve'), true, 'day 29: the door still opens');
953
+ assert.equal(await dbKernel.hasInvitedAccessRequest('fay'), false, 'day 31: the REAL admission read refuses her');
954
+ assert.equal(await dbKernel.hasInvitedAccessRequest('cy'), true, 'an approved application is not an invite and does not expire');
955
+ const state = await dbKernel.accessStateByLogin('fay');
956
+ assert.equal(state.invited, false, 'the waiting page does not call an expired invite imminent admission');
957
+ });
958
+
959
+ test('expiry: the queues show an expired invite as expired, and it offers the recruiter a re-invite, not a rescind', async () => {
960
+ reset();
961
+ builders.push(...ARCHONS);
962
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
963
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'eve' });
964
+ age(rows.find((x) => x.github_login === 'fay'), 31);
965
+ const hist = await call('GET', '/api/gds/access-requests?status=invited');
966
+ const h = Object.fromEntries(hist.json.requests.map((x) => [x.github_login, x]));
967
+ assert.equal(h.fay.expired, true);
968
+ assert.equal(h.eve.expired, false);
969
+ assert.equal(h.cy.expired, false, 'an approved application is never marked expired');
970
+ assert.equal(h.cy.expires_at, null, 'and has no day it closes');
971
+ assert.ok(h.fay.expires_at <= Date.now() && h.eve.expires_at > Date.now(), 'each invite says when its door closes');
972
+ const out = await call('GET', '/api/gds/access-requests/invites');
973
+ const o = Object.fromEntries(out.json.invites.map((x) => [x.github_login, x]));
974
+ assert.deepEqual(Object.keys(o).sort(), ['eve', 'fay'], 'an expired invite stays listed, so its sender knows to invite again');
975
+ assert.equal(o.fay.expired, true);
976
+ assert.equal(o.eve.expired, false);
977
+ });
978
+
979
+ test('expiry: re-inviting an expired login opens a fresh door, notices the hub, and retires the expired line', async () => {
980
+ reset();
981
+ builders.push(...ARCHONS);
982
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
983
+ age(rows.find((x) => x.github_login === 'fay'), 31);
984
+ notices = [];
985
+ const again = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
986
+ assert.equal(again.status, 201, 'a fresh row, not already_invited: the expired invite does not stand');
987
+ assert.deepEqual(notices, [{ login: 'fay', invited: true }], 'the hub is told, so its notice shows again');
988
+ assert.equal(await dbKernel.hasInvitedAccessRequest('fay'), true, 'the new door opens');
989
+ const out = await call('GET', '/api/gds/access-requests/invites');
990
+ const fays = out.json.invites.filter((x) => x.github_login === 'fay');
991
+ assert.equal(fays.length, 1, 'the expired line is replaced by the live one, not listed beside it');
992
+ assert.equal(fays[0].expired, false);
993
+ });
994
+
995
+ test('expiry: another sender re-inviting the login retires your expired line from your own list too', async () => {
996
+ reset();
997
+ builders.push(...ARCHONS);
998
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' }); // sent by 42
999
+ age(rows.find((x) => x.github_login === 'fay'), 31);
1000
+ actingId = 43;
1001
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' }); // 43 re-invites
1002
+ actingId = 42;
1003
+ const mine = await call('GET', '/api/gds/access-requests/invites?mine=1');
1004
+ assert.deepEqual(mine.json.invites.map((x) => x.github_login), [],
1005
+ 'the sender filter sits outside the "has a live invite" window, so 42 is not told to re-invite fay');
1006
+ actingId = 43;
1007
+ const theirs = await call('GET', '/api/gds/access-requests/invites?mine=1');
1008
+ assert.deepEqual(theirs.json.invites.map((x) => [x.github_login, x.expired]), [['fay', false]]);
1009
+ });
1010
+
1011
+ test('expiry: the public status probe says expired, and an expired invitee may apply', async () => {
1012
+ reset();
1013
+ builders.push(...ARCHONS);
1014
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
1015
+ age(rows.find((x) => x.github_login === 'fay'), 31);
1016
+ const st = await call('GET', '/api/gds/access-requests/status?github_login=fay');
1017
+ assert.deepEqual(st.json, { github_login: 'fay', status: 'expired', admitted: false });
1018
+ const asked = await call('POST', '/api/gds/access-requests', { github_login: 'fay' });
1019
+ assert.equal(asked.status, 201, 'not already_approved: an expired invite is not an approval');
1020
+ const st2 = await call('GET', '/api/gds/access-requests/status?github_login=fay');
1021
+ assert.equal(st2.json.status, 'pending', 'the newer request is what the probe reports');
1022
+ });
1023
+
1024
+ test('expiry: rescinding the last live invite clears the hub notice even though an expired one remains', async () => {
1025
+ reset();
1026
+ builders.push(...ARCHONS);
1027
+ await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
1028
+ age(rows.find((x) => x.github_login === 'fay'), 31);
1029
+ const live = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
1030
+ notices = [];
1031
+ await call('PATCH', `/api/gds/access-requests/${live.json.request.id}`, { status: 'dismissed' });
1032
+ assert.deepEqual(notices, [{ login: 'fay', invited: false }], 'an expired row is not a standing invite');
1033
+ });
1034
+
876
1035
  test('invite: resolving a pending application in place keeps it an application, with the applicant’s own note', async () => {
877
1036
  reset();
878
1037
  builders.push(...ARCHONS);
@@ -39,6 +39,8 @@ test('pollDecision maps the /access-requests/status body to a loop action', () =
39
39
  assert.equal(pollDecision({ status: 'invited' }), 'admitted');
40
40
  assert.equal(pollDecision({ status: 'member', admitted: true }), 'admitted');
41
41
  assert.equal(pollDecision({ status: 'dismissed' }), 'dismissed');
42
+ // task 1002969: an invite past its 30 days stops the loop rather than waiting on a shut door
43
+ assert.equal(pollDecision({ status: 'expired', admitted: false }), 'expired');
42
44
  assert.equal(pollDecision({ status: 'pending', admitted: false }), 'wait');
43
45
  assert.equal(pollDecision({ status: 'none' }), 'wait');
44
46
  assert.equal(pollDecision(null), 'wait');
@@ -107,8 +107,11 @@ test('notify seeds a PENDING row on the calling project, and cannot touch a real
107
107
  const w = writes();
108
108
  assert.equal(w.length, 1);
109
109
  assert.match(w[0].sql, /VALUES \(\$1, \$2, 'pending'\)/, 'a notice is a pending row, never a membership');
110
- assert.match(w[0].sql, /ON CONFLICT \(client_id, github_id\) DO NOTHING/, 'a current member is left exactly as they are');
111
- assert.deepEqual(w[0].params, ['mercury', '4242'], 'keyed on the authenticated client and the account the LOGIN resolves to');
110
+ // The conflict branch restamps ONLY joined_at, and only on a pending row past its 30
111
+ // days (a re-invite, task 1002969): a current member is left exactly as they are.
112
+ assert.match(w[0].sql, /ON CONFLICT \(client_id, github_id\) DO UPDATE SET joined_at = now\(\)\s+WHERE m\.membership_kind = 'pending' AND m\.joined_at <= now\(\) - make_interval\(days => \$3\)/,
113
+ 'a current member is left exactly as they are');
114
+ assert.deepEqual(w[0].params, ['mercury', '4242', 30], 'keyed on the authenticated client and the account the LOGIN resolves to');
112
115
  });
113
116
 
114
117
  test('clear drops ONLY a pending row on the calling project', async () => {
@@ -0,0 +1,37 @@
1
+ // tests/invite_expiry.mjs — task 1002969 (privacy spec D4, ADR 0356). DB-free.
2
+ //
3
+ // The one definition of when a recruit invite's door closes. The behaviour is proved
4
+ // in tests/application_lifecycle.mjs (interpreted) and tests/invite_expiry_db.mjs
5
+ // (real Postgres); this pins the definition itself: the owner-signed window, that
6
+ // only an invite can expire, that "live" is exactly "not expired", and that a table
7
+ // alias can never carry SQL into the statements that interpolate it.
8
+ //
9
+ // Run: node --test tests/invite_expiry.mjs
10
+
11
+ import assert from 'node:assert/strict';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ const require = createRequire(import.meta.url);
15
+ const expiry = require('../src/bongos/invite-expiry.js');
16
+ const api = require('../src/module-api.js');
17
+
18
+ test('the window is the owner-signed 30 days, and the doorway serves this same module', () => {
19
+ assert.equal(expiry.INVITE_EXPIRY_DAYS, 30);
20
+ for (const k of ['INVITE_EXPIRY_DAYS', 'inviteExpiredSql', 'inviteLiveSql', 'inviteExpiresAtSql']) {
21
+ assert.equal(api.inviteExpiry[k], expiry[k], `${k}: modules read the definition core reads, not a copy`);
22
+ }
23
+ });
24
+
25
+ test('only an invite expires, and live is exactly not-expired', () => {
26
+ assert.equal(expiry.inviteExpiredSql(), "(kind = 'invite' AND created_at <= now() - interval '30 days')");
27
+ assert.equal(expiry.inviteLiveSql('ar'), `NOT ${expiry.inviteExpiredSql('ar')}`);
28
+ assert.match(expiry.inviteExpiresAtSql('ar'), /^\(CASE WHEN ar\.kind = 'invite' THEN ar\.created_at \+ interval '30 days' END\)$/,
29
+ 'an application has no closing day');
30
+ });
31
+
32
+ test('an alias is an identifier or nothing', () => {
33
+ assert.match(expiry.inviteLiveSql('x'), /x\.kind = 'invite' AND x\.created_at/);
34
+ for (const bad of ['ar;', "a'", 'a b', 'A', '1a', 'a.b']) {
35
+ assert.throws(() => expiry.inviteExpiredSql(bad), /bad table alias/, JSON.stringify(bad));
36
+ }
37
+ });
@@ -0,0 +1,196 @@
1
+ // tests/invite_expiry_db.mjs — task 1002969 (privacy spec D4, ADR 0356). Real DB.
2
+ //
3
+ // A recruit invite's door closes 30 days after it is sent, computed at read time.
4
+ // tests/application_lifecycle.mjs pins the behaviour over an interpreter that reads
5
+ // each statement's WHERE; this file runs the SAME statements on Postgres, because an
6
+ // interpreter cannot tell you that Postgres would reject a statement, or what
7
+ // `now() - interval '30 days'` does at the boundary. Two halves:
8
+ //
9
+ // the instance — the real onboarding router and the real db-kernel admission reads,
10
+ // over access_requests built from its real migrations;
11
+ // the hub — inviteToProject and listPendingInvitesForAccount over the real
12
+ // memberships table: an expired notice is hidden, a re-invite
13
+ // restamps it, and a member's row is never touched.
14
+ //
15
+ // Each half builds its tables in a throwaway schema, so a shared database's rows are
16
+ // never seen or changed.
17
+ //
18
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
19
+ // INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
20
+ // never runs it.
21
+ //
22
+ // Run: DATABASE_URL=... node tests/invite_expiry_db.mjs
23
+
24
+ import { createRequire } from 'node:module';
25
+ import { readFileSync } from 'node:fs';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { dirname, join } from 'node:path';
28
+ import { strict as assert } from 'node:assert';
29
+ import http from 'node:http';
30
+ const require = createRequire(import.meta.url);
31
+ const { Pool } = require('pg');
32
+ const express = require('express');
33
+
34
+ const here = dirname(fileURLToPath(import.meta.url));
35
+ const coreSql = (f) => readFileSync(join(here, '..', 'migrations', `${f}.sql`), 'utf8');
36
+ const piSql = (f) => readFileSync(join(here, '..', 'modules', 'platform-identity', 'migrations', `platform_identity_${f}.sql`), 'utf8');
37
+
38
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
39
+ let client;
40
+ try {
41
+ client = await pgPool.connect();
42
+ } catch (e) {
43
+ console.log(`invite_expiry_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
44
+ await pgPool.end().catch(() => {});
45
+ process.exit(0);
46
+ }
47
+
48
+ const q = (sql, params) => client.query(sql, params);
49
+ const SCHEMA = `t1002969_${process.pid}`;
50
+ let pass = 0;
51
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
52
+
53
+ // Core's admission reads go through ./pool, and the router through the doorway's
54
+ // pool: both are pointed at this one connection, whose search_path is the schema.
55
+ const poolPath = require.resolve('../src/bongos/pool.js');
56
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
57
+ const apiPath = require.resolve('../src/module-api.js');
58
+ const realApi = require(apiPath);
59
+ let notices = [];
60
+ require.cache[apiPath].exports = {
61
+ ...realApi,
62
+ pool: client,
63
+ projectJoinDoor: () => 'apply',
64
+ logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
65
+ requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
66
+ requirePermission: () => (_req, _res, next) => next(),
67
+ readApplicationVouch: () => null,
68
+ notifyHubOfInvite: (login, opts) => { notices.push({ login, invited: opts && opts.invited }); },
69
+ };
70
+ const broadcastPath = require.resolve('../modules/onboarding/approval-broadcast.js');
71
+ require.cache[broadcastPath] = { id: broadcastPath, filename: broadcastPath, loaded: true, exports: { postMemberApprovalRequest: async () => ({}) } };
72
+
73
+ const dbKernel = require('../src/bongos/db-kernel.js');
74
+ const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
75
+ const pi = require('../modules/platform-identity/platform-identity.js');
76
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
77
+
78
+ const app = express();
79
+ app.use(express.json());
80
+ app.use(attachFail);
81
+ app.use('/api/gds', buildAccessRequestsRouter());
82
+ const server = http.createServer(app);
83
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
84
+ const port = server.address().port;
85
+ function call(method, path, body) {
86
+ return new Promise((resolve, reject) => {
87
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
88
+ const req = http.request({ host: '127.0.0.1', port, method, path,
89
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {} }, (res) => {
90
+ const chunks = [];
91
+ res.on('data', (c) => chunks.push(c));
92
+ res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
93
+ });
94
+ req.on('error', reject);
95
+ req.end(payload);
96
+ });
97
+ }
98
+
99
+ // Backdate one login's rows by `days` (an invite's resolved_at is its created_at).
100
+ const age = (login, days) => q(
101
+ `UPDATE access_requests SET created_at = now() - make_interval(days => $2),
102
+ resolved_at = CASE WHEN kind = 'invite' THEN now() - make_interval(days => $2) ELSE resolved_at END
103
+ WHERE github_login = $1`, [login, days]);
104
+
105
+ try {
106
+ await q(`CREATE SCHEMA ${SCHEMA}`);
107
+ await q(`SET search_path TO ${SCHEMA}`);
108
+ await q('CREATE TABLE schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())');
109
+ await q('CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text NOT NULL)');
110
+ await q(`INSERT INTO builders (id, github_login) VALUES (42, 'owner')`);
111
+ for (const f of ['060_access_requests', 'core_230_access_requests_applicant_vouch',
112
+ 'core_254_access_requests_applicant_handle', 'core_255_access_requests_invite_kind',
113
+ 'core_266_access_requests_invited_login_idx']) await q(coreSql(f));
114
+
115
+ // ── The instance: the door, the queues, the probe ────────────────────────
116
+ console.log('the instance:');
117
+ for (const login of ['eve', 'fay']) assert.equal((await call('POST', '/api/gds/access-requests/invite', { github_login: login })).status, 201);
118
+ await q(`INSERT INTO access_requests (github_login, status, resolved_at, created_at) VALUES ('cy', 'invited', now(), now() - interval '60 days')`);
119
+ await age('eve', 29);
120
+ await age('fay', 31);
121
+ ok(await dbKernel.hasInvitedAccessRequest('eve') === true, 'day 29: the invite still admits');
122
+ ok(await dbKernel.hasInvitedAccessRequest('fay') === false, 'day 31: the sign-in gate refuses it');
123
+ ok(await dbKernel.hasInvitedAccessRequest('cy') === true, 'an application approved 60 days ago is not an invite and still admits');
124
+ ok((await dbKernel.accessStateByLogin('fay')).invited === false, 'the waiting page agrees with the gate');
125
+
126
+ // core_266: the gate's own statement, captured as it runs, can use the invited-login
127
+ // index. Seq scans are switched off only so a tiny table still shows the plan.
128
+ const seen = [];
129
+ const realQuery = client.query.bind(client);
130
+ client.query = (text, params) => { seen.push({ text, params }); return realQuery(text, params); };
131
+ await dbKernel.hasInvitedAccessRequest('eve');
132
+ client.query = realQuery;
133
+ await q('SET enable_seqscan = off');
134
+ const plan = (await q(`EXPLAIN ${seen[0].text}`, seen[0].params)).rows.map((r) => r['QUERY PLAN']).join('\n');
135
+ await q('RESET enable_seqscan');
136
+ ok(/idx_access_requests_invited_login/.test(plan), 'the sign-in gate can read invited rows through core_266\'s index');
137
+
138
+ const hist = Object.fromEntries((await call('GET', '/api/gds/access-requests?status=invited')).json.requests.map((r) => [r.github_login, r]));
139
+ ok(hist.fay.expired === true && hist.eve.expired === false && hist.cy.expired === false, 'the queue marks only the expired invite');
140
+ ok(hist.cy.expires_at === null && new Date(hist.fay.expires_at) <= new Date() && new Date(hist.eve.expires_at) > new Date(),
141
+ 'and says when each invite closes; an application has no such day');
142
+
143
+ const probe = (await call('GET', '/api/gds/access-requests/status?github_login=fay')).json;
144
+ ok(probe.status === 'expired' && probe.admitted === false, 'the status probe reports expired, not invited');
145
+
146
+ notices = [];
147
+ const again = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
148
+ ok(again.status === 201 && await dbKernel.hasInvitedAccessRequest('fay') === true, 're-inviting an expired login opens a fresh door');
149
+ ok(notices.length === 1 && notices[0].invited === true, 'and tells the hub');
150
+ const out = (await call('GET', '/api/gds/access-requests/invites')).json.invites;
151
+ ok(out.filter((r) => r.github_login === 'fay').length === 1 && out.find((r) => r.github_login === 'fay').expired === false,
152
+ 'the outstanding list shows the live invite in place of the expired one');
153
+ const mine = (await call('GET', '/api/gds/access-requests/invites?mine=1')).json.invites;
154
+ ok(mine.filter((r) => r.github_login === 'fay').length === 1, 'and so does the sender\'s own list (?mine=1)');
155
+
156
+ notices = [];
157
+ await call('PATCH', `/api/gds/access-requests/${again.json.request.id}`, { status: 'dismissed' });
158
+ ok(notices.length === 1 && notices[0].invited === false, 'rescinding the live invite clears the hub notice; the expired row does not stand');
159
+
160
+ // ── The hub: the notice follows the door ──────────────────────────────────
161
+ console.log('the hub:');
162
+ for (const f of ['001_tables', '003_project_memberships', '010_membership_kind', '021_drop_legacy_role']) await q(piSql(f));
163
+ const joined = async (id) => (await q(
164
+ `SELECT membership_kind, joined_at FROM platform_identity_project_memberships WHERE client_id = 'demo' AND github_id = $1`, [id])).rows[0];
165
+ const backdate = (id, days) => q(
166
+ `UPDATE platform_identity_project_memberships SET joined_at = now() - make_interval(days => $2) WHERE client_id = 'demo' AND github_id = $1`, [id, days]);
167
+
168
+ ok((await pi.inviteToProject({ clientId: 'demo', githubId: 1 }, { pool: client })).invited === true, 'a first invite writes a notice');
169
+ await backdate(1, 10);
170
+ const before = (await joined(1)).joined_at;
171
+ ok((await pi.inviteToProject({ clientId: 'demo', githubId: 1 }, { pool: client })).invited === false
172
+ && (await joined(1)).joined_at.getTime() === before.getTime(), 'a re-send while the invite is live leaves the notice as it was');
173
+
174
+ await pi.inviteToProject({ clientId: 'demo', githubId: 2 }, { pool: client });
175
+ await backdate(2, 31);
176
+ const listed = async (id) => (await pi.listPendingInvitesForAccount(id, { pool: client })).length;
177
+ ok(await listed(1) === 1, 'a 10-day-old notice is listed');
178
+ ok(await listed(2) === 0, 'a 31-day-old notice is not: its door no longer opens');
179
+ ok((await pi.inviteToProject({ clientId: 'demo', githubId: 2 }, { pool: client })).invited === true && await listed(2) === 1,
180
+ 'a re-invite after the 30 days restamps the notice, so it shows again');
181
+
182
+ await q(`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, joined_at)
183
+ VALUES ('demo', 3, 'member', now() - interval '400 days')`);
184
+ const member = await joined(3);
185
+ ok((await pi.inviteToProject({ clientId: 'demo', githubId: 3 }, { pool: client })).invited === false, 'inviting a member writes nothing');
186
+ const after = await joined(3);
187
+ ok(after.membership_kind === 'member' && after.joined_at.getTime() === member.joined_at.getTime(),
188
+ 'and their row is untouched, however old it is');
189
+
190
+ console.log(`\ninvite_expiry_db.mjs: ${pass} passed`);
191
+ } finally {
192
+ await new Promise((r) => server.close(r));
193
+ await q(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
194
+ client.release();
195
+ await pgPool.end().catch(() => {});
196
+ }