@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
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ // modules/government/charter.js — the genesis charter and legislation stages'
4
+ // vocabulary, on the REAL constitution (task 1004427 / BV2.PS13, spec decision
5
+ // D10 in docs/specs/bongos-v2-project-startup.md).
6
+ //
7
+ // REAL NAMES, EACH EXPLAINED PLAINLY. Real-world governments are the model for
8
+ // a project's structure, so every form and every setting is called what it
9
+ // actually is — Monarchy / BDFL, Timocratic Republic, franchise, judicial
10
+ // review — and carries a one- or two-sentence explanation of how it works in a
11
+ // project. The explanation is simpler than the academic definition and never a
12
+ // replacement for the name.
13
+ //
14
+ // ONLY WHAT IS BUILT CLAIMS TO BE. Two variables are the live board block:
15
+ // franchise (`membership`) and pass rule (`pass_rule`). Loyalty of enforcement
16
+ // is always on — authority is resolved from the rank you hold, per request and
17
+ // uncached (ADR 0016), so nobody keeps a rank's powers after leaving it. The
18
+ // other four are shown as coming later, and so are the three forms that need
19
+ // machinery this system does not have (elections, member shares, federated
20
+ // chambers).
21
+ //
22
+ // THE NAME FOLLOWS THE SETTINGS. `readCharter` reads what a constitution SEATS
23
+ // (through the real membership parser) and its pass rule, and names the form
24
+ // those make — or "Custom" when they make none. It reads the seated set, not
25
+ // the spelling, so `rank:metic+` and `rank:metic,archon` read the same.
26
+ //
27
+ // APPLYING IS AN AMENDMENT. `buildCharterProposal` only BUILDS a whole
28
+ // constitution from a choice; board.js `proposeCharter` files it through the
29
+ // ordinary amendment path, so the choice is validated strictly and decided
30
+ // under the constitution in force (under monarchy, the founder's one yes
31
+ // applies it). There is no second write path to the constitution.
32
+ //
33
+ // Pure and DB-free: it requires only the catalog's ladder and the membership
34
+ // grammar, so a test (or a boot-time caller) can use it without a database.
35
+ //
36
+ // The form names are held equal to the hall's charter library
37
+ // (modules/hall-ui/public/government.js CHARTERS) by
38
+ // tests/government_charter_forms.mjs — browser code cannot require this file,
39
+ // and one form must never go by two names.
40
+
41
+ const { RANK_ORDER } = require('./catalog');
42
+ const { parseMembershipPredicate } = require('./membership-predicate');
43
+
44
+ const freezeAll = (list) => Object.freeze(list.map((x) => Object.freeze({ ...x })));
45
+
46
+ // ── franchise: who gets a vote ───────────────────────────────────────────────
47
+ // `predicate` is the exact membership string written to the constitution, and
48
+ // `seats` is what it must resolve to. `council` names a custom rank the owner
49
+ // chooses, so its predicate is built from that choice.
50
+ const FRANCHISE_OPTIONS = freezeAll([
51
+ { key: 'founder', name: 'The founder', predicate: 'rank:archon', seats: ['archon'],
52
+ explanation: 'Only the founder votes (the Archon rank). Right for a solo start, when there is nobody else to ask.' },
53
+ { key: 'trusted', name: 'Trusted builders and up', predicate: 'rank:metic+', seats: ['metic', 'archon'],
54
+ explanation: 'Builders who have earned the trusted rank by shipping get a vote, along with the founder.' },
55
+ { key: 'everyone', name: 'Every builder', predicate: 'rank:xenos+', seats: [...RANK_ORDER],
56
+ explanation: 'Everyone on the project gets a vote, from the newest builder to the founder.' },
57
+ { key: 'council', name: 'A council you name', predicate: null, seats: null,
58
+ explanation: 'Only the members of a council rank you create get a vote. You choose who sits on it.' },
59
+ ]);
60
+
61
+ // ── pass rule (winning coalition): how many must agree ───────────────────────
62
+ // Exactly the four rules config.js PASS_RULES knows (held equal by test).
63
+ const PASS_RULE_OPTIONS = freezeAll([
64
+ { key: 'first_ratifier', name: 'One yes is enough',
65
+ explanation: 'One yes from any member decides it.' },
66
+ { key: 'consent', name: 'Passes unless someone objects',
67
+ explanation: 'It passes unless a member objects, once everyone has voted or the clock runs out.' },
68
+ { key: 'majority', name: 'More than half',
69
+ explanation: 'More than half of all members must say yes.' },
70
+ { key: 'unanimous', name: 'Everyone who votes agrees',
71
+ explanation: 'Everyone who votes must agree, and at least one member must say yes.' },
72
+ ]);
73
+
74
+ // ── trajectory: how the government changes as the project grows ─────────────
75
+ const TRAJECTORIES = freezeAll([
76
+ { key: 'locked', name: 'Locked',
77
+ explanation: 'The government stays as founded until the project votes to change it.' },
78
+ { key: 'laddered', name: 'Laddered',
79
+ explanation: 'The government steps up automatically as the team crosses each size threshold.' },
80
+ { key: 'scheduled', name: 'Scheduled',
81
+ explanation: 'The government changes on dates the project commits to in advance.' },
82
+ ]);
83
+
84
+ // ── the government doc's seven variables ─────────────────────────────────────
85
+ // status: 'live' (a real setting of the constitution today) | 'always_on' |
86
+ // 'later' (shown, explained, and not yet settable).
87
+ const VARIABLES = freezeAll([
88
+ { key: 'franchise', name: 'Franchise', question: 'Who gets a vote?', status: 'live',
89
+ explanation: 'Who sits on the board and votes on the project\'s decisions.' },
90
+ { key: 'pass_rule', name: 'Pass rule (winning coalition)', question: 'How many must agree?', status: 'live',
91
+ explanation: 'How much agreement a decision needs before it passes.' },
92
+ { key: 'veto_points', name: 'Veto points', question: 'How many checkpoints must a big change pass?', status: 'later',
93
+ explanation: 'How many separate approvals a big change must collect before it happens.' },
94
+ { key: 'executive_legislative', name: 'Executive–legislative relationship',
95
+ question: 'Does the leader serve at the team\'s confidence?', status: 'later',
96
+ explanation: 'Whether the team can replace its leader by vote, or the leader holds office for a set term.' },
97
+ { key: 'judicial_review', name: 'Judicial review', question: 'Who can void a decision that breaks the charter?', status: 'later',
98
+ explanation: 'Who may cancel a decision that goes against the project\'s charter.' },
99
+ { key: 'loyalty_of_enforcement', name: 'Loyalty of enforcement',
100
+ question: 'Does power belong to the office or the person?', status: 'always_on',
101
+ explanation: 'Whoever holds a rank holds its powers, and nobody keeps them after leaving it.' },
102
+ { key: 'trajectory', name: 'Trajectory', question: 'How does the government change as you grow?', status: 'later',
103
+ explanation: 'Whether the government stays put, grows with the team, or changes on a schedule.' },
104
+ ]);
105
+
106
+ // ── the forms ────────────────────────────────────────────────────────────────
107
+ // An available form is a franchise + a pass rule; choosing it sets exactly
108
+ // those two. `key` matches the hall charter library's key.
109
+ const FORMS = freezeAll([
110
+ { key: 'monarchy', name: 'Monarchy / BDFL', available: true, franchise: 'founder', pass_rule: 'first_ratifier',
111
+ explanation: 'One leader decides. In a project the founder makes the calls, which is fast, clear and honest for a solo start.' },
112
+ { key: 'timocratic-republic', name: 'Timocratic Republic', available: true, franchise: 'trusted', pass_rule: 'consent',
113
+ explanation: 'A say is earned, not given. Builders who have reached the trusted rank decide together.' },
114
+ { key: 'direct-democracy', name: 'Direct Democracy', available: true, franchise: 'everyone', pass_rule: 'consent',
115
+ explanation: 'Every builder votes directly. A decision passes unless someone objects with a reason.' },
116
+ { key: 'foundation', name: 'Foundation', available: true, franchise: 'council', pass_rule: 'consent',
117
+ explanation: 'A written charter looked after by a small council of trustees you appoint.' },
118
+ { key: 'presidential', name: 'Presidential', available: false,
119
+ explanation: 'The team elects a leader for a fixed term. Coming later, because it needs elections.' },
120
+ { key: 'cooperative', name: 'Cooperative', available: false,
121
+ explanation: 'Every member holds a share and gets one vote. Coming later, because it needs member shares.' },
122
+ { key: 'federation', name: 'Federation', available: false,
123
+ explanation: 'Self-governing teams coordinate under one shared pact. Coming later, for projects with many teams.' },
124
+ ]);
125
+
126
+ const CUSTOM_EXPLANATION = 'These settings match no named form of government. They are still the real rules the board runs under.';
127
+
128
+ const byKey = (list, key) => list.find((x) => x.key === key) || null;
129
+ const sameSet = (a, b) => a.length === b.length && a.every((k) => b.includes(k));
130
+
131
+ // Is this a single CUSTOM rank (a council)? A custom rank is a DB row this file
132
+ // cannot see, so "not on the standard ladder" is the whole test here; whether
133
+ // it exists is board.js's check against the live rows.
134
+ const isCustomRankKey = (key) => typeof key === 'string' && !RANK_ORDER.includes(key);
135
+
136
+ // Which franchise option does a membership predicate SEAT? Read through the
137
+ // real parser, so an equivalent spelling reads the same and an unparseable one
138
+ // reads as custom rather than as a guess.
139
+ function franchiseOf(membership) {
140
+ const parsed = parseMembershipPredicate(membership);
141
+ if (!parsed) return 'custom';
142
+ const seats = [...parsed.rankKeys];
143
+ for (const o of FRANCHISE_OPTIONS) if (o.seats && sameSet(o.seats, seats)) return o.key;
144
+ if (seats.length === 1 && isCustomRankKey(seats[0])) return 'council';
145
+ return 'custom';
146
+ }
147
+
148
+ // The name follows the settings: which form does this board block make?
149
+ function readCharter(board) {
150
+ const b = board || {};
151
+ const franchise = franchiseOf(b.membership);
152
+ const passRule = byKey(PASS_RULE_OPTIONS, b.pass_rule) ? b.pass_rule : 'custom';
153
+ const form = FORMS.find((f) => f.available && f.franchise === franchise && f.pass_rule === passRule);
154
+ return Object.freeze(form
155
+ ? { form: form.key, name: form.name, explanation: form.explanation, franchise, pass_rule: passRule }
156
+ : { form: 'custom', name: 'Custom', explanation: CUSTOM_EXPLANATION, franchise, pass_rule: passRule });
157
+ }
158
+
159
+ // Build the WHOLE constitution a charter choice makes, on top of the live one.
160
+ // A choice is a form, a franchise, a pass rule, a window, or a form fine-tuned by
161
+ // the others (the legislation stage tunes what the charter stage picked). The
162
+ // fields a charter does not speak to — the clock and the full-turnout early
163
+ // close — are KEPT from the live constitution unless a window is named, because
164
+ // a constitution is ratified whole and the choice must not quietly change them.
165
+ // Returns { ok: true, proposal } or { ok: false, reason }; the caller validates
166
+ // the result strictly and files it as an amendment.
167
+ function buildCharterProposal(liveBoard, { form, franchise, councilRank, passRule, windowMinutes } = {}) {
168
+ const live = liveBoard || {};
169
+ let franchiseKey = franchise;
170
+ let rule = passRule;
171
+ if (form !== undefined && form !== null) {
172
+ const f = byKey(FORMS, form);
173
+ if (!f) return { ok: false, reason: 'unknown_form' };
174
+ if (!f.available) return { ok: false, reason: 'form_not_available' };
175
+ if (franchiseKey == null) franchiseKey = f.franchise;
176
+ if (rule == null) rule = f.pass_rule;
177
+ }
178
+ if (franchiseKey == null && rule == null && windowMinutes === undefined) return { ok: false, reason: 'nothing_chosen' };
179
+
180
+ let membership = live.membership;
181
+ if (franchiseKey != null) {
182
+ const option = byKey(FRANCHISE_OPTIONS, franchiseKey);
183
+ if (!option) return { ok: false, reason: 'unknown_franchise' };
184
+ if (option.key === 'council') {
185
+ if (councilRank == null || councilRank === '') return { ok: false, reason: 'council_needs_a_rank' };
186
+ const predicate = `rank:${councilRank}`;
187
+ const parsed = parseMembershipPredicate(predicate);
188
+ if (!parsed || parsed.rankKeys.length !== 1 || !isCustomRankKey(parsed.rankKeys[0])) {
189
+ return { ok: false, reason: 'council_needs_a_custom_rank' };
190
+ }
191
+ membership = predicate;
192
+ } else {
193
+ membership = option.predicate;
194
+ }
195
+ }
196
+ if (rule != null && !byKey(PASS_RULE_OPTIONS, rule)) return { ok: false, reason: 'unknown_pass_rule' };
197
+
198
+ return {
199
+ ok: true,
200
+ proposal: {
201
+ membership,
202
+ pass_rule: rule != null ? rule : live.pass_rule,
203
+ window_minutes: windowMinutes !== undefined ? windowMinutes : (live.window_minutes ?? null),
204
+ close_early_on_full_turnout: live.close_early_on_full_turnout,
205
+ },
206
+ };
207
+ }
208
+
209
+ // The whole vocabulary plus what the constitution in force reads as — the
210
+ // payload the genesis charter and legislation stages render from.
211
+ function charterView(liveBoard) {
212
+ return {
213
+ forms: FORMS,
214
+ variables: VARIABLES.map((v) => (
215
+ v.key === 'franchise' ? { ...v, options: FRANCHISE_OPTIONS }
216
+ : v.key === 'pass_rule' ? { ...v, options: PASS_RULE_OPTIONS }
217
+ : v.key === 'trajectory' ? { ...v, options: TRAJECTORIES }
218
+ : v)),
219
+ reads_as: readCharter(liveBoard),
220
+ };
221
+ }
222
+
223
+ module.exports = {
224
+ FORMS,
225
+ VARIABLES,
226
+ FRANCHISE_OPTIONS,
227
+ PASS_RULE_OPTIONS,
228
+ TRAJECTORIES,
229
+ readCharter,
230
+ buildCharterProposal,
231
+ charterView,
232
+ };
@@ -114,6 +114,11 @@ const governmentPort = {
114
114
  // the constitution in force. And the founding band's read: per stage, what it
115
115
  // is waiting on. A pass is announced as `board.genesis_stage.passed`.
116
116
  openGenesisStageClose: board.openGenesisStageClose,
117
+ // task 1004427 (BV2.PS13): the genesis charter + legislation choice, filed as
118
+ // an ordinary amendment under the constitution in force — for the genesis
119
+ // home, which renders the stages from GET /government/constitution's
120
+ // `charter` block. Opening a sitting decides nothing; the vote does.
121
+ proposeCharter: board.proposeCharter,
117
122
  genesisStageStatus: board.genesisStageStatus,
118
123
  };
119
124
 
@@ -536,6 +541,63 @@ module.exports = function buildGovernmentRouter() {
536
541
  }
537
542
  });
538
543
 
544
+ // rank: perm board.item.open — the genesis charter + legislation choice (task
545
+ // 1004427, spec D10): a form of government and/or its two live settings
546
+ // (franchise, pass rule), built into a whole constitution on top of the live
547
+ // one and filed through the SAME amendment path as the route above — the same
548
+ // atom, the same strict validation, the same self-removal guard. Nothing here
549
+ // changes the constitution; the sitting it opens is what decides.
550
+ router.post('/government/board/charter', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
551
+ if (validateOrRespond(req, res, {
552
+ form: { type: 'string', maxLength: 64 },
553
+ franchise: { type: 'string', maxLength: 32 },
554
+ council_rank: { type: 'string', maxLength: RANK_KEY_MAX },
555
+ pass_rule: { type: 'string', maxLength: 32 },
556
+ rationale_md: { type: 'string', maxLength: 5000 },
557
+ acknowledge_self_removal: { type: 'boolean' },
558
+ })) return;
559
+ const body = req.body || {};
560
+ if (body.window_minutes !== undefined && body.window_minutes !== null && !Number.isInteger(body.window_minutes)) {
561
+ return res.fail('invalid_proposal', 400, { reason: 'bad_window_minutes', expected: 'null (no clock) or a whole number of minutes' });
562
+ }
563
+ try {
564
+ const out = await board.proposeCharter({
565
+ form: body.form ?? undefined,
566
+ franchise: body.franchise ?? undefined,
567
+ councilRank: body.council_rank ?? undefined,
568
+ passRule: body.pass_rule ?? undefined,
569
+ windowMinutes: body.window_minutes,
570
+ rationaleMd: body.rationale_md ?? null,
571
+ proposedBy: req.builder.id,
572
+ acknowledgeSelfRemoval: body.acknowledge_self_removal === true,
573
+ });
574
+ if (!out.ok) {
575
+ if (out.reason === 'proposer_would_lose_their_seat') {
576
+ return res.fail('proposer_would_lose_their_seat', 409, {
577
+ membership: out.membership,
578
+ describes: out.describes,
579
+ what_this_means: out.what_this_means,
580
+ hint: out.hint,
581
+ retry_with: { acknowledge_self_removal: true },
582
+ });
583
+ }
584
+ if (out.reason === 'no_change') {
585
+ return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings' });
586
+ }
587
+ return res.fail('invalid_charter', 400, {
588
+ reason: out.reason,
589
+ ...(out.field ? { field: out.field } : {}),
590
+ ...(out.expected ? { expected: out.expected } : {}),
591
+ ...(out.rank_key ? { rank_key: out.rank_key } : {}),
592
+ });
593
+ }
594
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as });
595
+ } catch (err) {
596
+ log.error('[gds] POST /government/board/charter', err);
597
+ res.fail('charter_failed', { status: 500, message: 'internal error' });
598
+ }
599
+ });
600
+
539
601
  // rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
540
602
  // 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
541
603
  // note): the permission is only the coarse gate, and the real wall is the
@@ -678,6 +678,16 @@
678
678
  // hand" note; PASS_RULE_NAME sits beside its sentence-length twin
679
679
  // PASS_RULE_PLAIN there, so the two renderings of one pass rule cannot drift.
680
680
 
681
+ // The form of government the constitution in force READS AS (task 1004427):
682
+ // the real name and its plain explanation, or Custom. The server does the
683
+ // reading (charter.js readCharter) — the name follows the settings, so this
684
+ // page never guesses it. An older server without the block is said plainly.
685
+ function formOfGovernmentSentence(charter) {
686
+ const r = charter && charter.reads_as;
687
+ if (!r || !r.name) return 'Not available from this server yet.';
688
+ return `<strong>${escapeHtml(r.name)}</strong>. ${escapeHtml(r.explanation || '')}`;
689
+ }
690
+
681
691
  function dialRow(name, sentenceHtml) {
682
692
  return `<li class="ov-row"><div class="ov-row__main"><span class="ov-row__title">${escapeHtml(name)}</span><p class="ov-row__sub">${sentenceHtml}</p></div></li>`;
683
693
  }
@@ -829,6 +839,7 @@
829
839
  live.innerHTML = `
830
840
  ${divergenceHtml(data.divergence)}
831
841
  <ul class="ov-rows">
842
+ ${dialRow('Form of government', formOfGovernmentSentence(data.charter))}
832
843
  ${dialRow('Who sits on the board', membershipSentence(b))}
833
844
  ${dialRow('What counts as passing', passRuleSentence(b))}
834
845
  ${dialRow('How long a sitting runs', windowSentence(b, data.clock))}
@@ -474,6 +474,9 @@
474
474
  // invite form above, which is what the server's own refusal names.
475
475
  function gateRowActions(r) {
476
476
  const id = escapeHtml(r.id);
477
+ // An expired invite's door is already shut: inviting again (the form above) is
478
+ // the only move, so the row offers none.
479
+ if (r.expired) return '';
477
480
  if (r.status === 'invited') {
478
481
  return ghost(`data-gate-action="dismissed" data-gate-from="invited" data-id="${id}"`, 'Rescind', true);
479
482
  }
@@ -493,9 +496,12 @@
493
496
  // A rescind overwrites the approver with the rescinder (ADR 0208), so this
494
497
  // names who put the row in the state it is in now — which is what it says.
495
498
  const by = r.resolved_by_login ? ` by <span class="ov-mono">@${escapeHtml(r.resolved_by_login)}</span>` : '';
496
- const decided = r.resolved_at
497
- ? `<span class="ov-row__when">${r.status === 'invited' ? 'Admitted' : 'Declined'} ${escapeHtml(fmtDate(r.resolved_at))}${by}</span>`
498
- : '';
499
+ // An invite past its 30 days is still 'invited' in storage, but no longer admits.
500
+ const decided = r.expired
501
+ ? `<span class="ov-row__when">Invite expired ${escapeHtml(fmtDate(r.expires_at))} — invite them again to reopen the door</span>`
502
+ : r.resolved_at
503
+ ? `<span class="ov-row__when">${r.status === 'invited' ? 'Admitted' : 'Declined'} ${escapeHtml(fmtDate(r.resolved_at))}${by}</span>`
504
+ : '';
499
505
  const actions = gateRowActions(r);
500
506
  return (
501
507
  `<li class="ov-row" data-req-id="${escapeHtml(r.id)}">` +
@@ -61,6 +61,9 @@ const api = require('../../../src/module-api');
61
61
  const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requirePermission };
62
62
  const { pool } = api;
63
63
  const { validateOrRespond, parseId, accountExistenceReadRateLimit } = api;
64
+ // A recruit invite's door closes 30 days after it is sent, computed at read time
65
+ // (task 1002969, ADR 0356): one definition, shared with the sign-in gate itself.
66
+ const { inviteLiveSql, inviteExpiredSql, inviteExpiresAtSql } = api.inviteExpiry;
64
67
  const approvalBroadcast = require('../approval-broadcast');
65
68
  const log = api.logger('onboarding');
66
69
 
@@ -161,8 +164,10 @@ async function attachApplicantProfiles(rows) {
161
164
  // doorway port that swallows every failure, so the hub can neither slow nor fail an
162
165
  // invite. No hub (self-hosted), or an older one without the route, costs the notice
163
166
  // alone.
167
+ // "Standing" means the door still opens, so an expired invite neither blocks a
168
+ // re-invite nor keeps the hub's notice alive.
164
169
  const STANDING_INVITE_SQL = `SELECT 1 FROM access_requests
165
- WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`;
170
+ WHERE lower(github_login) = lower($1) AND status = 'invited' AND ${inviteLiveSql()} LIMIT 1`;
166
171
 
167
172
  function noticeHubInvited(login) {
168
173
  api.notifyHubOfInvite(login, { invited: true });
@@ -223,11 +228,8 @@ module.exports = function buildAccessRequestsRouter() {
223
228
  return res.fail('already_member', { status: 409, message: 'You already have an account — just sign in with GitHub.' });
224
229
  }
225
230
  // Already approved (invited) but not yet signed in? No new request needed.
226
- const invited = await pool.query(
227
- `SELECT 1 FROM access_requests
228
- WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`,
229
- [login]
230
- );
231
+ // An expired invite is not an approval any more, so its invitee may ask.
232
+ const invited = await pool.query(STANDING_INVITE_SQL, [login]);
231
233
  if (invited.rows.length > 0) {
232
234
  return res.fail('already_approved', { status: 409, message: "You're already approved — sign in with GitHub to enter." });
233
235
  }
@@ -267,7 +269,9 @@ module.exports = function buildAccessRequestsRouter() {
267
269
  // (the device_code is spent), so the CLI can't re-poll the flow — it polls this
268
270
  // instead, then restarts sign-in once admitted. Exposes nothing the public POST
269
271
  // above doesn't already reveal through its 409s (already_member / already_approved).
270
- // status ∈ member | invited | pending | dismissed | none; admitted = member||invited.
272
+ // status ∈ member | invited | expired | pending | dismissed | none; admitted =
273
+ // member||invited. `expired` is an invite past its 30 days (task 1002969): the door
274
+ // is shut, and the CLI stops waiting rather than polling a door that will not open.
271
275
  // rank: public — a would-be builder is by definition outside the system.
272
276
  // Declared BEFORE `GET /access-requests` so the literal `/status` path is matched
273
277
  // as its own route (not swallowed as a query on the list route).
@@ -294,15 +298,17 @@ module.exports = function buildAccessRequestsRouter() {
294
298
  if (member.rows.length > 0) {
295
299
  return res.json({ github_login: login, status: 'member', admitted: true });
296
300
  }
297
- // Prefer an 'invited' row if one exists, else the most recent request.
301
+ // Prefer an invite that still admits, else the most recent request.
298
302
  const ar = await pool.query(
299
- `SELECT status FROM access_requests
303
+ `SELECT status, (status = 'invited' AND ${inviteExpiredSql()}) AS expired
304
+ FROM access_requests
300
305
  WHERE lower(github_login) = lower($1)
301
- ORDER BY (status = 'invited') DESC, created_at DESC
306
+ ORDER BY (status = 'invited' AND ${inviteLiveSql()}) DESC, created_at DESC
302
307
  LIMIT 1`,
303
308
  [login]
304
309
  );
305
- const status = ar.rows.length > 0 ? ar.rows[0].status : 'none';
310
+ const top = ar.rows[0];
311
+ const status = !top ? 'none' : top.expired ? 'expired' : top.status;
306
312
  return res.json({ github_login: login, status, admitted: status === 'invited' });
307
313
  } catch (err) {
308
314
  console.error('[gds] GET /access-requests/status', err);
@@ -335,9 +341,13 @@ module.exports = function buildAccessRequestsRouter() {
335
341
  try {
336
342
  // `kind` + `invited_by_login` let the invited history tell an invite from
337
343
  // an approved application (ADR 0335 D2) — descriptive, like the vouch.
344
+ // `expired` + `expires_at` mark an invite whose 30 days are up (task 1002969):
345
+ // still 'invited' in storage, but its door no longer opens.
338
346
  const { rows } = await pool.query(
339
347
  `SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
340
348
  ar.created_at, ar.resolved_at, ar.kind,
349
+ (ar.status = 'invited' AND ${inviteExpiredSql('ar')}) AS expired,
350
+ ${inviteExpiresAtSql('ar')} AS expires_at,
341
351
  ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
342
352
  (ar.applicant_github_id IS NOT NULL) AS vouched,
343
353
  rb.github_login AS resolved_by_login,
@@ -560,21 +570,34 @@ module.exports = function buildAccessRequestsRouter() {
560
570
  // still 'invited' (not rescinded), and no builder row yet (not yet accepted by
561
571
  // signing in), each naming its sender; ?mine=1 keeps only the caller's own
562
572
  // (ADR 0335 D2; task 1002814). Rescind one with PATCH /access-requests/:id.
573
+ // Each carries `expires_at`, and `expired` once its 30 days are up (task 1002969):
574
+ // listed so the sender knows to invite again, and dropped once a newer invite
575
+ // for the same login is live, since that one already replaced it.
563
576
  // rank: archon — it names logins that are not members yet.
564
577
  router.get('/access-requests/invites', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
565
578
  const mine = req.query.mine === '1';
566
- const mineClause = mine ? 'AND ar.created_by = $1' : '';
579
+ // `login_has_live` is a window over the rows this scan already reads, not a
580
+ // lookup per row: access_requests has no index on lower(github_login) outside
581
+ // pending rows, and invite rows are kept as history. The sender filter sits
582
+ // OUTSIDE the window, so another sender's live re-invite still retires yours.
583
+ const mineClause = mine ? 'AND inv.created_by = $1' : '';
567
584
  try {
568
585
  const { rows } = await pool.query(
569
- `SELECT ar.id, ar.github_login, ar.note, ar.created_at,
570
- ib.github_login AS invited_by_login
571
- FROM access_requests ar
572
- LEFT JOIN builders ib ON ib.id = ar.created_by
573
- WHERE ar.kind = 'invite' AND ar.status = 'invited'
574
- AND NOT EXISTS (SELECT 1 FROM builders b
575
- WHERE lower(b.github_login) = lower(ar.github_login))
586
+ `SELECT inv.id, inv.github_login, inv.note, inv.created_at,
587
+ inv.expires_at, inv.expired, inv.invited_by_login
588
+ FROM (SELECT ar.id, ar.github_login, ar.note, ar.created_at, ar.created_by,
589
+ ${inviteExpiresAtSql('ar')} AS expires_at,
590
+ ${inviteExpiredSql('ar')} AS expired,
591
+ bool_or(${inviteLiveSql('ar')}) OVER (PARTITION BY lower(ar.github_login)) AS login_has_live,
592
+ ib.github_login AS invited_by_login
593
+ FROM access_requests ar
594
+ LEFT JOIN builders ib ON ib.id = ar.created_by
595
+ WHERE ar.kind = 'invite' AND ar.status = 'invited'
596
+ AND NOT EXISTS (SELECT 1 FROM builders b
597
+ WHERE lower(b.github_login) = lower(ar.github_login))) inv
598
+ WHERE NOT (inv.expired AND inv.login_has_live)
576
599
  ${mineClause}
577
- ORDER BY ar.created_at DESC
600
+ ORDER BY inv.created_at DESC
578
601
  LIMIT 200`,
579
602
  mine ? [req.builder.id] : []
580
603
  );
@@ -0,0 +1,65 @@
1
+ -- platform_identity_028_purge_pre_notice_invites.sql — delete the `pending` rows the
2
+ -- OLD hub invite wrote (task 1004408, the owner's call on ADR 0353's open consequence).
3
+ --
4
+ -- WHY. Before ADR 0353 the hub's POST /projects/invite wrote a `pending` membership
5
+ -- row and nothing on the project. My Projects renders that row as "Open project →",
6
+ -- and on any door but `open` (the default is `apply`) it leads to the project's own
7
+ -- gate, which never heard of the invite: a locked door. Since ADR 0353 a `pending`
8
+ -- row is a NOTICE that only the project writes (POST /sso/invites/notify), after it
9
+ -- wrote its own `invited` row. So a pending row is a dead end exactly when it
10
+ -- predates the hub's first run of ADR 0353 code.
11
+ --
12
+ -- THE CUTOFF IS THAT FIRST RUN, read from the core_upgrades ledger: the earliest
13
+ -- upgrade to core 1.20.22 or later (the first release carrying ADR 0353, PR 1367).
14
+ -- No such row means the hub has never served a notice route, so every pending row
15
+ -- is an old one and all of them go. That is the expected case: the hub served
16
+ -- 1.19.1081 when this was written, and upgrade.js writes the ledger row AFTER
17
+ -- migrate, so the upgrade applying this file is not yet in the ledger. The ledger
18
+ -- row lands just after the restart, so a notice written in those few seconds would
19
+ -- also go; an old-style row cannot survive either way.
20
+ --
21
+ -- WHAT IS LOST. The owner accepted this (option (a)): an old row on an `open` door
22
+ -- did lead somewhere, and it is deleted too. That invitee can still sign in to the
23
+ -- project, and the inviter can re-invite through the project's own hall.
24
+ --
25
+ -- Touches `pending` rows only, so no real membership (owner, member, visitor) can
26
+ -- be removed. One-shot: a replay after this file is recorded does nothing, so a
27
+ -- hand-run later can never sweep up real notices. Deletes data but adds nothing
28
+ -- the previous release cannot read, so it is forward-safe (ADR 0083 §5).
29
+
30
+ BEGIN;
31
+
32
+ DO $$
33
+ DECLARE
34
+ notices_since timestamptz;
35
+ removed integer;
36
+ BEGIN
37
+ IF EXISTS (SELECT 1 FROM schema_migrations
38
+ WHERE version = 'platform_identity_028_purge_pre_notice_invites') THEN
39
+ RETURN;
40
+ END IF;
41
+
42
+ -- Only a well-formed x.y.z counts (any suffix ignored); the digit caps keep the
43
+ -- int cast from overflowing, so a malformed ledger row can never fail the deploy.
44
+ IF to_regclass('core_upgrades') IS NOT NULL THEN
45
+ SELECT min(applied_at) INTO notices_since
46
+ FROM (SELECT applied_at,
47
+ substring(to_version FROM '^v?([0-9]{1,6}\.[0-9]{1,6}\.[0-9]{1,6})(?:[^0-9]|$)') AS xyz
48
+ FROM core_upgrades) u
49
+ WHERE xyz IS NOT NULL
50
+ AND string_to_array(xyz, '.')::int[] >= ARRAY[1, 20, 22];
51
+ END IF;
52
+
53
+ DELETE FROM platform_identity_project_memberships
54
+ WHERE membership_kind = 'pending'
55
+ AND (notices_since IS NULL OR joined_at < notices_since);
56
+ GET DIAGNOSTICS removed = ROW_COUNT;
57
+
58
+ RAISE NOTICE 'platform_identity_028: removed % pre-ADR-0353 pending invite row(s) (cutoff: %)',
59
+ removed, COALESCE(notices_since::text, 'none, the hub never ran ADR 0353');
60
+ END $$;
61
+
62
+ INSERT INTO schema_migrations (version) VALUES ('platform_identity_028_purge_pre_notice_invites')
63
+ ON CONFLICT DO NOTHING;
64
+
65
+ COMMIT;
@@ -529,22 +529,25 @@ async function listMembershipsForAccount(githubId, { pool = defaultPool } = {})
529
529
 
530
530
  // Record that a project invited a builder: pre-seed a 'pending' membership, which is
531
531
  // a NOTICE shown in My Projects — the project's own row is what admits (ADR 0353;
532
- // written only from invite-notices.js now). Does NOT clobber an existing membership (ON CONFLICT DO NOTHING) — a
533
- // current member is left as-is. Returns { invited:true, row } when a pending row was
534
- // created, or { invited:false, alreadyMember:true } when a membership already existed.
532
+ // written only from invite-notices.js now). Never clobbers a membership: a member is
533
+ // left as-is, and a pending notice is restamped only once its invite's 30 days are up,
534
+ // so a re-invite shows again (ADR 0356). Returns { invited:true, row } when it wrote,
535
+ // or { invited:false, alreadyMember:true } when a membership or live notice stood.
535
536
  async function inviteToProject({ clientId, githubId }, { pool = defaultPool } = {}) {
536
537
  const { rows } = await pool.query(
537
- `INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind)
538
+ `INSERT INTO platform_identity_project_memberships AS m (client_id, github_id, membership_kind)
538
539
  VALUES ($1, $2, 'pending')
539
- ON CONFLICT (client_id, github_id) DO NOTHING
540
+ ON CONFLICT (client_id, github_id) DO UPDATE SET joined_at = now()
541
+ WHERE m.membership_kind = 'pending' AND m.joined_at <= now() - make_interval(days => $3)
540
542
  RETURNING id, client_id, github_id, membership_kind`,
541
- [clientId, githubId],
543
+ [clientId, githubId, api.inviteExpiry.INVITE_EXPIRY_DAYS],
542
544
  );
543
545
  if (rows[0]) return { invited: true, row: rows[0] };
544
546
  return { invited: false, alreadyMember: true };
545
547
  }
546
548
 
547
- // The pending invitations for one account (joined to the project display fields).
549
+ // The pending invitations for one account (joined to the project display fields),
550
+ // minus any past its invite's 30 days: that door no longer opens (ADR 0356).
548
551
  async function listPendingInvitesForAccount(githubId, { pool = defaultPool } = {}) {
549
552
  const { rows } = await pool.query(
550
553
  `SELECT m.client_id, m.joined_at AS invited_at,
@@ -552,8 +555,9 @@ async function listPendingInvitesForAccount(githubId, { pool = defaultPool } = {
552
555
  FROM platform_identity_project_memberships m
553
556
  LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
554
557
  WHERE m.github_id = $1 AND m.membership_kind = 'pending'
558
+ AND m.joined_at > now() - make_interval(days => $2)
555
559
  ORDER BY m.joined_at DESC`,
556
- [githubId],
560
+ [githubId, api.inviteExpiry.INVITE_EXPIRY_DAYS],
557
561
  );
558
562
  return rows;
559
563
  }
@@ -538,10 +538,12 @@ await ta('inviteToProject pre-seeds a pending row, never clobbering an existing
538
538
  const c = pool.calls[0];
539
539
  assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
540
540
  assert.match(c.text, /VALUES \(\$1, \$2, 'pending'\)/);
541
- assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO NOTHING/, 'must not downgrade an existing member');
541
+ // Only joined_at, only on an expired pending notice (task 1002969); real SQL in
542
+ // ../../tests/invite_expiry_db.mjs.
543
+ assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE SET joined_at = now\(\)\s+WHERE m\.membership_kind = 'pending' AND/, 'must not downgrade an existing member');
542
544
  });
543
545
 
544
- await ta('inviteToProject reports already-member when the row exists (DO NOTHING → no row)', async () => {
546
+ await ta('inviteToProject reports already-member when the row exists (no row returned)', async () => {
545
547
  const pool = mockPool([{ rows: [] }]);
546
548
  const out = await P.inviteToProject({ clientId: 'c1', githubId: 42 }, { pool });
547
549
  assert.deepEqual(out, { invited: false, alreadyMember: true });