@bongos/core 1.19.791 → 1.19.793
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 +48 -33
- package/.claude/skills/builder-reauth/SKILL.md +9 -7
- package/README.md +1 -1
- package/bin/bongos.js +12 -1
- package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +2 -0
- package/docs/adr/0294-cli-sessions-do-not-expire.md +56 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +31 -31
- package/docs/copy-registry.json +41 -41
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_243_cli_sessions_never_expire.sql +58 -0
- package/modules/hall-ui/public/settings.js +5 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-auth-check.js +1 -1
- package/scripts/gds/cli-lib.js +2 -2
- package/scripts/gds/paste-token.js +4 -2
- package/scripts/gds/reauth.js +11 -9
- package/scripts/gds/run-unit-tests.js +7 -0
- package/src/bongos/auth-github.js +2 -0
- package/src/bongos/db-kernel.js +29 -11
- package/src/bongos/db.js +33 -24
- package/src/bongos/routes/auth.js +13 -4
- package/src/module-api.js +1 -1
- package/tests/auth_session_expiry_db.mjs +183 -0
- package/tests/auth_session_ttl.mjs +124 -11
- package/tests/bongos_cli.mjs +28 -0
- package/tests/cli_token_reissue.mjs +29 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.793",
|
|
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",
|
|
@@ -93,7 +93,7 @@ async function probeMe(tokenInfo) {
|
|
|
93
93
|
function printReauth({ boxScoped }) {
|
|
94
94
|
const why = boxScoped
|
|
95
95
|
? 'This box holds the BOX-SCOPED bootstrap token (source=box, ADR 0053) — it can bootstrap the box but is refused (403) on claim / ship / publish.'
|
|
96
|
-
: 'This session token is
|
|
96
|
+
: 'This session token is REVOKED or otherwise rejected (CLI tokens do not expire on their own — ADR 0294).';
|
|
97
97
|
console.log('[otb] ── dev box: RE-AUTH BEFORE YOU WORK ──');
|
|
98
98
|
console.log(`[otb] ${why}`);
|
|
99
99
|
console.log('[otb] ⚠ FIRST STEP this session: get the builder onto a real CLI session BEFORE claiming or shipping —');
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -521,9 +521,9 @@ function printUnauthGuidanceAndExit() {
|
|
|
521
521
|
}
|
|
522
522
|
console.error('');
|
|
523
523
|
console.error(' Possible causes:');
|
|
524
|
-
console.error(' - Your
|
|
525
|
-
console.error(' - Your token was revoked by an Archon');
|
|
524
|
+
console.error(' - Your token was revoked — by you ("Sign out everywhere") or by an Archon');
|
|
526
525
|
console.error(' - A recent deploy applied an auth-changing migration (see deploy banner)');
|
|
526
|
+
console.error(' - This instance is older than ADR 0294 and still expires idle CLI sessions');
|
|
527
527
|
console.error('');
|
|
528
528
|
console.error(' Once re-authed, re-run whichever command you were trying.');
|
|
529
529
|
// `doctor` is checkout-only — it is not one of the public CLI's verbs (ADR 0258).
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// paste never silently un-registers a builder's Gemini key.
|
|
8
8
|
//
|
|
9
9
|
// Usage:
|
|
10
|
-
//
|
|
10
|
+
// bongos paste-token <token> # what the hall hands you (task 1003961)
|
|
11
|
+
// node scripts/gds/paste-token.js <token> # equivalent, inside a core checkout
|
|
11
12
|
//
|
|
12
13
|
// The portal returns the exact invocation as the "paste_command" field, so a
|
|
13
14
|
// builder can copy + paste a single line.
|
|
@@ -42,7 +43,8 @@ const API_BASE = ic.resolveApiBase();
|
|
|
42
43
|
function usage(extra) {
|
|
43
44
|
if (extra) console.error(extra);
|
|
44
45
|
console.error('');
|
|
45
|
-
console.error('Usage:
|
|
46
|
+
console.error('Usage: bongos paste-token <token>');
|
|
47
|
+
console.error(' (inside a core checkout: node scripts/gds/paste-token.js <token>)');
|
|
46
48
|
console.error('');
|
|
47
49
|
console.error('Get a token on your Settings page: ' + API_BASE + '/builders/settings');
|
|
48
50
|
console.error('— under "CLI Access" click "Re-issue CLI token" (you must be signed in).');
|
package/scripts/gds/reauth.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The fast, UI-first path to refresh an expired/invalid GDS CLI session.
|
|
5
5
|
//
|
|
6
|
-
// Why this exists: CLI
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// Why this exists: a CLI session can stop working — revoked from the hall's
|
|
7
|
+
// "Sign out everywhere", swept by a demotion, or invalidated by an auth-changing
|
|
8
|
+
// deploy. (It no longer stops working from mere disuse: ADR 0294 removed the idle
|
|
9
|
+
// expiry, because the timer only ever signed out the legitimate holder while
|
|
10
|
+
// refresh-on-use kept a thief's copy alive.) Whatever killed it, the friction of
|
|
11
|
+
// getting back in — the slow Device Flow, terminal codes, Gemini-key prompts — is
|
|
12
|
+
// not something to keep. This script is the fix:
|
|
11
13
|
//
|
|
12
14
|
// 1. Detect — is the current session still valid? (GET /api/gds/me)
|
|
13
15
|
// valid → print who you are + exit 0. Nothing to do.
|
|
@@ -15,7 +17,7 @@
|
|
|
15
17
|
// 2. Guide — open the builder's Settings page in the default browser (where
|
|
16
18
|
// the builder is already signed in via the httpOnly cookie) and print the
|
|
17
19
|
// exact click-path: Settings → CLI Access → Re-issue CLI token → Copy.
|
|
18
|
-
// The page hands back a one-liner: `
|
|
20
|
+
// The page hands back a one-liner: `bongos paste-token
|
|
19
21
|
// <token>`. The builder pastes that one line back into Claude; the
|
|
20
22
|
// /builder-reauth skill runs it (paste-token.js verifies + writes safely).
|
|
21
23
|
//
|
|
@@ -94,7 +96,7 @@ function printGuidance({ opened }) {
|
|
|
94
96
|
console.log(" you're already signed in there via your browser cookie)");
|
|
95
97
|
console.log(' 2. Under the "CLI Access" section, click "Re-issue CLI token".');
|
|
96
98
|
console.log(' 3. Click "Copy" — it copies a one-line command that looks like:');
|
|
97
|
-
console.log('
|
|
99
|
+
console.log(' bongos paste-token <token>');
|
|
98
100
|
console.log(' 4. Paste that one line back to Claude. It will run it for you,');
|
|
99
101
|
console.log(' verify the token, and write your new session.');
|
|
100
102
|
console.log('');
|
|
@@ -133,8 +135,8 @@ async function main() {
|
|
|
133
135
|
const b = (res.data && res.data.builder) || {};
|
|
134
136
|
console.log(`Your GDS CLI session is valid — no re-auth needed.`);
|
|
135
137
|
console.log(` Signed in as ${b.github_login || '?'}${b.rank ? ` (rank: ${b.rank})` : ''}.`);
|
|
136
|
-
console.log(' (
|
|
137
|
-
console.log('
|
|
138
|
+
console.log(' (CLI sessions do not expire — they last until revoked. Kill one from');
|
|
139
|
+
console.log(' Settings → Active sessions, or "Sign out everywhere".)');
|
|
138
140
|
process.exit(0);
|
|
139
141
|
}
|
|
140
142
|
|
|
@@ -145,6 +145,13 @@ const INTEGRATION = new Set([
|
|
|
145
145
|
// prove the CHECK actually refuses a stored fourth state, that the backfill lands on a
|
|
146
146
|
// pre-migration row, and that publishableSql and projectPublishability agree.
|
|
147
147
|
'project_visibility_db',
|
|
148
|
+
// task 1003956 (ADR 0294): that CLI sessions really never expire. The DB-free
|
|
149
|
+
// sibling (auth_session_ttl) pins the constant, createSession's value against a
|
|
150
|
+
// stub pool, and the TEXT of the refresh statement — but a regex over source
|
|
151
|
+
// proves the characters are there, not that Postgres does what they mean. Only a
|
|
152
|
+
// real engine can show the CASE guard leaving a NULL alone on an actual use,
|
|
153
|
+
// which is exactly where the removed timeout would come back.
|
|
154
|
+
'auth_session_expiry_db',
|
|
148
155
|
'scouting_rollup_fanout', 'session_records',
|
|
149
156
|
// task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
|
|
150
157
|
// SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
|
|
@@ -218,6 +218,8 @@ async function pollDeviceFlow(deviceCode, userAgent) {
|
|
|
218
218
|
source: 'cli',
|
|
219
219
|
userAgent: userAgent ?? null,
|
|
220
220
|
// CLI tokens have no expiry (manually revocable). Web tokens expire.
|
|
221
|
+
// True again as of ADR 0294: migration 035 made this line a lie for two years
|
|
222
|
+
// by defaulting a null to a TTL, and removing that default restored it.
|
|
221
223
|
expiresAt: null,
|
|
222
224
|
});
|
|
223
225
|
// V3.R75 (#294): tick the onboarding state machine. Best-effort — auth
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -262,16 +262,25 @@ async function accessStateByLogin(login) {
|
|
|
262
262
|
|
|
263
263
|
// --- Auth sessions (kernel: the builder_sessions token store auth.js drives) ----
|
|
264
264
|
|
|
265
|
-
// SESSION_TTL_MS —
|
|
266
|
-
//
|
|
265
|
+
// SESSION_TTL_MS — the roll-forward window for a session that HAS an expiry.
|
|
266
|
+
// It is no longer a default: a caller that names no expiry gets none (ADR 0294).
|
|
267
|
+
// What still reads it is refresh-on-use below, which rolls an already-dated row
|
|
268
|
+
// (the web flow's) forward on each request. Centralized so the tests and the
|
|
269
|
+
// code agree on one number.
|
|
267
270
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
268
271
|
|
|
269
|
-
// createSession — mint a builder_sessions row.
|
|
270
|
-
//
|
|
272
|
+
// createSession — mint a builder_sessions row.
|
|
273
|
+
//
|
|
274
|
+
// A null/undefined expiresAt means NO EXPIRY (expires_at NULL), which
|
|
275
|
+
// lookupSession honours as never-expires. That is the CLI's contract (ADR 0294,
|
|
276
|
+
// restoring 003_pms.sql's original one): a `bongos` sign-in lasts until it is
|
|
277
|
+
// revoked, because an idle expiry only ever logged out the legitimate holder —
|
|
278
|
+
// the remedy for a leaked bearer is the own-scoped revoke of ADR 0206, which is
|
|
279
|
+
// immediate where a timer is not. Every caller that passes null is source='cli'.
|
|
280
|
+
//
|
|
281
|
+
// Web callers pass an explicit date (30 days) and are untouched by this default.
|
|
271
282
|
async function createSession({ builderId, token, source, userAgent, expiresAt }) {
|
|
272
|
-
const effectiveExpiresAt = expiresAt
|
|
273
|
-
? new Date(Date.now() + SESSION_TTL_MS)
|
|
274
|
-
: expiresAt;
|
|
283
|
+
const effectiveExpiresAt = expiresAt ?? null;
|
|
275
284
|
const { rows } = await pool.query(
|
|
276
285
|
`INSERT INTO builder_sessions (builder_id, token, source, user_agent, expires_at)
|
|
277
286
|
VALUES ($1, $2, $3, $4, $5)
|
|
@@ -282,9 +291,10 @@ async function createSession({ builderId, token, source, userAgent, expiresAt })
|
|
|
282
291
|
}
|
|
283
292
|
|
|
284
293
|
// lookupSession — resolve a live (unrevoked, unexpired) session token to its
|
|
285
|
-
// builder identity, and refresh-on-use (bump last_used_at
|
|
286
|
-
// fire-and-forget). Returns null for
|
|
287
|
-
//
|
|
294
|
+
// builder identity, and refresh-on-use (bump last_used_at, and roll expiry
|
|
295
|
+
// forward for a row that has one, fire-and-forget). Returns null for
|
|
296
|
+
// missing/revoked/expired; a row with expires_at NULL never expires. Read fresh
|
|
297
|
+
// per request, never cached (ADR 0016).
|
|
288
298
|
//
|
|
289
299
|
// The governance FOLD (task 1002556): when the governance module is enabled, the
|
|
290
300
|
// same round-trip also aggregates the builder's raw permission grant keys, so
|
|
@@ -335,11 +345,19 @@ async function lookupSession(token) {
|
|
|
335
345
|
if (!row) return null;
|
|
336
346
|
if (row.revoked_at) return null;
|
|
337
347
|
if (row.expires_at && row.expires_at < new Date()) return null;
|
|
348
|
+
// Refresh-on-use. last_used_at always moves — the own-sessions list (ADR 0206)
|
|
349
|
+
// is how a person spots a session they don't recognise, and that reading is the
|
|
350
|
+
// whole remedy now that CLI rows never lapse. expires_at only rolls forward for
|
|
351
|
+
// a row that HAS one: the CASE is what stops a use from minting an expiry on a
|
|
352
|
+
// never-expires row and quietly reintroducing the timeout per ADR 0294.
|
|
338
353
|
pool
|
|
339
354
|
.query(
|
|
340
355
|
`UPDATE builder_sessions
|
|
341
356
|
SET last_used_at = now(),
|
|
342
|
-
expires_at =
|
|
357
|
+
expires_at = CASE
|
|
358
|
+
WHEN expires_at IS NULL THEN NULL
|
|
359
|
+
ELSE now() + ($2::int * INTERVAL '1 millisecond')
|
|
360
|
+
END
|
|
343
361
|
WHERE id = $1`,
|
|
344
362
|
[row.session_id, SESSION_TTL_MS]
|
|
345
363
|
)
|
package/src/bongos/db.js
CHANGED
|
@@ -664,32 +664,41 @@ async function reactivateBuilder({ builderId, actorBuilderId }) {
|
|
|
664
664
|
// Sessions (auth tokens)
|
|
665
665
|
// -------------------------------------------------------------------------
|
|
666
666
|
//
|
|
667
|
-
// V3.R38 / task #259 — short-lived sessions with refresh-on-use.
|
|
667
|
+
// V3.R38 / task #259 — short-lived sessions with refresh-on-use (1h).
|
|
668
668
|
// V3.R90 / task #400 (2026-05-29) — TTL widened from 1h to 24h.
|
|
669
|
+
// ADR 0294 / task 1003956 (2026-09-16) — CLI sessions lose their expiry entirely.
|
|
669
670
|
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
// *
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
//
|
|
692
|
-
//
|
|
671
|
+
// The arc is one long argument the idle timer kept losing. 1h kicked builders
|
|
672
|
+
// out over lunch; 24h kicked them out over a weekend. Each round shortened the
|
|
673
|
+
// nuisance without touching the threat, because the timer never bounded a leaked
|
|
674
|
+
// token in the first place — refresh-on-use means the thief's own traffic keeps
|
|
675
|
+
// it alive, and only the legitimate holder, who steps away, is ever signed out.
|
|
676
|
+
// So the expiry is gone from CLI sessions rather than widened a third time.
|
|
677
|
+
//
|
|
678
|
+
// * A NEW session whose caller names no expiry gets NONE — expires_at NULL,
|
|
679
|
+
// which lookupSession treats as never-expires. Every such caller is
|
|
680
|
+
// source='cli'. The WEB flow passes an explicit 30 days and is unchanged.
|
|
681
|
+
// * Every successful lookupSession bumps last_used_at, and rolls expires_at
|
|
682
|
+
// forward to NOW()+24h ONLY for a row that already has one. A NULL stays
|
|
683
|
+
// NULL — otherwise the first request after sign-in would mint the very
|
|
684
|
+
// timeout this removed. The bump is fire-and-forget: an optimization for the
|
|
685
|
+
// next request, not a precondition for THIS one, so it isn't awaited.
|
|
686
|
+
// * Migration core_243 cleared expires_at on live source='cli' rows, so the
|
|
687
|
+
// change reached sessions that already existed instead of making every
|
|
688
|
+
// builder re-auth one last time. (Migration 035 had backfilled a 24h grace
|
|
689
|
+
// in the other direction, for the same reason: don't sign out the session
|
|
690
|
+
// deploying the migration.)
|
|
691
|
+
// * Nothing here is load-bearing for C7 (rank-change-near-instant):
|
|
692
|
+
// setBuilderRank calls revokeAllSessionsForBuilder on every demotion (see
|
|
693
|
+
// below) and requireBuilder denies a deactivated builder on the hot path, so
|
|
694
|
+
// authority drift is closed by revocation, not by waiting out a clock.
|
|
695
|
+
// * The remedy for a leaked bearer is the own-scoped revoke of ADR 0206 —
|
|
696
|
+
// GET/POST /me/sessions, and "Sign out everywhere" in the hall — which kills
|
|
697
|
+
// it now rather than within a day. That is why the timer is affordable to
|
|
698
|
+
// lose: the thing it was nominally for has a faster answer that already ships.
|
|
699
|
+
|
|
700
|
+
// The roll-forward window for a session that HAS an expiry, in milliseconds.
|
|
701
|
+
// Centralized so the test suite and the code agree on one number.
|
|
693
702
|
|
|
694
703
|
// SESSION_TTL_MS — carved to ./db-kernel (ADR 0102); imported + re-exported above.
|
|
695
704
|
|
|
@@ -1025,7 +1025,7 @@ module.exports = function buildAuthRouter() {
|
|
|
1025
1025
|
// the requesting builder, gated on a WEB COOKIE session (not a bearer). The
|
|
1026
1026
|
// browser already proves identity via the gds_session cookie; this endpoint
|
|
1027
1027
|
// mints a separate cli-source session row the builder can paste into
|
|
1028
|
-
// ~/.config/otb/gds-session.json via
|
|
1028
|
+
// ~/.config/otb/gds-session.json via `bongos paste-token`. Replaces
|
|
1029
1029
|
// the ~30-60s Device Flow when the CLI bearer has expired.
|
|
1030
1030
|
//
|
|
1031
1031
|
// rank: any-builder (own identity only) — no rank gate.
|
|
@@ -1049,7 +1049,8 @@ module.exports = function buildAuthRouter() {
|
|
|
1049
1049
|
// audit_log distinguishes portal-reissue from Device Flow tokens.
|
|
1050
1050
|
source: 'cli',
|
|
1051
1051
|
userAgent: req.headers['user-agent'] ?? null,
|
|
1052
|
-
// expiresAt: null →
|
|
1052
|
+
// expiresAt: null → NO expiry: a CLI bearer lasts until revoked (ADR 0294).
|
|
1053
|
+
// The builder kills it from this same Settings page, under Active sessions.
|
|
1053
1054
|
expiresAt: null,
|
|
1054
1055
|
});
|
|
1055
1056
|
res.json({
|
|
@@ -1057,7 +1058,15 @@ module.exports = function buildAuthRouter() {
|
|
|
1057
1058
|
token,
|
|
1058
1059
|
expires_at: row.expires_at,
|
|
1059
1060
|
builder_login: req.builder.github_login,
|
|
1060
|
-
|
|
1061
|
+
// A VERB, never a path (task 1003961). This string is composed HERE and run
|
|
1062
|
+
// THERE — on an instance, where the core is an installed dependency and
|
|
1063
|
+
// `scripts/gds/` does not exist, so the previous
|
|
1064
|
+
// `node scripts/gds/paste-token.js <token>` named a file the builder did not
|
|
1065
|
+
// have and the copy button handed out a command that could not run. `bongos`
|
|
1066
|
+
// is on PATH in any installed instance, so the verb form is layout- and
|
|
1067
|
+
// directory-independent. Same discipline as configuredOAuthOrigin() above:
|
|
1068
|
+
// nothing about the RECIPIENT's filesystem may be assumed here.
|
|
1069
|
+
paste_command: `bongos paste-token ${token}`,
|
|
1061
1070
|
});
|
|
1062
1071
|
} catch (err) {
|
|
1063
1072
|
log.error('[gds] POST /auth/cli-token/issue', err);
|
|
@@ -1189,7 +1198,7 @@ module.exports = function buildAuthRouter() {
|
|
|
1189
1198
|
token,
|
|
1190
1199
|
source: 'cli', // 003_pms.sql CHECK; the app is a CLI-class bearer client
|
|
1191
1200
|
userAgent: `devbox-app pair (${req.headers['user-agent'] ?? 'unknown'})`,
|
|
1192
|
-
expiresAt: null, //
|
|
1201
|
+
expiresAt: null, // no expiry — a CLI bearer lasts until revoked (ADR 0294)
|
|
1193
1202
|
});
|
|
1194
1203
|
const out = pairStore.approve((req.body || {}).pair_code, {
|
|
1195
1204
|
token,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.793'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// tests/auth_session_expiry_db.mjs — ADR 0294, task 1003956 (real DB).
|
|
2
|
+
//
|
|
3
|
+
// The DB-free sibling (auth_session_ttl.mjs) pins the CONSTANT, createSession's
|
|
4
|
+
// choice of value against a stub pool, and the TEXT of the refresh statement and
|
|
5
|
+
// the migration. That last part is the weak half, and the grader on this task was
|
|
6
|
+
// right to say so — though not for the reason first assumed. Most edits to the
|
|
7
|
+
// guard's WORDING do break its regex, so the sibling is tighter than it looks.
|
|
8
|
+
// What a regex cannot see is whether Postgres ACCEPTS the statement at all.
|
|
9
|
+
//
|
|
10
|
+
// Measured, by deleting the CASE's `END` and running both files: the sibling
|
|
11
|
+
// reported 20 passed, 0 failed on SQL that fails to parse — SQL that would throw
|
|
12
|
+
// on every authenticated request in production. This file caught it.
|
|
13
|
+
//
|
|
14
|
+
// Worth knowing WHICH assertions caught it, because it is not the obvious one.
|
|
15
|
+
// The refresh is fire-and-forget with a `.catch()`, so a broken UPDATE fails
|
|
16
|
+
// SILENTLY: expires_at stays NULL and the never-expires assertion still passes.
|
|
17
|
+
// What failed was `last_used_at` never moving and the web row never rolling. Those
|
|
18
|
+
// two are not padding — on this statement they are the only live smoke alarm.
|
|
19
|
+
//
|
|
20
|
+
// So this file proves what only a real transaction engine can:
|
|
21
|
+
//
|
|
22
|
+
// 1. A CLI session is CREATED with no expiry, and a web session's explicit
|
|
23
|
+
// 30 days survives untouched — the two halves of the default change.
|
|
24
|
+
// 2. THE REGRESSION GUARD EXECUTES — after a real lookupSession, a CLI row's
|
|
25
|
+
// expires_at is still NULL. Without the CASE this is where the timeout
|
|
26
|
+
// comes back, on the first request after sign-in.
|
|
27
|
+
// 3. A web row's expiry STILL rolls forward, so the guard narrowed the write
|
|
28
|
+
// to NULL rows rather than disabling refresh-on-use for everyone.
|
|
29
|
+
// 4. Expiry is still HONOURED where present, and revocation still wins — the
|
|
30
|
+
// two things a "sessions never expire" change could plausibly have broken.
|
|
31
|
+
// 5. THE MIGRATION, run for real: it clears a pre-existing CLI expiry, leaves
|
|
32
|
+
// web and revoked rows alone, and is idempotent.
|
|
33
|
+
// 6. The point of the whole task — a session idle far past 24h authenticates.
|
|
34
|
+
//
|
|
35
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in
|
|
36
|
+
// run-unit-tests.js's INTEGRATION set so the default DB-free lane never runs it.
|
|
37
|
+
//
|
|
38
|
+
// Run: DATABASE_URL=... node tests/auth_session_expiry_db.mjs
|
|
39
|
+
|
|
40
|
+
import { createRequire } from 'node:module';
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
import { dirname, join } from 'node:path';
|
|
44
|
+
import { strict as assert } from 'node:assert';
|
|
45
|
+
|
|
46
|
+
const require = createRequire(import.meta.url);
|
|
47
|
+
const { Pool } = require('pg');
|
|
48
|
+
|
|
49
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const MIGRATION = join(here, '..', 'migrations', 'core_243_cli_sessions_never_expire.sql');
|
|
51
|
+
|
|
52
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
53
|
+
try {
|
|
54
|
+
await pool.query('SELECT 1');
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.log(`auth_session_expiry_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
57
|
+
await pool.end().catch(() => {});
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// An isolated schema, so this never touches a real builders/builder_sessions.
|
|
62
|
+
const SCHEMA = `ttl_probe_${process.pid}`;
|
|
63
|
+
await pool.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
64
|
+
await pool.query(`SET search_path TO ${SCHEMA}`);
|
|
65
|
+
await pool.query(`
|
|
66
|
+
CREATE TABLE builders (id bigserial PRIMARY KEY, github_id text, github_login text,
|
|
67
|
+
display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
|
|
68
|
+
status text DEFAULT 'active', system_role text, anthropic_user_id text);
|
|
69
|
+
CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
|
|
70
|
+
token text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
|
|
71
|
+
last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
|
|
72
|
+
INSERT INTO builders (github_login) VALUES ('probe');
|
|
73
|
+
`);
|
|
74
|
+
|
|
75
|
+
// Run the REAL kernel against this schema: stub ./pool in the require cache so
|
|
76
|
+
// db-kernel binds our client, exactly as the production code binds the singleton.
|
|
77
|
+
const client = await pool.connect();
|
|
78
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
79
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
80
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
|
|
81
|
+
const kernel = require('../src/bongos/db-kernel.js');
|
|
82
|
+
|
|
83
|
+
let passed = 0, failed = 0;
|
|
84
|
+
const t = async (name, fn) => {
|
|
85
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
86
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
87
|
+
};
|
|
88
|
+
const row = async (sql, p) => (await client.query(sql, p)).rows[0];
|
|
89
|
+
// refresh-on-use is fire-and-forget: let the UPDATE land before reading it back.
|
|
90
|
+
const settle = () => new Promise((r) => setTimeout(r, 300));
|
|
91
|
+
|
|
92
|
+
const thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
93
|
+
|
|
94
|
+
await t('a CLI session is created with NO expiry', async () => {
|
|
95
|
+
const s = await kernel.createSession({ builderId: '1', token: 'cli-tok', source: 'cli', userAgent: 'probe', expiresAt: null });
|
|
96
|
+
assert.equal(s.expires_at, null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await t("a web session's explicit 30-day expiry is stored as given", async () => {
|
|
100
|
+
const s = await kernel.createSession({ builderId: '1', token: 'web-tok', source: 'web', userAgent: 'probe', expiresAt: thirtyDays });
|
|
101
|
+
assert.equal(s.expires_at.getTime(), thirtyDays.getTime());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('lookupSession resolves a CLI bearer to its builder', async () => {
|
|
105
|
+
const got = await kernel.lookupSession('cli-tok');
|
|
106
|
+
assert.equal(got && got.github_login, 'probe');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// THE GUARD. This is the assertion the source-matching sibling cannot make.
|
|
110
|
+
await t('after a real use, a CLI session STILL has no expiry (the CASE guard)', async () => {
|
|
111
|
+
await kernel.lookupSession('cli-tok');
|
|
112
|
+
await settle();
|
|
113
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='cli-tok'`)).expires_at, null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('refresh-on-use still bumps last_used_at on that same row', async () => {
|
|
117
|
+
const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
|
|
118
|
+
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token='cli-tok'`);
|
|
119
|
+
await kernel.lookupSession('cli-tok');
|
|
120
|
+
await settle();
|
|
121
|
+
const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
|
|
122
|
+
assert.ok(after > new Date(Date.now() - 60_000), `last_used_at did not move: ${after} (was ${before})`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await t('a web session still rolls its expiry forward (refresh-on-use intact)', async () => {
|
|
126
|
+
await kernel.lookupSession('web-tok');
|
|
127
|
+
await settle();
|
|
128
|
+
const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token='web-tok'`)).expires_at;
|
|
129
|
+
assert.ok(got !== null && got < thirtyDays, `expected a rolled-forward date below 30d, got ${got}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await t('an expired session is still REFUSED (expiry honoured where present)', async () => {
|
|
133
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at)
|
|
134
|
+
VALUES (1,'dead-tok','web', now() - interval '1 hour')`);
|
|
135
|
+
assert.equal(await kernel.lookupSession('dead-tok'), null);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await t('a revoked CLI session is still REFUSED', async () => {
|
|
139
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token, source, revoked_at)
|
|
140
|
+
VALUES (1,'revoked-tok','cli', now())`);
|
|
141
|
+
assert.equal(await kernel.lookupSession('revoked-tok'), null);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The migration, against rows shaped like the ones live instances actually hold.
|
|
145
|
+
const migrationSql = readFileSync(MIGRATION, 'utf8');
|
|
146
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at) VALUES
|
|
147
|
+
(1,'old-cli','cli', now() + interval '24 hours'),
|
|
148
|
+
(1,'old-web','web', now() + interval '30 days'),
|
|
149
|
+
(1,'old-revoked','cli', now() + interval '24 hours')`);
|
|
150
|
+
await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token='old-revoked'`);
|
|
151
|
+
await client.query(migrationSql);
|
|
152
|
+
|
|
153
|
+
await t('core_243 clears a pre-existing CLI expiry', async () => {
|
|
154
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await t('core_243 leaves a WEB row dated — the source filter is its safety property', async () => {
|
|
158
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await t('core_243 leaves a revoked row alone (it is the audit trail)', async () => {
|
|
162
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-revoked'`)).expires_at, null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await t('core_243 is idempotent — a re-run changes nothing', async () => {
|
|
166
|
+
await client.query(migrationSql);
|
|
167
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
|
|
168
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The whole point of the task, stated as a test.
|
|
172
|
+
await t('a CLI session idle for 21 DAYS still authenticates', async () => {
|
|
173
|
+
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token='cli-tok'`);
|
|
174
|
+
assert.notEqual(await kernel.lookupSession('cli-tok'), null);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await client.query(`SET search_path TO public`);
|
|
178
|
+
client.release();
|
|
179
|
+
await pool.query(`DROP SCHEMA ${SCHEMA} CASCADE`);
|
|
180
|
+
await pool.end();
|
|
181
|
+
|
|
182
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
183
|
+
process.exit(failed === 0 ? 0 : 1);
|