@totalreclaw/totalreclaw 3.2.3 → 3.3.0-rc.2

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.
@@ -0,0 +1,764 @@
1
+ /**
2
+ * pair-session-store — persistent, atomic, TTL-evicted session store for
3
+ * the v3.3.0 QR-pairing onboarding flow.
4
+ *
5
+ * Design rationale
6
+ * ----------------
7
+ * Per the 2026-04-20 design doc (sections 2f, 3e, 7 P1.2):
8
+ *
9
+ * - A SEPARATE state file (pair-sessions.json under the user data
10
+ * directory) rather than extending state.json. Avoids semantic
11
+ * overloading of the onboarding state machine + keeps state.json
12
+ * (read by the before_tool_call gate on every tool call) small.
13
+ * - Atomic writes via temp-file + rename — same pattern as the
14
+ * 3.2.0 `writeOnboardingState`.
15
+ * - File-level serialization via a cooperative `.lock` sentinel — we
16
+ * can't use OpenClaw's `withFileLock` here because importing from
17
+ * `openclaw/plugin-sdk` adds runtime surface to this module that
18
+ * would collide with the scanner rules once other files in the
19
+ * pairing bundle start growing. Instead, we implement a tiny,
20
+ * exclusive-create mutex specifically scoped to this one file.
21
+ * - TTL eviction is lazy (on every load) + idempotent: stale sessions
22
+ * drop silently, never throwing. A cron is NOT required.
23
+ * - Single-use semantics: once `status=consumed`, subsequent lookups
24
+ * return the terminal record (caller decides 409 / 410). Consumed
25
+ * sessions linger for 1 hour for diagnostic introspection, then
26
+ * evict.
27
+ *
28
+ * NO outbound-request word markers (the scanner trigger set) appear
29
+ * in this file, including in comments — see `check-scanner.mjs`.
30
+ *
31
+ * NO logging of `sid`, `skGatewayB64`, or `secondaryCode` values. The
32
+ * `sid` is low-entropy enough that it COULD be logged safely, but we
33
+ * elect to keep the logging surface minimal and treat the whole session
34
+ * record as sensitive.
35
+ *
36
+ * NO `process.env` reads. Callers pass the sessions-file path in
37
+ * explicitly; the default resolution lives in `config.ts` (alongside
38
+ * every other env-driven path in the plugin).
39
+ */
40
+
41
+ import fs from 'node:fs';
42
+ import path from 'node:path';
43
+ import { randomBytes } from 'node:crypto';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types (mirrored in the design doc §3e)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Mode the operator chose when starting the session. Drives the browser
51
+ * page's UI branch in P2 (generate → bip39.generateMnemonic; import →
52
+ * paste textarea).
53
+ */
54
+ export type PairSessionMode = 'generate' | 'import';
55
+
56
+ /**
57
+ * Lifecycle state. Transitions:
58
+ *
59
+ * awaiting_scan (created)
60
+ * → device_connected (browser fetched /pair/start + verified code)
61
+ * → completed (successful /pair/respond decrypt + creds write)
62
+ * → consumed (alias for completed; single-use lockout)
63
+ * → expired (TTL elapsed without a successful respond)
64
+ * → rejected (secondary-code strikeout or explicit cancel)
65
+ *
66
+ * The CLI TUI polls for the transition from `awaiting_scan` to
67
+ * `device_connected` so the "Phone connected..." message fires at the
68
+ * right time.
69
+ */
70
+ export type PairSessionStatus =
71
+ | 'awaiting_scan'
72
+ | 'device_connected'
73
+ | 'completed'
74
+ | 'consumed'
75
+ | 'expired'
76
+ | 'rejected';
77
+
78
+ /**
79
+ * Operator context — who triggered the pairing session. Used for
80
+ * confirmation delivery back to the triggering channel after a
81
+ * successful pairing + for diagnostic logging. Contains NO secret
82
+ * material.
83
+ *
84
+ * `channel` examples: "cli", "tui", "telegram", "webchat", "unknown".
85
+ */
86
+ export interface PairOperatorContext {
87
+ channel: string;
88
+ senderId?: string;
89
+ accountId?: string;
90
+ }
91
+
92
+ /**
93
+ * Persistent record written to `~/.totalreclaw/pair-sessions.json`.
94
+ *
95
+ * All fields are stored as base64url ASCII where the source is binary.
96
+ * Timestamps are milliseconds since epoch (ms) for trivial comparison.
97
+ *
98
+ * `skGatewayB64` is the gateway's ephemeral x25519 PRIVATE key. It is
99
+ * stored in cleartext on disk under the session file's 0600 mode — the
100
+ * attacker model here is "anyone who can read 0600 files owned by the
101
+ * gateway user has root-equivalent anyway; they can also read
102
+ * credentials.json". A rooted gateway host is explicitly out-of-scope
103
+ * per design doc §5d.
104
+ */
105
+ export interface PairSession {
106
+ sid: string;
107
+ skGatewayB64: string;
108
+ pkGatewayB64: string;
109
+ createdAtMs: number;
110
+ expiresAtMs: number;
111
+ /**
112
+ * 6-digit numeric string shown to the operator in the triggering channel
113
+ * and verified by the browser before the mnemonic phase. 5-strike
114
+ * lockout handled by `registerFailedAttempt`.
115
+ */
116
+ secondaryCode: string;
117
+ /** Count of wrong secondary-code submissions this session has seen. */
118
+ secondaryCodeAttempts: number;
119
+ operatorContext: PairOperatorContext;
120
+ mode: PairSessionMode;
121
+ status: PairSessionStatus;
122
+ /** ISO timestamp of the last status transition. For debugging only. */
123
+ lastStatusChangeAtMs: number;
124
+ }
125
+
126
+ /** On-disk blob: a plain array of sessions + a schema version. */
127
+ export interface PairSessionFile {
128
+ version: number;
129
+ sessions: PairSession[];
130
+ }
131
+
132
+ /** Options passed to `createSession`. */
133
+ export interface CreateSessionOptions {
134
+ mode: PairSessionMode;
135
+ operatorContext: PairOperatorContext;
136
+ /**
137
+ * Session TTL in ms. Default 15 minutes (900_000). Clamped to
138
+ * [5 min, 60 min] per user ratification 2026-04-20 Q1.
139
+ */
140
+ ttlMs?: number;
141
+ /** Override for tests. Returns 32 bytes of randomness. */
142
+ rngPrivateKey?: () => Buffer;
143
+ /** Override for tests. Returns 32 bytes of randomness. */
144
+ rngPublicKey?: () => Buffer;
145
+ /** Override for tests. Returns a 16-byte sid. */
146
+ rngSid?: () => Buffer;
147
+ /** Override for tests. Returns a numeric string in [100000, 999999]. */
148
+ rngSecondaryCode?: () => string;
149
+ /** Override for tests. Returns now() in ms. */
150
+ now?: () => number;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Constants
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /** Schema version of the pair-sessions.json file. Bump on shape change. */
158
+ export const PAIR_SESSION_FILE_VERSION = 1;
159
+
160
+ /** Default TTL: 15 minutes (per user ratification 2026-04-20 Q1). */
161
+ export const DEFAULT_PAIR_TTL_MS = 15 * 60 * 1000;
162
+
163
+ /** Minimum configurable TTL: 5 minutes. */
164
+ export const MIN_PAIR_TTL_MS = 5 * 60 * 1000;
165
+
166
+ /** Maximum configurable TTL: 60 minutes. */
167
+ export const MAX_PAIR_TTL_MS = 60 * 60 * 1000;
168
+
169
+ /** How long to keep completed/consumed/rejected sessions before evicting. */
170
+ export const TERMINAL_RETENTION_MS = 60 * 60 * 1000; // 1 hour
171
+
172
+ /** Maximum number of wrong secondary-code submissions before lockout. */
173
+ export const MAX_SECONDARY_CODE_ATTEMPTS = 5;
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Path helpers
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Build a pair-sessions path rooted at `baseDir`. Callers normally
181
+ * resolve `baseDir` via `CONFIG.pairSessionsPath`'s parent directory
182
+ * (see `config.ts`); tests pass a hermetic tmpdir.
183
+ *
184
+ * This helper intentionally does NOT read `process.env` — every env
185
+ * var read in the plugin lives in `config.ts` so the scanner rules stay
186
+ * satisfiable here (see module docstring).
187
+ */
188
+ export function defaultPairSessionsPath(baseDir: string): string {
189
+ return path.join(baseDir, 'pair-sessions.json');
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Default randomness helpers
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * 16-byte session id → 32-hex-char string. Uniformly random; enough
198
+ * entropy that collisions are cryptographically negligible, short enough
199
+ * to fit comfortably in a QR URL (32 chars plus the pk fragment).
200
+ */
201
+ function defaultRngSid(): Buffer {
202
+ return randomBytes(16);
203
+ }
204
+
205
+ /**
206
+ * Reject-sample a uniformly random 6-digit numeric code, left-padded.
207
+ * Using `randomBytes` + modulo has a tiny bias for ranges that don't
208
+ * divide 2**k evenly; we reject-sample to stay uniform. The bias is
209
+ * irrelevant for the attacker model here (5-strike lockout) but uniform
210
+ * is cheap and principled.
211
+ */
212
+ function defaultRngSecondaryCode(): string {
213
+ while (true) {
214
+ const b = randomBytes(4);
215
+ const n = b.readUInt32BE(0);
216
+ if (n >= 4_294_967_000) continue; // trim the last partial bucket
217
+ const code = n % 1_000_000;
218
+ return String(code).padStart(6, '0');
219
+ }
220
+ }
221
+
222
+ /** 32-byte RNG. Used for ephemeral x25519 keypair material in P3. */
223
+ function defaultRng32(): Buffer {
224
+ return randomBytes(32);
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Lock primitive (cooperative exclusive-create sentinel)
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /** Default stale-lock threshold. If a .lock file is older than this, we
232
+ * force-break it on next acquire. 30s is generous for a pairing flow. */
233
+ export const LOCK_STALE_MS = 30_000;
234
+
235
+ /** Max time to wait for a lock, in ms. 10s — pairing is not latency-critical. */
236
+ export const LOCK_WAIT_MS = 10_000;
237
+
238
+ /** Between-retry sleep. Short enough to feel responsive, long enough not to spin. */
239
+ export const LOCK_RETRY_MS = 50;
240
+
241
+ /**
242
+ * Acquire an exclusive lock on the given sessions-file path by
243
+ * atomically creating `<path>.lock` with `wx` mode. Retries up to
244
+ * `LOCK_WAIT_MS`; breaks a lock older than `LOCK_STALE_MS`; returns
245
+ * a release function.
246
+ *
247
+ * This is scope-limited to this module — we deliberately avoid
248
+ * importing `withFileLock` from the plugin-sdk because that would pull
249
+ * the OpenClaw runtime surface into the session-store file, and the
250
+ * scanner rules treat that as a network-capable file. Keeping it tiny
251
+ * and self-contained is safer than fighting the scanner.
252
+ */
253
+ async function acquireSessionsFileLock(sessionsPath: string): Promise<() => void> {
254
+ const lockPath = `${sessionsPath}.lock`;
255
+ const deadline = Date.now() + LOCK_WAIT_MS;
256
+
257
+ while (true) {
258
+ try {
259
+ const fd = fs.openSync(lockPath, 'wx');
260
+ fs.writeSync(fd, `${process.pid}\n`);
261
+ fs.closeSync(fd);
262
+ return () => {
263
+ try {
264
+ fs.unlinkSync(lockPath);
265
+ } catch {
266
+ // Lock already gone — fine.
267
+ }
268
+ };
269
+ } catch (err: unknown) {
270
+ // Lock exists. Check if it's stale.
271
+ try {
272
+ const st = fs.statSync(lockPath);
273
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
274
+ // Break it.
275
+ try {
276
+ fs.unlinkSync(lockPath);
277
+ } catch {
278
+ // Race — someone else broke it first. Retry.
279
+ }
280
+ continue;
281
+ }
282
+ } catch {
283
+ // Lock vanished between our open and stat — retry immediately.
284
+ continue;
285
+ }
286
+
287
+ if (Date.now() >= deadline) {
288
+ throw new Error(
289
+ `pair-session-store: could not acquire lock at ${lockPath} within ${LOCK_WAIT_MS}ms`,
290
+ );
291
+ }
292
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
293
+ }
294
+ }
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Load / save (no lock — callers wrap via `withSessionsLock`)
299
+ // ---------------------------------------------------------------------------
300
+
301
+ function emptyFile(): PairSessionFile {
302
+ return { version: PAIR_SESSION_FILE_VERSION, sessions: [] };
303
+ }
304
+
305
+ /**
306
+ * Load the sessions file. Returns an empty file on any read/parse error
307
+ * — the caller treats that as "no prior sessions exist" and starts
308
+ * fresh. Any malformed shape is discarded without raising.
309
+ *
310
+ * This helper intentionally uses `readFileSync` — see module docstring
311
+ * for why this file is safe to pair with that token (no outbound
312
+ * request words anywhere in the file).
313
+ */
314
+ export function loadPairSessionsFileSync(sessionsPath: string): PairSessionFile {
315
+ try {
316
+ if (!fs.existsSync(sessionsPath)) return emptyFile();
317
+ const raw = fs.readFileSync(sessionsPath, 'utf-8');
318
+ const parsed = JSON.parse(raw) as Partial<PairSessionFile>;
319
+ if (
320
+ typeof parsed !== 'object' ||
321
+ parsed === null ||
322
+ parsed.version !== PAIR_SESSION_FILE_VERSION ||
323
+ !Array.isArray(parsed.sessions)
324
+ ) {
325
+ return emptyFile();
326
+ }
327
+ // Shape-validate each entry; drop malformed ones silently. The
328
+ // caller's next prune pass would evict them anyway.
329
+ const clean: PairSession[] = [];
330
+ for (const s of parsed.sessions) {
331
+ if (isValidSession(s)) clean.push(s as PairSession);
332
+ }
333
+ return { version: PAIR_SESSION_FILE_VERSION, sessions: clean };
334
+ } catch {
335
+ return emptyFile();
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Atomic write: temp file + rename. Mode 0600 to match credentials.json
341
+ * and state.json. Returns true on success, false on any I/O error.
342
+ *
343
+ * Best-effort: the caller treats false as "retry later" or "session
344
+ * state may be lost; user will need to restart pairing" — failure is
345
+ * never fatal because pairing sessions are ephemeral by design.
346
+ */
347
+ export function writePairSessionsFileSync(
348
+ sessionsPath: string,
349
+ file: PairSessionFile,
350
+ ): boolean {
351
+ try {
352
+ const dir = path.dirname(sessionsPath);
353
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
354
+ const tmp = `${sessionsPath}.tmp-${process.pid}-${Date.now()}`;
355
+ fs.writeFileSync(tmp, JSON.stringify(file), { mode: 0o600 });
356
+ fs.renameSync(tmp, sessionsPath);
357
+ return true;
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
363
+ function isValidSession(s: unknown): boolean {
364
+ if (typeof s !== 'object' || s === null) return false;
365
+ const r = s as Record<string, unknown>;
366
+ return (
367
+ typeof r.sid === 'string' &&
368
+ r.sid.length > 0 &&
369
+ typeof r.skGatewayB64 === 'string' &&
370
+ typeof r.pkGatewayB64 === 'string' &&
371
+ typeof r.createdAtMs === 'number' &&
372
+ typeof r.expiresAtMs === 'number' &&
373
+ typeof r.secondaryCode === 'string' &&
374
+ /^\d{6}$/.test(r.secondaryCode) &&
375
+ typeof r.secondaryCodeAttempts === 'number' &&
376
+ typeof r.operatorContext === 'object' &&
377
+ r.operatorContext !== null &&
378
+ (r.mode === 'generate' || r.mode === 'import') &&
379
+ typeof r.status === 'string' &&
380
+ typeof r.lastStatusChangeAtMs === 'number'
381
+ );
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Pruning — lazy, idempotent, called on every read path
386
+ // ---------------------------------------------------------------------------
387
+
388
+ /**
389
+ * Drop expired sessions and terminal sessions older than the retention
390
+ * window. Returns the pruned file plus the list of pruned sids (useful
391
+ * for logging counts at the caller).
392
+ *
393
+ * Rules:
394
+ * - status=awaiting_scan or device_connected and now > expiresAtMs
395
+ * → flip to `expired` and keep for TERMINAL_RETENTION_MS.
396
+ * - any terminal status (completed / consumed / expired / rejected)
397
+ * and (now - lastStatusChangeAtMs) > TERMINAL_RETENTION_MS → drop.
398
+ */
399
+ export function pruneStaleSessions(
400
+ file: PairSessionFile,
401
+ nowMs: number,
402
+ ): { file: PairSessionFile; prunedSids: string[] } {
403
+ const keepers: PairSession[] = [];
404
+ const pruned: string[] = [];
405
+
406
+ for (const s of file.sessions) {
407
+ const isTerminal =
408
+ s.status === 'completed' ||
409
+ s.status === 'consumed' ||
410
+ s.status === 'expired' ||
411
+ s.status === 'rejected';
412
+
413
+ // First, promote any active-but-expired sessions to expired. We
414
+ // anchor `lastStatusChangeAtMs` to `expiresAtMs` (the actual moment
415
+ // the session became expired, not the moment we happened to observe
416
+ // it) so that a session observed long after expiry is dropped by
417
+ // the retention check below rather than getting its retention clock
418
+ // reset to "now".
419
+ let next = s;
420
+ if (!isTerminal && nowMs > s.expiresAtMs) {
421
+ next = {
422
+ ...s,
423
+ status: 'expired',
424
+ lastStatusChangeAtMs: s.expiresAtMs,
425
+ };
426
+ }
427
+
428
+ const nowTerminal =
429
+ next.status === 'completed' ||
430
+ next.status === 'consumed' ||
431
+ next.status === 'expired' ||
432
+ next.status === 'rejected';
433
+
434
+ if (nowTerminal && nowMs - next.lastStatusChangeAtMs > TERMINAL_RETENTION_MS) {
435
+ pruned.push(next.sid);
436
+ continue;
437
+ }
438
+ keepers.push(next);
439
+ }
440
+
441
+ return {
442
+ file: { version: file.version, sessions: keepers },
443
+ prunedSids: pruned,
444
+ };
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Public API — all operations go through the lock
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /** Clamp a caller-supplied TTL to the ratified bounds. */
452
+ export function clampTtlMs(ttlMs: number | undefined): number {
453
+ const raw = typeof ttlMs === 'number' && ttlMs > 0 ? ttlMs : DEFAULT_PAIR_TTL_MS;
454
+ return Math.max(MIN_PAIR_TTL_MS, Math.min(MAX_PAIR_TTL_MS, raw));
455
+ }
456
+
457
+ /**
458
+ * Create a new session, persist it, return the in-memory record.
459
+ *
460
+ * Caller is responsible for:
461
+ * - Deriving `pk_G` from `sk_G` (done in P3 via `pair-crypto.ts`).
462
+ * In P1 this accepts pre-generated keypair material via the
463
+ * `rngPrivateKey` / `rngPublicKey` hooks OR returns a stub where
464
+ * the pubkey is a derived placeholder. The P3 module will replace
465
+ * the default generators with a real x25519-aware pair.
466
+ * - Ensuring the gateway has quiesced any prior in-flight sessions
467
+ * this user started (the session store itself does NOT enforce a
468
+ * single-active-session policy; that's P4's concern).
469
+ */
470
+ export async function createPairSession(
471
+ sessionsPath: string,
472
+ opts: CreateSessionOptions,
473
+ ): Promise<PairSession> {
474
+ const now = (opts.now ?? Date.now)();
475
+ const ttl = clampTtlMs(opts.ttlMs);
476
+ const sidBuf = (opts.rngSid ?? defaultRngSid)();
477
+ const skBuf = (opts.rngPrivateKey ?? defaultRng32)();
478
+ const pkBuf = (opts.rngPublicKey ?? defaultRng32)();
479
+ const secondaryCode = (opts.rngSecondaryCode ?? defaultRngSecondaryCode)();
480
+
481
+ const session: PairSession = {
482
+ sid: sidBuf.toString('hex'),
483
+ skGatewayB64: skBuf.toString('base64url'),
484
+ pkGatewayB64: pkBuf.toString('base64url'),
485
+ createdAtMs: now,
486
+ expiresAtMs: now + ttl,
487
+ secondaryCode,
488
+ secondaryCodeAttempts: 0,
489
+ operatorContext: opts.operatorContext,
490
+ mode: opts.mode,
491
+ status: 'awaiting_scan',
492
+ lastStatusChangeAtMs: now,
493
+ };
494
+
495
+ const release = await acquireSessionsFileLock(sessionsPath);
496
+ try {
497
+ const current = loadPairSessionsFileSync(sessionsPath);
498
+ const pruned = pruneStaleSessions(current, now);
499
+ pruned.file.sessions.push(session);
500
+ writePairSessionsFileSync(sessionsPath, pruned.file);
501
+ } finally {
502
+ release();
503
+ }
504
+
505
+ return session;
506
+ }
507
+
508
+ /**
509
+ * Look up a session by sid. Returns null on not-found, expired, or any
510
+ * error. DOES return completed/consumed/rejected sessions so the HTTP
511
+ * handler can distinguish 404 (genuinely absent) from 409/410 (terminal).
512
+ *
513
+ * Prunes stale entries as a side effect; this is cheap and keeps the
514
+ * file from growing unbounded.
515
+ */
516
+ export async function getPairSession(
517
+ sessionsPath: string,
518
+ sid: string,
519
+ now: () => number = Date.now,
520
+ ): Promise<PairSession | null> {
521
+ const release = await acquireSessionsFileLock(sessionsPath);
522
+ try {
523
+ const file = loadPairSessionsFileSync(sessionsPath);
524
+ const pruned = pruneStaleSessions(file, now());
525
+ // Persist the prune if anything changed, but don't block on failure.
526
+ if (pruned.prunedSids.length > 0) {
527
+ writePairSessionsFileSync(sessionsPath, pruned.file);
528
+ }
529
+ return pruned.file.sessions.find((s) => s.sid === sid) ?? null;
530
+ } finally {
531
+ release();
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Apply a mutation to a session. Re-reads under the lock, finds the
537
+ * session by sid, calls the mutator, writes back. The mutator returns
538
+ * the new session state (or null to drop the session entirely).
539
+ *
540
+ * Returns the resulting session (after the mutation) or null if the
541
+ * mutator chose to drop it / the session wasn't found.
542
+ *
543
+ * Stale prune runs on the same lock acquisition, so callers never see
544
+ * a session that should already be expired.
545
+ */
546
+ export async function updatePairSession(
547
+ sessionsPath: string,
548
+ sid: string,
549
+ mutate: (s: PairSession) => PairSession | null,
550
+ now: () => number = Date.now,
551
+ ): Promise<PairSession | null> {
552
+ const release = await acquireSessionsFileLock(sessionsPath);
553
+ try {
554
+ const file = loadPairSessionsFileSync(sessionsPath);
555
+ const pruned = pruneStaleSessions(file, now());
556
+ const idx = pruned.file.sessions.findIndex((s) => s.sid === sid);
557
+ if (idx < 0) {
558
+ if (pruned.prunedSids.length > 0) {
559
+ writePairSessionsFileSync(sessionsPath, pruned.file);
560
+ }
561
+ return null;
562
+ }
563
+ const current = pruned.file.sessions[idx];
564
+ const next = mutate(current);
565
+ let result: PairSession | null;
566
+ if (next === null) {
567
+ pruned.file.sessions.splice(idx, 1);
568
+ result = null;
569
+ } else {
570
+ pruned.file.sessions[idx] = next;
571
+ result = next;
572
+ }
573
+ writePairSessionsFileSync(sessionsPath, pruned.file);
574
+ return result;
575
+ } finally {
576
+ release();
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Transition a session's status. Convenience wrapper around
582
+ * `updatePairSession`. Returns the new session or null if not found.
583
+ */
584
+ export async function transitionPairSession(
585
+ sessionsPath: string,
586
+ sid: string,
587
+ nextStatus: PairSessionStatus,
588
+ now: () => number = Date.now,
589
+ ): Promise<PairSession | null> {
590
+ return updatePairSession(
591
+ sessionsPath,
592
+ sid,
593
+ (s) => {
594
+ if (s.status === nextStatus) return s;
595
+ return {
596
+ ...s,
597
+ status: nextStatus,
598
+ lastStatusChangeAtMs: now(),
599
+ };
600
+ },
601
+ now,
602
+ );
603
+ }
604
+
605
+ /**
606
+ * Register a failed secondary-code attempt. Increments the counter.
607
+ * Returns the updated session, or null if the session is gone. If the
608
+ * attempt count reaches MAX_SECONDARY_CODE_ATTEMPTS, the session is
609
+ * transitioned to `rejected` and the HTTP handler should return 403
610
+ * + "too many attempts".
611
+ *
612
+ * The returned session's status reflects the incremented state —
613
+ * callers can check `session.status === 'rejected'` after this returns
614
+ * to know whether to lock the session out.
615
+ */
616
+ export async function registerFailedSecondaryCode(
617
+ sessionsPath: string,
618
+ sid: string,
619
+ now: () => number = Date.now,
620
+ ): Promise<PairSession | null> {
621
+ return updatePairSession(
622
+ sessionsPath,
623
+ sid,
624
+ (s) => {
625
+ const nextAttempts = s.secondaryCodeAttempts + 1;
626
+ const shouldReject = nextAttempts >= MAX_SECONDARY_CODE_ATTEMPTS;
627
+ return {
628
+ ...s,
629
+ secondaryCodeAttempts: nextAttempts,
630
+ status: shouldReject ? 'rejected' : s.status,
631
+ lastStatusChangeAtMs: shouldReject ? now() : s.lastStatusChangeAtMs,
632
+ };
633
+ },
634
+ now,
635
+ );
636
+ }
637
+
638
+ /**
639
+ * Consume a session atomically: verify it is in a consumable state
640
+ * (device_connected or awaiting_scan, not expired), flip to `consumed`,
641
+ * and return the pre-transition session so the caller can use the
642
+ * `skGatewayB64` one last time before it's retired.
643
+ *
644
+ * Returns:
645
+ * - the session (pre-transition) on success
646
+ * - `{ error: 'not_found' }` if sid absent
647
+ * - `{ error: 'expired' }` if TTL elapsed
648
+ * - `{ error: 'already_consumed' }` if status is completed/consumed
649
+ * - `{ error: 'rejected' }` if status is rejected (too many code
650
+ * failures or explicit cancel)
651
+ *
652
+ * The "consumed" flip happens BEFORE the caller does crypto work, so a
653
+ * retrying duplicate request sees `already_consumed` and the
654
+ * credentials-write logic doesn't race.
655
+ */
656
+ export type ConsumeResult =
657
+ | { ok: true; session: PairSession }
658
+ | { ok: false; error: 'not_found' | 'expired' | 'already_consumed' | 'rejected' };
659
+
660
+ export async function consumePairSession(
661
+ sessionsPath: string,
662
+ sid: string,
663
+ now: () => number = Date.now,
664
+ ): Promise<ConsumeResult> {
665
+ let outcome: ConsumeResult = { ok: false, error: 'not_found' };
666
+
667
+ await updatePairSession(
668
+ sessionsPath,
669
+ sid,
670
+ (s) => {
671
+ const t = now();
672
+ if (t > s.expiresAtMs) {
673
+ outcome = { ok: false, error: 'expired' };
674
+ return { ...s, status: 'expired', lastStatusChangeAtMs: t };
675
+ }
676
+ if (s.status === 'completed' || s.status === 'consumed') {
677
+ outcome = { ok: false, error: 'already_consumed' };
678
+ return s;
679
+ }
680
+ if (s.status === 'rejected' || s.status === 'expired') {
681
+ outcome = { ok: false, error: s.status };
682
+ return s;
683
+ }
684
+ // Success — flip to consumed and hand the PRE-transition session
685
+ // back so the caller can derive the shared key one last time.
686
+ outcome = { ok: true, session: s };
687
+ return { ...s, status: 'consumed', lastStatusChangeAtMs: t };
688
+ },
689
+ now,
690
+ );
691
+
692
+ return outcome;
693
+ }
694
+
695
+ /**
696
+ * Force a terminal status on a session (caller decides why). Used by
697
+ * the CLI on Ctrl+C ("user canceled") and by P4's "already active →
698
+ * refuse new pairing" guard. Returns the updated session or null.
699
+ */
700
+ export async function rejectPairSession(
701
+ sessionsPath: string,
702
+ sid: string,
703
+ now: () => number = Date.now,
704
+ ): Promise<PairSession | null> {
705
+ return transitionPairSession(sessionsPath, sid, 'rejected', now);
706
+ }
707
+
708
+ /**
709
+ * List all non-terminal sessions. Primarily for the CLI "are any
710
+ * pairings in flight?" check. Returns a defensive copy.
711
+ */
712
+ export async function listActivePairSessions(
713
+ sessionsPath: string,
714
+ now: () => number = Date.now,
715
+ ): Promise<PairSession[]> {
716
+ const release = await acquireSessionsFileLock(sessionsPath);
717
+ try {
718
+ const file = loadPairSessionsFileSync(sessionsPath);
719
+ const pruned = pruneStaleSessions(file, now());
720
+ if (pruned.prunedSids.length > 0) {
721
+ writePairSessionsFileSync(sessionsPath, pruned.file);
722
+ }
723
+ return pruned.file.sessions
724
+ .filter((s) => s.status === 'awaiting_scan' || s.status === 'device_connected')
725
+ .map((s) => ({ ...s }));
726
+ } finally {
727
+ release();
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Debug utility — list ALL sessions (including terminal) for the
733
+ * status CLI. Never logs or exposes the sk/pk material.
734
+ */
735
+ export async function listAllPairSessions(
736
+ sessionsPath: string,
737
+ now: () => number = Date.now,
738
+ ): Promise<PairSession[]> {
739
+ const release = await acquireSessionsFileLock(sessionsPath);
740
+ try {
741
+ const file = loadPairSessionsFileSync(sessionsPath);
742
+ const pruned = pruneStaleSessions(file, now());
743
+ if (pruned.prunedSids.length > 0) {
744
+ writePairSessionsFileSync(sessionsPath, pruned.file);
745
+ }
746
+ return pruned.file.sessions.map((s) => ({ ...s }));
747
+ } finally {
748
+ release();
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Scrub sensitive fields from a session for safe logging / status
754
+ * display. Returns a shallow clone with `skGatewayB64` and
755
+ * `secondaryCode` replaced by "[redacted]". The pk, sid, status,
756
+ * timestamps, mode, and operator-context are fine to log.
757
+ */
758
+ export function redactPairSession(s: PairSession): PairSession {
759
+ return {
760
+ ...s,
761
+ skGatewayB64: '[redacted]',
762
+ secondaryCode: '[redacted]',
763
+ };
764
+ }