@bongos/core 1.20.26 → 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 (54) hide show
  1. package/.bongos-core.json +117 -47
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -1
  8. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +2 -2
  9. package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
  10. package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
  11. package/docs/adr/README.md +2 -0
  12. package/docs/api/openapi.json +105 -5
  13. package/docs/api-reference.md +3 -2
  14. package/docs/copy-inventory.md +37 -36
  15. package/docs/copy-registry.json +52 -43
  16. package/docs/module-api-changelog.md +4 -0
  17. package/docs/page-readings.json +70 -68
  18. package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
  19. package/modules/government/board.js +64 -7
  20. package/modules/government/charter.js +232 -0
  21. package/modules/government/routes/government.js +62 -0
  22. package/modules/hall-ui/public/government.js +11 -0
  23. package/modules/hall-ui/public/watch.js +9 -3
  24. package/modules/onboarding/routes/access-requests.js +43 -20
  25. package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
  26. package/modules/platform-identity/platform-identity.js +12 -8
  27. package/modules/platform-identity/tests/platform-identity.mjs +4 -2
  28. package/modules/provisioning/planet-physics.js +154 -0
  29. package/modules/provisioning/provisioning.js +29 -9
  30. package/modules/provisioning/routes/body-validators.js +25 -7
  31. package/modules/provisioning/routes/provisioning.js +8 -3
  32. package/modules/provisioning/screening.js +62 -0
  33. package/modules/provisioning/soft-limits.js +146 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +24 -0
  37. package/scripts/gds/fitness.js +4 -0
  38. package/scripts/gds/login.js +6 -0
  39. package/scripts/gds/run-unit-tests.js +8 -0
  40. package/src/bongos/db-kernel.js +6 -3
  41. package/src/bongos/invite-expiry.js +47 -0
  42. package/src/bongos/route-rank-check.js +3 -0
  43. package/src/module-api.js +10 -1
  44. package/tests/access_requests_invite.mjs +2 -1
  45. package/tests/application_lifecycle.mjs +164 -5
  46. package/tests/bongos_login.mjs +2 -0
  47. package/tests/government_charter_forms.mjs +283 -0
  48. package/tests/hub_invite_notices.mjs +5 -2
  49. package/tests/invite_expiry.mjs +37 -0
  50. package/tests/invite_expiry_db.mjs +196 -0
  51. package/tests/module_api.mjs +1 -0
  52. package/tests/pending_invite_purge_db.mjs +129 -0
  53. package/tests/project_admin_console_model.mjs +12 -5
  54. package/tests/provisioning_planet_physics.mjs +377 -0
@@ -208,6 +208,14 @@ 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',
215
+ // task 1004408: migration platform_identity_028 deletes the old hub invite's
216
+ // pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
217
+ // engine runs the DO block, the numeric version compare and the one-shot guard.
218
+ 'pending_invite_purge_db',
211
219
  // task 1003956 (ADR 0294): that CLI sessions really never expire. The DB-free
212
220
  // sibling (auth_session_ttl) pins the constant, createSession's value against a
213
221
  // stub pool, and the TEXT of the refresh statement — but a regex over source
@@ -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 };
@@ -271,6 +271,9 @@ const GOVERNMENT_PINS = {
271
271
  // task 1004429: the founding band — who each genesis stage is waiting on. A
272
272
  // read of the open ballot, so the same atom as the room.
273
273
  'GET /government/board/genesis-stages': 'perm:board.vote.cast',
274
+ // task 1004427: the genesis charter + legislation choice — an amendment by
275
+ // another door, so the same atom as proposing one.
276
+ 'POST /government/board/charter': 'perm:board.item.open',
274
277
  };
275
278
 
276
279
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
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.26'; // 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');