@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.
- package/.bongos-core.json +75 -45
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -1
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
- package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +51 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +20 -19
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-readings.json +9 -9
- package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
- package/modules/hall-ui/public/watch.js +9 -3
- package/modules/onboarding/routes/access-requests.js +43 -20
- package/modules/platform-identity/platform-identity.js +12 -8
- package/modules/platform-identity/tests/platform-identity.mjs +4 -2
- package/modules/provisioning/core-upgrade.js +70 -1
- package/modules/provisioning/migrations/provisioning_029_update_channel.sql +24 -0
- package/modules/provisioning/routes/core-upgrade.js +21 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/login.js +6 -0
- package/scripts/gds/provision-core-upgrade.js +17 -11
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/db-kernel.js +6 -3
- package/src/bongos/invite-expiry.js +47 -0
- package/src/module-api.js +10 -1
- package/tests/access_requests_invite.mjs +2 -1
- package/tests/application_lifecycle.mjs +164 -5
- package/tests/bongos_login.mjs +2 -0
- package/tests/core_upgrade_door.mjs +80 -1
- package/tests/core_upgrade_runner.mjs +54 -1
- package/tests/hub_invite_notices.mjs +5 -2
- package/tests/invite_expiry.mjs +37 -0
- package/tests/invite_expiry_db.mjs +196 -0
- package/tests/module_api.mjs +1 -0
- 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
|
-
|
|
227
|
-
|
|
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 =
|
|
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
|
|
301
|
+
// Prefer an invite that still admits, else the most recent request.
|
|
298
302
|
const ar = await pool.query(
|
|
299
|
-
`SELECT status
|
|
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
|
|
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
|
-
|
|
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
|
|
570
|
-
|
|
571
|
-
FROM
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
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).
|
|
533
|
-
//
|
|
534
|
-
//
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
}
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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',
|
package/scripts/gds/login.js
CHANGED
|
@@ -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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
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
|
|
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.
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -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'
|
|
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.
|
|
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
|
-
|
|
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
|
|