@bongos/core 1.20.27 → 1.20.29

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 (44) hide show
  1. package/.bongos-core.json +75 -45
  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 +2 -0
  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 +1 -1
  9. package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +51 -3
  12. package/docs/api-reference.md +3 -2
  13. package/docs/copy-inventory.md +20 -19
  14. package/docs/copy-registry.json +34 -25
  15. package/docs/module-api-changelog.md +5 -1
  16. package/docs/page-readings.json +9 -9
  17. package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
  18. package/modules/hall-ui/public/watch.js +9 -3
  19. package/modules/onboarding/routes/access-requests.js +43 -20
  20. package/modules/platform-identity/platform-identity.js +12 -8
  21. package/modules/platform-identity/tests/platform-identity.mjs +4 -2
  22. package/modules/provisioning/core-upgrade.js +70 -1
  23. package/modules/provisioning/migrations/provisioning_029_update_channel.sql +24 -0
  24. package/modules/provisioning/routes/core-upgrade.js +21 -1
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +16 -0
  28. package/scripts/gds/fitness.js +4 -0
  29. package/scripts/gds/login.js +6 -0
  30. package/scripts/gds/provision-core-upgrade.js +17 -11
  31. package/scripts/gds/run-unit-tests.js +4 -0
  32. package/src/bongos/db-kernel.js +6 -3
  33. package/src/bongos/invite-expiry.js +47 -0
  34. package/src/module-api.js +10 -1
  35. package/tests/access_requests_invite.mjs +2 -1
  36. package/tests/application_lifecycle.mjs +164 -5
  37. package/tests/bongos_login.mjs +2 -0
  38. package/tests/core_upgrade_door.mjs +80 -1
  39. package/tests/core_upgrade_runner.mjs +54 -1
  40. package/tests/hub_invite_notices.mjs +5 -2
  41. package/tests/invite_expiry.mjs +37 -0
  42. package/tests/invite_expiry_db.mjs +196 -0
  43. package/tests/module_api.mjs +1 -0
  44. package/tests/project_admin_console_model.mjs +12 -5
@@ -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 });
@@ -318,6 +318,9 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
318
318
  slug: inst.slug,
319
319
  hosting_shape: inst.hosting_shape,
320
320
  served_version: served,
321
+ // The rule the runner bounds this project's moves by (task 1004445) — what the page
322
+ // shows beside the offer, so "nothing to deploy" can say WHICH rule made it so.
323
+ update_channel: updateChannelOf(inst),
321
324
  recommended_version: recommended,
322
325
  available_count: available.length,
323
326
  behind: !!behind,
@@ -429,5 +432,71 @@ function makeControlPlaneHandler({ provisioning, pool, log }) {
429
432
  };
430
433
  }
431
434
 
432
- module.exports = { CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
435
+ // ---- the project's update rule (task 1004445) -------------------------------------
436
+ //
437
+ // RE-STATED, NOT REQUIRED. The list is update-channel.js VALID_CHANNELS / DEFAULT_CHANNEL,
438
+ // which lives in scripts/gds/ and a module may not require (ADR 0083) — the same reason
439
+ // parseRepoNote re-states the runner's note shape. tests/core_upgrade_door.mjs asserts the
440
+ // two lists are equal, so they cannot drift apart silently; migration provisioning_029's
441
+ // CHECK is the third copy, and the database refuses anything outside it.
442
+ const UPDATE_CHANNELS = Object.freeze(['pinned', 'patch', 'minor']);
443
+ const DEFAULT_UPDATE_CHANNEL = 'patch';
444
+
445
+ /** The row's update rule, or the default for a row read before the column existed. PURE. */
446
+ function updateChannelOf(inst) {
447
+ const c = inst && typeof inst.update_channel === 'string' ? inst.update_channel : '';
448
+ return UPDATE_CHANNELS.includes(c) ? c : DEFAULT_UPDATE_CHANNEL;
449
+ }
450
+
451
+ /**
452
+ * POST /provisioning/instances/:id/update-channel — set how far this project's core
453
+ * may move (task 1004445). Body: { channel: 'pinned' | 'patch' | 'minor' }.
454
+ *
455
+ * WHY IT EXISTS. The runner's channel refusal told the owner to "change the channel
456
+ * first", and until this route there was no channel to change: every project was held to
457
+ * patch-only, so a step-up release (1.19 → 1.20) could never land through the door.
458
+ *
459
+ * It changes a RULE, never the core: nothing is queued and nothing restarts. The next
460
+ * runner tick re-resolves what is on offer under the new rule, which is why the reply
461
+ * says the offer updates on the next check rather than claiming it already has.
462
+ */
463
+ function makeUpdateChannelHandler({ provisioning, pool, parseId, validateOrRespond, log }) {
464
+ return async function updateChannelHandler(req, res) {
465
+ const id = parseId(req, res);
466
+ if (!id) return;
467
+ if (validateOrRespond(req, res, { channel: { type: 'string', maxLength: 16 } }, { strict: true })) return;
468
+ try {
469
+ const inst = await provisioning.getInstanceById(pool, id);
470
+ if (!inst) return res.fail('instance_not_found', 404);
471
+ const channel = req.body && typeof req.body.channel === 'string' ? req.body.channel.trim().toLowerCase() : '';
472
+ if (!UPDATE_CHANNELS.includes(channel)) {
473
+ return res.fail('bad_update_channel', {
474
+ status: 400,
475
+ message: `Choose one of: ${UPDATE_CHANNELS.join(', ')} — pinned moves nothing, patch takes fixes within the same release line, minor also takes the next step up.`,
476
+ details: { valid: UPDATE_CHANNELS },
477
+ });
478
+ }
479
+ const was = updateChannelOf(inst);
480
+ if (was !== channel) {
481
+ await pool.query('UPDATE provisioning_instances SET update_channel = $2, updated_at = now() WHERE id = $1', [id, channel]);
482
+ const actor = require('./core-upgrade-authz').coreUpgradeActor(req);
483
+ await provisioning.recordEvent(pool, {
484
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'update-channel-changed',
485
+ detail: `update rule changed from ${was} to ${channel}`, actor,
486
+ }).catch(() => {});
487
+ }
488
+ return res.json({
489
+ ok: true, update_channel: channel, previous: was, changed: was !== channel,
490
+ message: was === channel
491
+ ? `This project already follows the '${channel}' rule — nothing changed.`
492
+ : `This project now follows the '${channel}' rule. What it can move to updates on the next check, within a few minutes.`,
493
+ });
494
+ } catch (err) {
495
+ log.error('[provisioning] POST /provisioning/instances/:id/update-channel', err);
496
+ return failFrom(res, err, 'update_channel_failed');
497
+ }
498
+ };
499
+ }
500
+
501
+ module.exports = { UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
433
502
 
@@ -0,0 +1,24 @@
1
+ -- provisioning_029_update_channel.sql — each project's own core update rule (task 1004445).
2
+ --
3
+ -- WHY. The deploy runner bounded every project by the platform default (update-channel.js
4
+ -- DEFAULT_CHANNEL, patch-only), because nothing per-project existed to read. So a step-up
5
+ -- release (1.19.x -> 1.20.0) could never reach ANY project through the deploy door: the
6
+ -- runner refused it with "outside this project's 'patch' update channel ... Change the
7
+ -- channel first", and there was no channel anywhere to change. This column is that channel.
8
+ --
9
+ -- Values are update-channel.js VALID_CHANNELS: pinned (the door moves nothing), patch
10
+ -- (same major.minor — the old behaviour, hence the default), minor (any higher minor within
11
+ -- the major). A major bump is never admitted by any channel. The CHECK mirrors the list so a
12
+ -- hand-written UPDATE cannot store a value the runner would silently normalise away.
13
+ --
14
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
15
+
16
+ BEGIN;
17
+
18
+ ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS update_channel text NOT NULL DEFAULT 'patch';
19
+
20
+ ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_update_channel_check;
21
+ ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_update_channel_check
22
+ CHECK (update_channel IN ('pinned', 'patch', 'minor'));
23
+
24
+ COMMIT;
@@ -25,7 +25,7 @@
25
25
  const express = require('express');
26
26
  const api = require('../../../src/module-api');
27
27
  const provisioning = require('../provisioning');
28
- const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler } = require('../core-upgrade');
28
+ const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler, makeUpdateChannelHandler } = require('../core-upgrade');
29
29
  const { requireOwnProjectOrPin } = require('../core-upgrade-authz');
30
30
 
31
31
  const log = api.logger('provisioning');
@@ -86,6 +86,26 @@ module.exports = function buildCoreUpgradeRouter() {
86
86
  makeCoreUpgradePreviewHandler({ provisioning, pool: api.pool, parseId: api.parseId, log }),
87
87
  );
88
88
 
89
+ // POST /provisioning/instances/:id/update-channel — set the project's update rule
90
+ // (task 1004445). Body: { channel: "pinned" | "patch" | "minor" }.
91
+ //
92
+ // rank: `core.pin.move`, OR THE PROJECT'S OWN OWNER — the same two doors as the move,
93
+ // because the rule decides which moves the runner will perform: whoever may press
94
+ // Move may choose how far it reaches, and nobody else may. The platform's own row
95
+ // stays on the atom alone (OWNER_SHAPES excludes it), exactly as for the move.
96
+ router.post(
97
+ '/provisioning/instances/:id/update-channel',
98
+ api.requireBuilder,
99
+ requireOwnProjectOrPin({ pinGate: api.requirePermission('core.pin.move'), provisioning, pool: api.pool, log }),
100
+ makeUpdateChannelHandler({
101
+ provisioning,
102
+ pool: api.pool,
103
+ parseId: api.parseId,
104
+ validateOrRespond: api.validateOrRespond,
105
+ log,
106
+ }),
107
+ );
108
+
89
109
  // GET /provisioning/control-plane — "which row is this platform, and where does it
90
110
  // stand?" The deploy page's first call: the self row is created once by hand at
91
111
  // install time, so its id is per-install and nobody's to memorise. The atom ALONE
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.29",
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.29",
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.29",
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,17 @@
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
+ }
8243
+ ],
8244
+ "1.20.29": [
8245
+ {
8246
+ "id": "1004445",
8247
+ "text": "Each project now has its own update rule. A project can be allowed to take bigger step-up updates (like 1.19 to 1.20) instead of being stuck on small fixes only, and its owner can change the rule."
8248
+ }
8233
8249
  ]
8234
8250
  }
@@ -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',
@@ -49,6 +49,8 @@ function pollDecision(body) {
49
49
  if (body.admitted === true) return 'admitted';
50
50
  if (body.status === 'member' || body.status === 'invited') return 'admitted';
51
51
  if (body.status === 'dismissed') return 'dismissed';
52
+ // An invite past its 30 days (task 1002969): the door is shut until they invite again.
53
+ if (body.status === 'expired') return 'expired';
52
54
  return 'wait';
53
55
  }
54
56
 
@@ -302,6 +304,10 @@ async function main() {
302
304
  console.error('\n✖ your access request was declined.');
303
305
  process.exit(1);
304
306
  }
307
+ if (decision === 'expired') {
308
+ console.error('\n✖ your invite has expired — ask the project to invite you again.');
309
+ process.exit(1);
310
+ }
305
311
  if (Date.now() > admitDeadline) {
306
312
  console.error('\n✖ timed out waiting for approval — re-run `bongos login` once an Archon has approved you.');
307
313
  process.exit(1);
@@ -141,6 +141,15 @@ function targetFault(to, available, installed, channel, channelAllows) {
141
141
  return null;
142
142
  }
143
143
 
144
+ // The update channel that bounds a move for THIS instance (task 1004445): the row's own
145
+ // `update_channel`, normalised by update-channel.js so anything unknown or absent (a row
146
+ // read before migration provisioning_029) falls back to DEFAULT_CHANNEL. PURE. One helper
147
+ // for the move and the snapshot, so the button's offer and the runner's refusal cannot
148
+ // disagree about which rule applies.
149
+ function instanceChannel(inst, channelMod) {
150
+ return channelMod.normalizeChannel(inst && inst.update_channel != null ? inst.update_channel : channelMod.DEFAULT_CHANNEL);
151
+ }
152
+
144
153
  // The builder id a queued move is attributed to, from the actor the web tier recorded.
145
154
  // PURE. Two doors reach the move (ADR 0339): an Archon (`api:archon:<id>`) and, since
146
155
  // task 1004174, the project's own owner (`api:owner:<id>`). Both carry the id, because
@@ -209,15 +218,12 @@ async function coreUpgradeInstance(inst, deps, intent) {
209
218
  const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
210
219
  const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
211
220
  const installed = readInstalled(instanceDir);
212
- // THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
213
- // otherwise. provisioning_instances carries no channel column; the subscription
214
- // roster update-channel.js reads is a control-plane file, not a per-row setting. So
215
- // this bounds every instance by the same policy (DEFAULT_CHANNEL: patch-only), which
216
- // is a real bound — it refuses a prerelease or a major jump — and is the honest one
217
- // available today. When a per-instance channel lands, this line is where it is read;
218
- // writing `inst.update_channel ||` now would only have created a guard that reads as
219
- // present and can never fire, which is the trap the web-tier half already documents.
220
- const channel = channelMod.normalizeChannel(channelMod.DEFAULT_CHANNEL);
221
+ // THIS PROJECT'S OWN UPDATE RULE (task 1004445, migration provisioning_029). Before it
222
+ // every instance was bounded by DEFAULT_CHANNEL (patch-only), so a step-up release
223
+ // could reach no project through the door and the refusal's "change the channel first"
224
+ // pointed at nothing. A row from before the column (undefined) still normalises to that
225
+ // default, so an old runner schema reads exactly as it always did.
226
+ const channel = instanceChannel(inst, channelMod);
221
227
  const listed = channelMod.listAvailableVersions({ cwd: instanceDir });
222
228
  const available = (listed && listed.versions) || [];
223
229
  const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
@@ -977,7 +983,7 @@ async function snapshotCoreVersions(inst, deps) {
977
983
  // admits nothing newer — "already current" and "nothing to take" are the same
978
984
  // answer here, and both correctly leave `recommended` NULL.
979
985
  const recommended = served
980
- ? channelMod.resolveChannelTarget({ installed: served, channel: channelMod.DEFAULT_CHANNEL, available })
986
+ ? channelMod.resolveChannelTarget({ installed: served, channel: instanceChannel(inst, channelMod), available })
981
987
  : null;
982
988
  // THE TICK DOES NOT DRY-RUN. The verdict is produced on demand (preflightCoreUpgrade),
983
989
  // because a check re-run every tick for a box that has been behind for hours spends a
@@ -1021,4 +1027,4 @@ async function snapshotAllCoreVersions(deps) {
1021
1027
  return { seen };
1022
1028
  }
1023
1029
 
1024
- module.exports = { pushUpgradePin, UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
1030
+ module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
@@ -208,6 +208,10 @@ const INTEGRATION = new Set([
208
208
  // prove the CHECK actually refuses a stored fourth state, that the backfill lands on a
209
209
  // pre-migration row, and that publishableSql and projectPublishability agree.
210
210
  'project_visibility_db',
211
+ // task 1002969 (ADR 0356): the invite expiry against real SQL — the router's and the
212
+ // admission reads' statements run on Postgres, and the hub's notice restamp and
213
+ // listing. The DB-free sibling (application_lifecycle) interprets the same WHEREs.
214
+ 'invite_expiry_db',
211
215
  // task 1004408: migration platform_identity_028 deletes the old hub invite's
212
216
  // pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
213
217
  // engine runs the DO block, the numeric version compare and the one-shot guard.
@@ -25,6 +25,7 @@
25
25
 
26
26
  const crypto = require('node:crypto');
27
27
  const { pool } = require('./pool');
28
+ const { inviteLiveSql } = require('./invite-expiry');
28
29
  const seams = require('../module-seams');
29
30
 
30
31
  // withTx(fn, {pool}) — run fn(client) inside BEGIN/COMMIT, ROLLBACK+rethrow on
@@ -232,11 +233,12 @@ async function getBuilderByGithubId(githubId) {
232
233
 
233
234
  // hasInvitedAccessRequest — has an Archon approved this login (an access_requests
234
235
  // row at status='invited', case-insensitive)? The approval signal that lets a
235
- // first-time web sign-in through.
236
+ // first-time web sign-in through. An invite older than 30 days no longer counts
237
+ // (inviteLiveSql, task 1002969 / ADR 0356); an approved application never expires.
236
238
  async function hasInvitedAccessRequest(login) {
237
239
  const { rows } = await pool.query(
238
240
  `SELECT 1 FROM access_requests
239
- WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`,
241
+ WHERE lower(github_login) = lower($1) AND status = 'invited' AND ${inviteLiveSql()} LIMIT 1`,
240
242
  [login]
241
243
  );
242
244
  return rows.length > 0;
@@ -259,7 +261,8 @@ async function accessStateByLogin(login) {
259
261
  EXISTS (SELECT 1 FROM builders
260
262
  WHERE lower(github_login) = lower($1)) AS member,
261
263
  EXISTS (SELECT 1 FROM access_requests
262
- WHERE lower(github_login) = lower($1) AND status = 'invited') AS invited,
264
+ WHERE lower(github_login) = lower($1) AND status = 'invited'
265
+ AND ${inviteLiveSql()}) AS invited,
263
266
  EXISTS (SELECT 1 FROM access_requests
264
267
  WHERE lower(github_login) = lower($1) AND status = 'pending') AS pending`,
265
268
  [login]
@@ -0,0 +1,47 @@
1
+ // src/bongos/invite-expiry.js — a recruit invite's door closes 30 days after it is
2
+ // sent, decided when it is READ (task 1002969; privacy spec D4, ADR 0356).
3
+ //
4
+ // There is no sweeper and no stored "expired" state. Every read that asks "does this
5
+ // invite still open the door" computes the answer from created_at, so the sign-in
6
+ // gate, the queues and the hub notice cannot disagree about the day it closed, and a
7
+ // re-invite (a fresh row) opens a fresh door.
8
+ //
9
+ // ONLY `kind = 'invite'` ROWS EXPIRE. An approved application stays open: its
10
+ // applicant asked, and spec D4 is about invites. Reading `kind` here makes it part
11
+ // of admission for the first time (core_255 called it descriptive only), which is
12
+ // safe for one reason: the predicate can only CLOSE a door. A row that is anything
13
+ // but an invite behaves exactly as it did before this rule, so a mis-kinded row fails
14
+ // toward the old gate, never past it (ADR 0356).
15
+ 'use strict';
16
+
17
+ // The owner-signed window (spec D4). One constant, not per-instance config.
18
+ const INVITE_EXPIRY_DAYS = 30;
19
+
20
+ const ALIAS_RE = /^[a-z_][a-z0-9_]*$/;
21
+ function columnPrefix(alias) {
22
+ if (alias == null || alias === '') return '';
23
+ if (!ALIAS_RE.test(alias)) throw new Error(`invite-expiry: bad table alias ${JSON.stringify(alias)}`);
24
+ return `${alias}.`;
25
+ }
26
+
27
+ // SQL: this access_requests row is an invite whose door has closed. `alias` names the
28
+ // table in the caller's statement ('ar' → ar.kind); omit it for an unaliased table.
29
+ function inviteExpiredSql(alias) {
30
+ const a = columnPrefix(alias);
31
+ return `(${a}kind = 'invite' AND ${a}created_at <= now() - interval '${INVITE_EXPIRY_DAYS} days')`;
32
+ }
33
+
34
+ // SQL: this row has not expired. ANDed beside `status = 'invited'` wherever the
35
+ // question is "does it still admit".
36
+ function inviteLiveSql(alias) {
37
+ return `NOT ${inviteExpiredSql(alias)}`;
38
+ }
39
+
40
+ // SQL: when this row's door closes if it is an invite, else NULL (an application
41
+ // never expires). What a queue shows beside `expired`.
42
+ function inviteExpiresAtSql(alias) {
43
+ const a = columnPrefix(alias);
44
+ return `(CASE WHEN ${a}kind = 'invite' THEN ${a}created_at + interval '${INVITE_EXPIRY_DAYS} days' END)`;
45
+ }
46
+
47
+ module.exports = { INVITE_EXPIRY_DAYS, inviteExpiredSql, inviteLiveSql, inviteExpiresAtSql };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.27'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.29'; // 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