@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
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 2188,
7
- "unique_ids": 1888,
8
- "certain": 2188,
6
+ "strings": 2189,
7
+ "unique_ids": 1889,
8
+ "certain": 2189,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 207
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 109,
16
- "strings": 1617
16
+ "strings": 1618
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -182,7 +182,7 @@
182
182
  "modules/hall-ui/public/idea.js:234",
183
183
  "modules/hall-ui/public/ideas.js:201",
184
184
  "modules/hall-ui/public/ideas.js:782",
185
- "modules/hall-ui/public/watch.js:1265"
185
+ "modules/hall-ui/public/watch.js:1271"
186
186
  ]
187
187
  },
188
188
  {
@@ -268,7 +268,7 @@
268
268
  "modules/hall-ui/public/board-room.js:526",
269
269
  "modules/hall-ui/public/gate.js:234",
270
270
  "modules/hall-ui/public/government.js:890",
271
- "modules/hall-ui/public/watch.js:1294"
271
+ "modules/hall-ui/public/watch.js:1300"
272
272
  ]
273
273
  },
274
274
  {
@@ -638,7 +638,7 @@
638
638
  "occurrences": 2,
639
639
  "at": [
640
640
  "modules/hall-ui/public/collab.js:91",
641
- "modules/hall-ui/public/watch.js:511"
641
+ "modules/hall-ui/public/watch.js:517"
642
642
  ]
643
643
  },
644
644
  {
@@ -1378,7 +1378,7 @@
1378
1378
  "occurrences": 2,
1379
1379
  "at": [
1380
1380
  "modules/hall-ui/public/drachmae.js:148",
1381
- "modules/hall-ui/public/watch.js:1155"
1381
+ "modules/hall-ui/public/watch.js:1161"
1382
1382
  ]
1383
1383
  },
1384
1384
  {
@@ -2187,7 +2187,7 @@
2187
2187
  "text": "{…} {…}{…}",
2188
2188
  "occurrences": 2,
2189
2189
  "at": [
2190
- "modules/hall-ui/public/watch.js:497",
2190
+ "modules/hall-ui/public/watch.js:503",
2191
2191
  "modules/hall-ui/public/work.js:265"
2192
2192
  ]
2193
2193
  },
@@ -3180,7 +3180,7 @@
3180
3180
  "surface": "builders-hall",
3181
3181
  "text": "Asked {…}",
3182
3182
  "file": "modules/hall-ui/public/watch.js",
3183
- "line": 511,
3183
+ "line": 517,
3184
3184
  "origin": "js-markup",
3185
3185
  "confidence": "certain"
3186
3186
  },
@@ -4251,7 +4251,7 @@
4251
4251
  "surface": "builders-hall",
4252
4252
  "text": "Could not change rank: {…}",
4253
4253
  "file": "modules/hall-ui/public/watch.js",
4254
- "line": 883,
4254
+ "line": 889,
4255
4255
  "origin": "js-notice",
4256
4256
  "confidence": "certain"
4257
4257
  },
@@ -4260,7 +4260,7 @@
4260
4260
  "surface": "builders-hall",
4261
4261
  "text": "Could not change standing: {…}",
4262
4262
  "file": "modules/hall-ui/public/watch.js",
4263
- "line": 909,
4263
+ "line": 915,
4264
4264
  "origin": "js-notice",
4265
4265
  "confidence": "certain"
4266
4266
  },
@@ -4476,7 +4476,7 @@
4476
4476
  "surface": "builders-hall",
4477
4477
  "text": "Could not load this page.",
4478
4478
  "file": "modules/hall-ui/public/watch.js",
4479
- "line": 1345,
4479
+ "line": 1351,
4480
4480
  "origin": "js-notice",
4481
4481
  "confidence": "certain"
4482
4482
  },
@@ -4557,7 +4557,7 @@
4557
4557
  "surface": "builders-hall",
4558
4558
  "text": "Could not read the report: {…}",
4559
4559
  "file": "modules/hall-ui/public/watch.js",
4560
- "line": 663,
4560
+ "line": 669,
4561
4561
  "origin": "js-markup",
4562
4562
  "confidence": "certain"
4563
4563
  },
@@ -4845,7 +4845,7 @@
4845
4845
  "surface": "builders-hall",
4846
4846
  "text": "Could not verify your access. Go back and try again.",
4847
4847
  "file": "modules/hall-ui/public/watch.js",
4848
- "line": 1344,
4848
+ "line": 1350,
4849
4849
  "origin": "js-text-assign",
4850
4850
  "confidence": "certain"
4851
4851
  },
@@ -6631,6 +6631,15 @@
6631
6631
  "origin": "js-markup",
6632
6632
  "confidence": "certain"
6633
6633
  },
6634
+ {
6635
+ "id": "33a35c77d372",
6636
+ "surface": "builders-hall",
6637
+ "text": "Invite expired {…} — invite them again to reopen the door",
6638
+ "file": "modules/hall-ui/public/watch.js",
6639
+ "line": 501,
6640
+ "origin": "js-markup",
6641
+ "confidence": "certain"
6642
+ },
6634
6643
  {
6635
6644
  "id": "b9cb720ad5d1",
6636
6645
  "surface": "builders-hall",
@@ -8679,7 +8688,7 @@
8679
8688
  "surface": "builders-hall",
8680
8689
  "text": "Over the last {…} days",
8681
8690
  "file": "modules/hall-ui/public/watch.js",
8682
- "line": 1182,
8691
+ "line": 1188,
8683
8692
  "origin": "js-markup",
8684
8693
  "confidence": "certain"
8685
8694
  },
@@ -9138,7 +9147,7 @@
9138
9147
  "surface": "builders-hall",
9139
9148
  "text": "Rank set to {…}.",
9140
9149
  "file": "modules/hall-ui/public/watch.js",
9141
- "line": 877,
9150
+ "line": 883,
9142
9151
  "origin": "js-notice",
9143
9152
  "confidence": "certain"
9144
9153
  },
@@ -9210,7 +9219,7 @@
9210
9219
  "surface": "builders-hall",
9211
9220
  "text": "Read it as a table",
9212
9221
  "file": "modules/hall-ui/public/watch.js",
9213
- "line": 1155,
9222
+ "line": 1161,
9214
9223
  "origin": "js-markup",
9215
9224
  "confidence": "certain"
9216
9225
  },
@@ -9543,7 +9552,7 @@
9543
9552
  "surface": "builders-hall",
9544
9553
  "text": "Report {…}.",
9545
9554
  "file": "modules/hall-ui/public/watch.js",
9546
- "line": 597,
9555
+ "line": 603,
9547
9556
  "origin": "js-notice",
9548
9557
  "confidence": "certain"
9549
9558
  },
@@ -9714,7 +9723,7 @@
9714
9723
  "surface": "builders-hall",
9715
9724
  "text": "Restricted.",
9716
9725
  "file": "modules/hall-ui/public/watch.js",
9717
- "line": 1294,
9726
+ "line": 1300,
9718
9727
  "origin": "js-text-assign",
9719
9728
  "confidence": "certain"
9720
9729
  },
@@ -10740,7 +10749,7 @@
10740
10749
  "surface": "builders-hall",
10741
10750
  "text": "System oversight: people, operations and security.",
10742
10751
  "file": "modules/hall-ui/public/watch.js",
10743
- "line": 1358,
10752
+ "line": 1364,
10744
10753
  "origin": "js-text-assign",
10745
10754
  "confidence": "certain"
10746
10755
  },
@@ -10983,7 +10992,7 @@
10983
10992
  "surface": "builders-hall",
10984
10993
  "text": "That's you",
10985
10994
  "file": "modules/hall-ui/public/watch.js",
10986
- "line": 826,
10995
+ "line": 832,
10987
10996
  "origin": "js-markup",
10988
10997
  "confidence": "certain"
10989
10998
  },
@@ -13287,7 +13296,7 @@
13287
13296
  "surface": "builders-hall",
13288
13297
  "text": "Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last {…} locking everyone out — so another {…} does it.",
13289
13298
  "file": "modules/hall-ui/public/watch.js",
13290
- "line": 858,
13299
+ "line": 864,
13291
13300
  "origin": "js-text-assign",
13292
13301
  "confidence": "certain"
13293
13302
  },
@@ -15204,7 +15213,7 @@
15204
15213
  "surface": "builders-hall",
15205
15214
  "text": "task {…}",
15206
15215
  "file": "modules/hall-ui/public/watch.js",
15207
- "line": 1265,
15216
+ "line": 1271,
15208
15217
  "origin": "js-markup",
15209
15218
  "confidence": "certain"
15210
15219
  },
@@ -16176,7 +16185,7 @@
16176
16185
  "surface": "builders-hall",
16177
16186
  "text": "{…} {…}{…}",
16178
16187
  "file": "modules/hall-ui/public/watch.js",
16179
- "line": 497,
16188
+ "line": 503,
16180
16189
  "origin": "js-markup",
16181
16190
  "confidence": "certain"
16182
16191
  },
@@ -2710,6 +2710,8 @@ is load-bearing: the script throws rather than guess if it is missing, and
2710
2710
  1.20.26 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2711
2711
  landed since 1.20.25 with no explicit bump. run 36764011836. (task 1002620)
2712
2712
  1.20.27 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2713
- landed since 1.20.26 with no explicit bump. run 36767793137. (task 1002620)
2713
+ landed since 1.20.26 with no explicit bump. run 36768642814. (task 1002620)
2714
+ 1.20.28 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2715
+ landed since 1.20.27 with no explicit bump. run 36772266063. (task 1002620)
2714
2716
  ---------------------------------------------------------------------------
2715
2717
  ```
@@ -2980,7 +2980,7 @@
2980
2980
  "surface": "builders",
2981
2981
  "title": "Watch",
2982
2982
  "states_read": ["board","out","operations","security"],
2983
- "files_hash": "4139b4721a06ea62",
2983
+ "files_hash": "4b89e8960b706953",
2984
2984
  "reading_hash": "de49e347cca76e41",
2985
2985
  "counts": {"lines":156,"placed":44,"shared":5,"unplaced":107},
2986
2986
  "omitted": {"data":123},
@@ -2991,7 +2991,7 @@
2991
2991
  {"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":487,"string_id":"eba610d25523"},
2992
2992
  {"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared"},
2993
2993
  {"key":"L0005","section":"Watch","text":"Watch","placement":"unplaced"},
2994
- {"key":"L0006","section":"Watch","text":"System oversight: people, operations and security.","placement":"placed","file":"modules/hall-ui/public/watch.js","line":1358,"string_id":"4894b1d9b0de"},
2994
+ {"key":"L0006","section":"Watch","text":"System oversight: people, operations and security.","placement":"placed","file":"modules/hall-ui/public/watch.js","line":1364,"string_id":"4894b1d9b0de"},
2995
2995
  {"key":"L0007","section":"Watch","text":"Access requests","placement":"placed","file":"modules/hall-ui/public/watch.html","line":46,"string_id":"996edffc1966"},
2996
2996
  {"key":"L0008","section":"Watch","text":"pending, on the People tab","placement":"placed","file":"modules/hall-ui/public/watch.html","line":46,"string_id":"2fb1dad96280"},
2997
2997
  {"key":"L0009","section":"Watch","text":"Security reports","placement":"placed","file":"modules/hall-ui/public/watch.html","line":47,"string_id":"ab8af800f2bb"},
@@ -3029,7 +3029,7 @@
3029
3029
  {"key":"L0041","section":"Security","text":"Requester","placement":"unplaced","state":"security"},
3030
3030
  {"key":"L0042","section":"Security","text":"Rationale","placement":"unplaced","state":"security"},
3031
3031
  {"key":"L0043","section":"Security","text":"Reward","placement":"unplaced","state":"security"},
3032
- {"key":"L0044","section":"Security","text":"Asked","placement":"placed","file":"modules/hall-ui/public/watch.js","line":511,"string_id":"b2656d0b2402","state":"security"},
3032
+ {"key":"L0044","section":"Security","text":"Asked","placement":"placed","file":"modules/hall-ui/public/watch.js","line":517,"string_id":"b2656d0b2402","state":"security"},
3033
3033
  {"key":"L0045","section":"Security","text":"Decide","placement":"unplaced","state":"security"},
3034
3034
  {"key":"L0046","section":"Security","text":"Approve","placement":"unplaced","state":"security"},
3035
3035
  {"key":"L0047","section":"Security","text":"Deny","placement":"unplaced","state":"security"},
@@ -3061,7 +3061,7 @@
3061
3061
  {"key":"L0073","section":"Sessions","text":"Earned","placement":"unplaced","state":"operations"},
3062
3062
  {"key":"L0074","section":"Sessions","text":"Duration","placement":"unplaced","state":"operations"},
3063
3063
  {"key":"L0075","section":"Sessions","text":"Uploaded","placement":"unplaced","state":"operations"},
3064
- {"key":"L0076","section":"Sessions","text":"task {…}","placement":"placed","file":"modules/hall-ui/public/watch.js","line":1265,"string_id":"4d8ee7d68974","state":"operations","occurrences":5},
3064
+ {"key":"L0076","section":"Sessions","text":"task {…}","placement":"placed","file":"modules/hall-ui/public/watch.js","line":1271,"string_id":"4d8ee7d68974","state":"operations","occurrences":5},
3065
3065
  {"key":"L0077","section":"Sessions","text":"412.0k","placement":"unplaced","state":"operations"},
3066
3066
  {"key":"L0078","section":"Sessions","text":"1h 31m","placement":"unplaced","state":"operations"},
3067
3067
  {"key":"L0079","section":"Sessions","text":"1.2M","placement":"unplaced","state":"operations"},
@@ -3082,8 +3082,8 @@
3082
3082
  {"key":"L0094","section":"Repo health","text":"Dead Files","placement":"unplaced","state":"operations"},
3083
3083
  {"key":"L0095","section":"Repo health","text":"Stale Branches","placement":"unplaced","state":"operations"},
3084
3084
  {"key":"L0096","section":"Repo health","text":"Confirmed Unshipped","placement":"unplaced","state":"operations"},
3085
- {"key":"L0097","section":"Repo health","text":"Over the last {…} days","placement":"placed","file":"modules/hall-ui/public/watch.js","line":1182,"string_id":"d89d1923ecf9","state":"operations"},
3086
- {"key":"L0098","section":"Repo health","text":"Read it as a table","placement":"placed","file":"modules/hall-ui/public/watch.js","line":1155,"string_id":"b03e17693dcd","state":"operations","occurrences":4},
3085
+ {"key":"L0097","section":"Repo health","text":"Over the last {…} days","placement":"placed","file":"modules/hall-ui/public/watch.js","line":1188,"string_id":"d89d1923ecf9","state":"operations"},
3086
+ {"key":"L0098","section":"Repo health","text":"Read it as a table","placement":"placed","file":"modules/hall-ui/public/watch.js","line":1161,"string_id":"b03e17693dcd","state":"operations","occurrences":4},
3087
3087
  {"key":"L0099","section":"Repo health","text":"{…} of {…}","placement":"placed","file":"modules/hall-ui/public/sky-panel.js","line":120,"string_id":"73c1372b43d9","state":"operations"},
3088
3088
  {"key":"L0100","section":"Repo health","text":"Dead exports","placement":"unplaced","state":"operations"},
3089
3089
  {"key":"L0101","section":"Repo health","text":"Exported symbols nothing imports — the knip scan, run daily. 0 – 3 across 28 snapshots.","placement":"unplaced","state":"operations"},
@@ -3111,7 +3111,7 @@
3111
3111
  {"key":"L0123","section":"Access requests","text":"Admitted","placement":"unplaced"},
3112
3112
  {"key":"L0124","section":"Access requests","text":"Declined","placement":"unplaced"},
3113
3113
  {"key":"L0125","section":"Access requests","text":"Name unvouched","placement":"placed","file":"modules/hall-ui/public/watch.js","line":397,"string_id":"5e8199266a8a","occurrences":2},
3114
- {"key":"L0126","section":"Access requests","text":"Asked {…}","placement":"placed","file":"modules/hall-ui/public/watch.js","line":511,"string_id":"b2656d0b2402","occurrences":2},
3114
+ {"key":"L0126","section":"Access requests","text":"Asked {…}","placement":"placed","file":"modules/hall-ui/public/watch.js","line":517,"string_id":"b2656d0b2402","occurrences":2},
3115
3115
  {"key":"L0127","section":"Access requests","text":"Dismiss","placement":"unplaced","occurrences":2},
3116
3116
  {"key":"L0128","section":"Roster","text":"Roster","placement":"unplaced"},
3117
3117
  {"key":"L0129","section":"Roster","text":"Every builder in {{worldName}} — rank, {{currency}}, karma, and active claims.","placement":"placed","file":"modules/hall-ui/public/watch.html","line":69,"string_id":"8690c6424585"},
@@ -3124,9 +3124,9 @@
3124
3124
  {"key":"L0136","section":"Roster","text":"Action","placement":"unplaced"},
3125
3125
  {"key":"L0137","section":"Roster","text":"Offboard","placement":"unplaced","occurrences":3},
3126
3126
  {"key":"L0138","section":"Roster","text":"stale","placement":"unplaced"},
3127
- {"key":"L0139","section":"Roster","text":"That's you","placement":"placed","file":"modules/hall-ui/public/watch.js","line":826,"string_id":"6e5a324b2972"},
3127
+ {"key":"L0139","section":"Roster","text":"That's you","placement":"placed","file":"modules/hall-ui/public/watch.js","line":832,"string_id":"6e5a324b2972"},
3128
3128
  {"key":"L0140","section":"Roster","text":"Reactivate","placement":"unplaced"},
3129
- {"key":"L0141","section":"Roster","text":"Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last {…} locking everyone out — so another {…} does it.","placement":"placed","file":"modules/hall-ui/public/watch.js","line":858,"string_id":"5e0ec4cd1cfe"},
3129
+ {"key":"L0141","section":"Roster","text":"Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last {…} locking everyone out — so another {…} does it.","placement":"placed","file":"modules/hall-ui/public/watch.js","line":864,"string_id":"5e0ec4cd1cfe"},
3130
3130
  {"key":"L0142","section":"Reviews by builder","text":"Reviews by builder","placement":"placed","file":"modules/hall-ui/public/watch.html","line":75,"string_id":"4e062d4f8d69"},
3131
3131
  {"key":"L0143","section":"Reviews by builder","text":"How each builder's shipped work has been graded over the past thirty days.","placement":"placed","file":"modules/hall-ui/public/watch.html","line":76,"string_id":"0bab9286ea38"},
3132
3132
  {"key":"L0144","section":"Reviews by builder","text":"Builder","placement":"unplaced"},
@@ -0,0 +1,31 @@
1
+ -- core_266 — index the invited rows by login (task 1002969, the owner's go-ahead
2
+ -- on a grader finding)
3
+ --
4
+ -- access_requests had two indexes, both partial on status = 'pending' (060). Every
5
+ -- read of an INVITED row therefore scanned the table:
6
+ --
7
+ -- the sign-in gate hasInvitedAccessRequest / accessStateByLogin
8
+ -- (lower(github_login) = lower($1) AND status = 'invited')
9
+ -- the standing invite the onboarding routes' STANDING_INVITE_SQL: re-invite,
10
+ -- rescind-clears-notice, the public POST's already-approved check
11
+ -- the outstanding list GET /access-requests/invites (status = 'invited', windowed
12
+ -- by lower(github_login) since the invite expiry, ADR 0356)
13
+ --
14
+ -- Invite and approved rows are kept as history, so the scan grew with the table's
15
+ -- lifetime rather than with one login's rows. One partial index on the expression
16
+ -- every one of those reads already uses serves them all, and it stays small:
17
+ -- it holds invited rows only.
18
+ --
19
+ -- Additive and idempotent: an index only, no column, constraint or data. Safe to
20
+ -- run forward against a live database, and an older release running against it
21
+ -- just has an unused index. Rollback is DROP INDEX.
22
+
23
+ CREATE INDEX IF NOT EXISTS idx_access_requests_invited_login
24
+ ON access_requests (lower(github_login))
25
+ WHERE status = 'invited';
26
+
27
+ COMMENT ON INDEX idx_access_requests_invited_login IS
28
+ 'Every read of an invited row: the sign-in gate (hasInvitedAccessRequest), the standing-invite probe, and the outstanding-invite list (ADR 0356). 060''s indexes are partial on pending and cannot serve them.';
29
+
30
+ INSERT INTO schema_migrations (version) VALUES ('core_266_access_requests_invited_login_idx')
31
+ ON CONFLICT DO NOTHING;
@@ -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
  );
@@ -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 });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.27",
3
+ "version": "1.20.28",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.27",
9
+ "version": "1.20.28",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.27",
3
+ "version": "1.20.28",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8218,6 +8218,10 @@
8218
8218
  }
8219
8219
  ],
8220
8220
  "1.20.27": [
8221
+ {
8222
+ "id": "1004431",
8223
+ "text": "The new profile and leaderboard design is approved. Every builder keeps one profile that leads with the role they choose, and shows their other roles only once those have earned credits. Ideators get a small version of the sky"
8224
+ },
8221
8225
  {
8222
8226
  "id": "1004416",
8223
8227
  "text": "A new project can now store the seven startup answers: new or existing, where everyone is, whether under-18s are building, what the entity is, how many builders, how they'll be rewarded, and how much Bongos should run. A wrong"
@@ -8230,5 +8234,11 @@
8230
8234
  "id": "1004408",
8231
8235
  "text": "Old project invitations that led to a locked door are cleared from people's My Projects list."
8232
8236
  }
8237
+ ],
8238
+ "1.20.28": [
8239
+ {
8240
+ "id": "1002969",
8241
+ "text": "An invitation to join a project now expires after 30 days; the person who sent it can simply invite again."
8242
+ }
8233
8243
  ]
8234
8244
  }
@@ -558,6 +558,10 @@ const KERNEL_FILES = [
558
558
  // the import-direction guarantee on a file two surfaces now trust.
559
559
  'src/bongos/middleware/rate-limit.js', // the sliding-window abuse ceilings (write + per-surface read)
560
560
  'src/bongos/db-kernel.js', // the kernel db slice — identity/audit/rank/withTx (BV1.R69)
561
+ // task 1002969 (ADR 0356): when an invite's door closes is part of the admission
562
+ // gate db-kernel.js runs, and the doorway hands the same definition to the
563
+ // onboarding and hub modules. It requires NOTHING, so joining costs nothing.
564
+ 'src/bongos/invite-expiry.js', // the read-time invite expiry (INVITE_EXPIRY_DAYS + its SQL)
561
565
  'src/bongos/routes/_helpers.js', // shared request validators the doorway re-exports
562
566
  // config / identity (host context a module reads, never hardcodes)
563
567
  'src/instance-config.js',