@bongos/core 1.19.790 → 1.19.792

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 (41) hide show
  1. package/.bongos-core.json +76 -41
  2. package/.claude/skills/builder-reauth/SKILL.md +5 -3
  3. package/README.md +1 -1
  4. package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +2 -0
  5. package/docs/adr/0294-cli-sessions-do-not-expire.md +56 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/copy-inventory.md +90 -90
  8. package/docs/copy-registry.json +109 -109
  9. package/docs/module-api-changelog.md +4 -0
  10. package/migrations/core_243_cli_sessions_never_expire.sql +58 -0
  11. package/modules/hall-ui/public/hall-kit.js +7 -65
  12. package/modules/hall-ui/public/hall-render.js +1 -1
  13. package/modules/hall-ui/public/hall-visual.js +85 -0
  14. package/modules/hall-ui/public/index.html +1 -0
  15. package/modules/hall-ui/public/profile.html +1 -0
  16. package/modules/hall-ui/public/profile.js +2 -2
  17. package/modules/hall-ui/public/settings.js +5 -2
  18. package/modules/hall-ui/public/studio.css +1 -1
  19. package/modules/hall-ui/public/studio.html +1 -0
  20. package/modules/hall-ui/public/studio.js +4 -3
  21. package/modules/hall-ui/public/style.css +0 -16
  22. package/modules/hall-ui/public/task.html +1 -0
  23. package/modules/hall-ui/public/task.js +10 -13
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/accessibility-replay.js +195 -0
  27. package/scripts/gds/box-auth-check.js +1 -1
  28. package/scripts/gds/cli-lib.js +2 -2
  29. package/scripts/gds/reauth.js +9 -7
  30. package/scripts/gds/run-unit-tests.js +7 -0
  31. package/src/bongos/auth-github.js +2 -0
  32. package/src/bongos/db-kernel.js +29 -11
  33. package/src/bongos/db.js +33 -24
  34. package/src/bongos/routes/auth.js +3 -2
  35. package/src/module-api.js +1 -1
  36. package/tests/accessibility_replay.mjs +201 -0
  37. package/tests/auth_session_expiry_db.mjs +183 -0
  38. package/tests/auth_session_ttl.mjs +124 -11
  39. package/tests/fixtures/accessibility-replay.json +93 -0
  40. package/tests/hall_studio_world.mjs +19 -11
  41. package/tests/task_visuals.mjs +91 -27
@@ -0,0 +1,195 @@
1
+ // scripts/gds/accessibility-replay.js — the two-profile replay regression for the
2
+ // accessibility dimension (task 1002517; clauses 5 and 8 of criterion
3
+ // `accessibility-fluency-profile`, which named this run as its OWN proof).
4
+ //
5
+ // WHAT IT PROVES, and why it is not already covered.
6
+ // tests/session_accessibility.mjs pins the scorer's contracts against synthetic
7
+ // fixtures it builds itself — a test of the arithmetic. It cannot answer the
8
+ // criterion's actual question, which is about PROSE: does the dimension reward a
9
+ // reply a newcomer can use, and does it leave an expert's reply alone? So this
10
+ // harness takes a FIXED set of eight questions a newcomer really asks, each the
11
+ // documented trigger of a surface that exists, answered twice — once in each
12
+ // register — and scores both runs.
13
+ //
14
+ // Four legs. Each one fails a different way of being wrong:
15
+ //
16
+ // 1. FLOOR the non-technical run, under the non-technical profile,
17
+ // clears FLOOR. The dimension recognises accessible prose.
18
+ // 2. NO PENALTY the engineer run, under the engineer profile, is not
19
+ // scored at all — every signal waived, score null.
20
+ // 3. DISCRIMINATION the SAME engineer prose under the NON-TECHNICAL profile
21
+ // falls BELOW FLOOR, with every detector registering hits.
22
+ // Without this leg 1 proves nothing — a scorer that returned
23
+ // 10 for everything, or three detectors that had gone blind,
24
+ // would pass leg 1 and look identical to a working one.
25
+ // 4. LENGTH `doubleRun` says the same prose twice. Chars double, hits
26
+ // double, the rate is unchanged, so the score must be
27
+ // IDENTICAL. That is the metric reading density rather than
28
+ // volume, asserted against real prose.
29
+ //
30
+ // WHERE TERSENESS PROTECTION ACTUALLY COMES FROM, since the task's "watch out"
31
+ // is a metric that punishes an expert's brevity. It is leg 2 — the WAIVER — not
32
+ // the per-1000-character rates. The rates make the score independent of how long
33
+ // an answer is at a fixed density (leg 4); they do NOT make it independent of
34
+ // density, and they should not: three undefined terms in three hundred
35
+ // characters really is denser than three in three thousand. What protects the
36
+ // expert is that their profile removes those two signals from the scoring set
37
+ // entirely. `terseVariant` — every turn cut to its first sentence, a genuinely
38
+ // shorter and thinner answer — is checked too, asserting only that answering
39
+ // more briefly never COSTS points; it is a guard, not the mechanism.
40
+ //
41
+ // One honest asymmetry this surfaced and the corpus now records: under
42
+ // `technical_level: engineer` with `toolchain_familiarity: new`, the engineer
43
+ // run still scores, because terminal-where-a-click-path-exists is waived by the
44
+ // TOOLCHAIN setting, not the technical one. "I think in code" and "I live in a
45
+ // terminal" are two different answers, and the scorer is right to keep them
46
+ // apart. tests/accessibility_replay.mjs pins that pairing so a future widening
47
+ // of the engineer waiver has to be a deliberate edit.
48
+ //
49
+ // The corpus is data — tests/fixtures/accessibility-replay.json — so adding a
50
+ // prompt is an edit to JSON, not to code.
51
+ //
52
+ // Run: node scripts/gds/accessibility-replay.js
53
+ // node scripts/gds/accessibility-replay.js --json
54
+
55
+ 'use strict';
56
+
57
+ const fs = require('node:fs');
58
+ const path = require('node:path');
59
+
60
+ const { scoreAccessibility, bandFor } = require('../../modules/sessions/accessibility.js');
61
+
62
+ const CORPUS_PATH = path.join(__dirname, '..', '..', 'tests', 'fixtures', 'accessibility-replay.json');
63
+
64
+ // FLOOR — the score a non-technical run has to clear. It is bandFor()'s
65
+ // 'workable' boundary and deliberately not a second independent number: the
66
+ // scorer already draws the line between prose a newcomer can work with and prose
67
+ // that is merely dense, and inventing a different one here would let the two
68
+ // drift. tests/accessibility_replay.mjs asserts they stay tied.
69
+ const FLOOR = 6.0;
70
+
71
+ function loadCorpus(file = CORPUS_PATH) {
72
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
73
+ }
74
+
75
+ // terseVariant — each turn cut to its first sentence: a shorter, thinner answer.
76
+ // Mechanical on purpose. A hand-written "terse version" in the corpus would be a
77
+ // version tuned until it passed; taking the first sentence of prose written for
78
+ // another purpose is a variant nobody got to tune.
79
+ function terseVariant(turns) {
80
+ return turns.map((t) => {
81
+ const m = /^[^.!?]*[.!?]/.exec(t);
82
+ return m ? m[0] : t;
83
+ });
84
+ }
85
+
86
+ // doubleRun — the same prose said twice. Every count doubles and so does the
87
+ // character total, so every rate is unchanged and the score must come back
88
+ // EXACTLY equal. This is the length-independence property at prose altitude: a
89
+ // scorer that had drifted to absolute counts would double its penalties here.
90
+ function doubleRun(turns) {
91
+ return [...turns, ...turns];
92
+ }
93
+
94
+ // asDetail — a run's replies as the per-turn digest shape scoreAccessibility
95
+ // reads. The user turns are included because a real digest has them and
96
+ // assistantProse must keep ignoring them; if it ever stopped, the prompts would
97
+ // start scoring the builder's own words and every number here would move.
98
+ function asDetail(corpus, replies, { transform } = {}) {
99
+ const detail = [];
100
+ let i = 0;
101
+ for (const { id, prompt } of corpus.prompts) {
102
+ detail.push({ i: i++, role: 'user', snippet: prompt });
103
+ const turns = replies[id] || [];
104
+ for (const snippet of transform ? transform(turns) : turns) {
105
+ detail.push({ i: i++, role: 'assistant', snippet });
106
+ }
107
+ }
108
+ return detail;
109
+ }
110
+
111
+ const TRANSFORMS = { terse: terseVariant, doubled: doubleRun };
112
+
113
+ // replay — score one named run under one profile. `profile` defaults to the
114
+ // run's own declared profile, which is the pairing the criterion asks about;
115
+ // passing another is how leg 3 reads engineer prose as a newcomer would.
116
+ function replay(corpus, runName, { profile, variant } = {}) {
117
+ const run = corpus.runs[runName];
118
+ if (!run) throw new Error(`no such run in the corpus: ${runName}`);
119
+ const transform = variant ? TRANSFORMS[variant] : undefined;
120
+ if (variant && !transform) throw new Error(`no such variant: ${variant}`);
121
+ const effective = profile || run.profile;
122
+ const result = scoreAccessibility(asDetail(corpus, run.replies, { transform }), { profile: effective });
123
+ return { run: runName, variant: variant || 'full', profile: effective, result };
124
+ }
125
+
126
+ // The partial engineer profile — thinks in code, new to the toolchain. Named
127
+ // here because it is the pairing that shows the two waivers are independent.
128
+ const ENGINEER_NEW_TO_TOOLCHAIN = Object.freeze({ technical_level: 'engineer', toolchain_familiarity: 'new' });
129
+
130
+ function rows(corpus) {
131
+ const newcomer = corpus.runs.non_technical.profile;
132
+ return [
133
+ ['non-technical prose, non-technical profile', replay(corpus, 'non_technical')],
134
+ ['engineer prose, engineer profile', replay(corpus, 'engineer')],
135
+ ['engineer prose, NON-TECHNICAL profile', replay(corpus, 'engineer', { profile: newcomer })],
136
+ ['engineer prose, engineer but new to the terminal', replay(corpus, 'engineer', { profile: ENGINEER_NEW_TO_TOOLCHAIN })],
137
+ ['non-technical prose, said twice', replay(corpus, 'non_technical', { variant: 'doubled' })],
138
+ ['non-technical prose, first sentences only', replay(corpus, 'non_technical', { variant: 'terse' })],
139
+ ];
140
+ }
141
+
142
+ function fmt(n) {
143
+ return n == null ? '—' : n.toFixed(1);
144
+ }
145
+
146
+ function formatReport(corpus) {
147
+ const lines = [`accessibility replay — ${corpus.prompts.length} fixed beginner prompts, floor ${FLOOR.toFixed(1)}`, ''];
148
+ for (const [label, r] of rows(corpus)) {
149
+ const { score, band, sampled_chars: chars, sampled_turns: turns } = r.result;
150
+ const waived = r.result.signals.filter((s) => s.waived).length;
151
+ lines.push(
152
+ ` ${label.padEnd(48)} ${fmt(score).padStart(5)} ${band.padEnd(9)} ` +
153
+ `${String(turns).padStart(3)} turns ${String(chars).padStart(5)} chars ${waived}/3 waived`
154
+ );
155
+ for (const s of r.result.signals) {
156
+ if (s.waived) continue;
157
+ if (!s.count) continue;
158
+ lines.push(
159
+ ` ${s.type.padEnd(28)} ${String(s.count).padStart(2)} hits rate ${s.rate.toFixed(2)}/1k −${s.penalty.toFixed(2)}`
160
+ );
161
+ }
162
+ }
163
+ return lines.join('\n');
164
+ }
165
+
166
+ // main — the CLI. `write` is injectable so the test can exercise both output
167
+ // paths without printing a screenful of JSON into the test log.
168
+ function main(argv = process.argv.slice(2), write = (s) => process.stdout.write(s)) {
169
+ const corpus = loadCorpus();
170
+ if (argv.includes('--json')) {
171
+ const out = { floor: FLOOR };
172
+ for (const [label, r] of rows(corpus)) out[label] = r.result;
173
+ write(`${JSON.stringify(out, null, 2)}\n`);
174
+ return 0;
175
+ }
176
+ write(`${formatReport(corpus)}\n`);
177
+ return 0;
178
+ }
179
+
180
+ module.exports = {
181
+ FLOOR,
182
+ CORPUS_PATH,
183
+ ENGINEER_NEW_TO_TOOLCHAIN,
184
+ loadCorpus,
185
+ terseVariant,
186
+ doubleRun,
187
+ asDetail,
188
+ replay,
189
+ rows,
190
+ formatReport,
191
+ bandFor,
192
+ main,
193
+ };
194
+
195
+ if (require.main === module) process.exit(main());
@@ -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 EXPIRED or revoked (CLI tokens carry a 24h idle TTL).';
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 —');
@@ -521,9 +521,9 @@ function printUnauthGuidanceAndExit() {
521
521
  }
522
522
  console.error('');
523
523
  console.error(' Possible causes:');
524
- console.error(' - Your session token expired (24h idle TTL, auto-refreshes on each request)');
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).
@@ -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 bearer tokens carry a 24h idle TTL (refresh-on-use) for
7
- // auth-token hygiene (GDS-V3 criterion C9) — a leaked or abandoned token in the
8
- // plaintext session file dies on its own instead of living forever. That's a
9
- // keeper. The friction it caused (re-auth via the slow Device Flow, terminal
10
- // codes, Gemini-key prompts) is NOT a keeper. This script is the fix:
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.
@@ -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(' (Sessions carry a 24h idle TTL and refresh on every use, so an');
137
- console.log(' actively-used session stays alive.)');
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
@@ -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 — session TTL on create/refresh. Centralized so the migration's
266
- // grace formula, the tests, and the code agree on one number.
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. Defaults expiry to NOW()+TTL when
270
- // the caller passes null/undefined (refresh-on-use keeps active sessions alive).
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 === undefined || expiresAt === null
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 + roll expiry forward,
286
- // fire-and-forget). Returns null for missing/revoked/expired. Read fresh per
287
- // request, never cached (ADR 0016).
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 = now() + ($2::int * INTERVAL '1 millisecond')
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
- // * Every NEW session (CLI or web) defaults to a 24-hour TTL. The caller may
671
- // still pass an explicit expiresAt to override (web flow uses 30 days for
672
- // the cookie's max-age, but the SERVER-SIDE row still uses 24h + roll-on-use
673
- // so a logged-out browser tab in someone's history can't be replayed weeks
674
- // later).
675
- // * Every successful lookupSession bumps BOTH last_used_at AND expires_at to
676
- // NOW() + 24h. This is the "refresh-on-use" half: an actively-used session
677
- // stays valid; an idle session expires after 24h. The bump is fire-and-
678
- // forget — it's an optimization for the next request, not a precondition
679
- // for THIS request, so we don't add request latency by awaiting it.
680
- // * Migration 035 backfilled all pre-existing rows with a 24h grace runway
681
- // so deploying the V3.R38 change didn't kick the supervisor / mid-flow
682
- // builders. With the V3.R90 TTL = 24h, the refresh window matches that
683
- // grace formula exactly — no further migration needed.
684
- // * The 1h value originally backstopped C7 (rank-change-near-instant), but
685
- // setBuilderRank already calls revokeAllSessionsForBuilder on every
686
- // demotion (see below), so the TTL is no longer load-bearing for C7. Its
687
- // remaining job is idle-session hygiene under C9 (security baseline).
688
- // 24h still kills idle sessions overnight; an active builder no longer
689
- // gets kicked out over lunch.
690
-
691
- // Session TTL on refresh / creation, in milliseconds. Centralized so the
692
- // migration's grace formula, the test suite, and the code agree on one number.
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
 
@@ -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 → createSession applies SESSION_TTL_MS (24h after V3.R90).
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({
@@ -1189,7 +1190,7 @@ module.exports = function buildAuthRouter() {
1189
1190
  token,
1190
1191
  source: 'cli', // 003_pms.sql CHECK; the app is a CLI-class bearer client
1191
1192
  userAgent: `devbox-app pair (${req.headers['user-agent'] ?? 'unknown'})`,
1192
- expiresAt: null, // createSession applies the 24h refresh-on-use TTL
1193
+ expiresAt: null, // no expiry — a CLI bearer lasts until revoked (ADR 0294)
1193
1194
  });
1194
1195
  const out = pairStore.approve((req.body || {}).pair_code, {
1195
1196
  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.790'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.792'; // 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');