@chrischall/mcp-utils 0.14.2 → 0.16.0

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Session scaffolding for the MCP fleet — four related-but-distinct surfaces
2
+ * Session scaffolding for the MCP fleet — five related-but-distinct surfaces
3
3
  * consolidated behind one subpath (`@chrischall/mcp-utils/session`):
4
4
  *
5
5
  * 1. {@link SessionRegistry} — an *ephemeral, in-memory* registry of signed-in
@@ -12,12 +12,18 @@
12
12
  * (0600 file / 0700 dir), normalized keys, and a most-recently-used "active"
13
13
  * pointer. Used by ofw/creditkarma/honeybook.
14
14
  *
15
- * 3. {@link TokenManager} — a bearer-token lifecycle manager: proactive refresh
16
- * inside a 5-minute skew window, reactive 401-replay, and a single-flight
17
- * semaphore so concurrent callers coalesce into ONE refresh. Used by
18
- * skylight/canvas/creditkarma/honeybook/zola.
15
+ * 3. {@link StatePersistence} — the opt-in seam that lets the two managers
16
+ * below survive a process restart, with {@link createFileStatePersistence}
17
+ * (atomic, 0600) and {@link resolveStateDir} (`MCP_DATA_DIR` `HOME`) as
18
+ * the disk-backed default. Without it a scale-to-zero host re-runs a full
19
+ * login on every cold start, against endpoints that often rate-limit it.
19
20
  *
20
- * 4. {@link CookieSessionManager} — the cookie-session analog of TokenManager:
21
+ * 4. {@link TokenManager} — a bearer-token lifecycle manager: a lazily
22
+ * bootstrapped login, proactive refresh inside a 5-minute skew window,
23
+ * reactive 401-replay, and a single-flight semaphore so concurrent callers
24
+ * coalesce into ONE exchange. Used by skylight/canvas/creditkarma/honeybook/zola.
25
+ *
26
+ * 5. {@link CookieSessionManager} — the cookie-session analog of TokenManager:
21
27
  * a single-flight login + reactive expiry-replay (with heuristic, not just
22
28
  * status-code, expiry detection) + clear-on-settle so a rejected login never
23
29
  * sticks. Used by artsonia/canvas/evite/signupgenius/skylight.
@@ -26,10 +32,13 @@
26
32
  * the one audited implementation the fleet shares.
27
33
  */
28
34
  import { z } from 'zod';
29
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, } from 'node:fs';
30
- import { dirname } from 'node:path';
35
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync, } from 'node:fs';
36
+ import { dirname, join } from 'node:path';
37
+ import { homedir } from 'node:os';
31
38
  import { randomBytes } from 'node:crypto';
32
39
  import { textResult } from '../response/index.js';
40
+ import { readEnvVar } from '../config/index.js';
41
+ import { ApiError, RateLimitedError, RequestTimeoutError } from '../http/index.js';
33
42
  /** Generate a short, collision-resistant label id. */
34
43
  function makeSessionId() {
35
44
  return Date.now().toString(36) + randomBytes(6).toString('hex');
@@ -389,72 +398,336 @@ export class SessionStore {
389
398
  this.mostRecentKey = null;
390
399
  }
391
400
  }
401
+ /**
402
+ * File-backed {@link StatePersistence}. The file is `0600`, re-asserted after
403
+ * the write because `mode` only applies on creation. A directory is created
404
+ * `0700` and re-asserted the same way — but ONLY one this call creates: a bare
405
+ * {@link resolveStateDir} is `$HOME`, and on `mcp-host` the data dir exists
406
+ * before the child starts, so re-permissioning a pre-existing directory would
407
+ * be an invasive side effect of writing one token file rather than hardening.
408
+ *
409
+ * Two differences from {@link SessionStore}, which is why this is its own
410
+ * implementation rather than a wrapper over it. It holds ONE record rather than
411
+ * a keyed collection; and it replaces the file **atomically** — written to a
412
+ * temp file beside it, then renamed over the target — because two children of
413
+ * the same registration can share a data directory, and a half-written token
414
+ * file that parses as valid JSON is worse than none.
415
+ *
416
+ * Nothing here throws. A load failure (absent, corrupt, rejected by `validate`)
417
+ * returns `null`; a save failure is swallowed and leaves the previous file
418
+ * intact. On `mcp-host` this belongs under {@link resolveStateDir}, which needs
419
+ * the registration to declare `state.dataDir: true` — the runner's
420
+ * unpersisted-state detector will report the omission rather than let the
421
+ * writes silently vanish on the next idle-stop.
422
+ */
423
+ export function createFileStatePersistence(opts) {
424
+ const { filePath, validate } = opts;
425
+ return {
426
+ load() {
427
+ if (!existsSync(filePath))
428
+ return null;
429
+ try {
430
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'));
431
+ if (validate !== undefined)
432
+ return validate(raw);
433
+ return raw;
434
+ }
435
+ catch {
436
+ // Corrupt or unreadable: the caller re-authenticates. Unlike
437
+ // SessionStore this does NOT preserve a `.corrupt` copy — the file
438
+ // holds one refreshable credential, not an irreplaceable capture.
439
+ return null;
440
+ }
441
+ },
442
+ save(state) {
443
+ const dir = dirname(filePath);
444
+ // A unique temp name so two writers cannot share (and tear) one temp file.
445
+ const tmp = `${filePath}.tmp-${randomBytes(6).toString('hex')}`;
446
+ // Only a directory THIS call creates gets tightened. `resolveStateDir()`
447
+ // without a `subdir` is `$HOME`, and chmodding a user's home directory to
448
+ // 0700 is not an acceptable side effect of writing one token file.
449
+ const dirExisted = existsSync(dir);
450
+ try {
451
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
452
+ // mkdir's mode is subject to the umask, so re-assert it on what we made.
453
+ if (!dirExisted)
454
+ chmodSync(dir, 0o700);
455
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
456
+ // Tighten BEFORE the rename: the window where fresh secrets sit in a
457
+ // possibly-loose file should not exist at all.
458
+ chmodSync(tmp, 0o600);
459
+ renameSync(tmp, filePath);
460
+ chmodSync(filePath, 0o600);
461
+ }
462
+ catch {
463
+ // Degrade to in-memory. Best-effort cleanup of the temp file so a
464
+ // failed write does not litter the data dir.
465
+ try {
466
+ if (existsSync(tmp))
467
+ unlinkSync(tmp);
468
+ }
469
+ catch {
470
+ /* best-effort */
471
+ }
472
+ }
473
+ },
474
+ clear() {
475
+ try {
476
+ if (existsSync(filePath))
477
+ unlinkSync(filePath);
478
+ }
479
+ catch {
480
+ /* best-effort */
481
+ }
482
+ },
483
+ };
484
+ }
485
+ /**
486
+ * Where a server should keep state that must survive a restart.
487
+ *
488
+ * `MCP_DATA_DIR` first — that is the variable `mcp-host` injects for a
489
+ * registration with `state.dataDir: true`, pointing at a path on the Fly volume
490
+ * keyed by the registration itself (a slot `$HOME` is handed out by arrival
491
+ * order and moves between boots, which is why the data dir is the fix and a
492
+ * bigger rootfs is not). Then `HOME`, then the OS home directory.
493
+ *
494
+ * Blank and unexpanded-placeholder values (`${MCP_DATA_DIR}`, the shape a host
495
+ * config leaves behind when a variable was never substituted) are ignored
496
+ * rather than used as a literal directory name — the same hardening
497
+ * {@link readEnvVar} applies.
498
+ */
499
+ export function resolveStateDir(opts = {}) {
500
+ // Delegated rather than re-implemented: readEnvVar is the fleet's one place
501
+ // that suppresses blank, `'null'`, `'undefined'` AND `${...}` placeholders.
502
+ // The sentinels matter as much as the placeholders here — `MCP_DATA_DIR=null`
503
+ // is a RELATIVE `./null` directory, so the credential would be written under
504
+ // the process cwd and silently stop surviving restarts.
505
+ const env = opts.env;
506
+ const base = readEnvVar('MCP_DATA_DIR', { env }) ?? readEnvVar('HOME', { env }) ?? homedir();
507
+ return opts.subdir !== undefined ? join(base, opts.subdir) : base;
508
+ }
392
509
  // ===========================================================================
393
- // 3. TokenManager — bearer lifecycle (skew, proactive + reactive, race-safe)
510
+ // 4. TokenManager — bearer lifecycle (skew, proactive + reactive, race-safe)
394
511
  // ===========================================================================
395
512
  /** Refresh proactively this many ms before the access token expires. */
396
513
  export const TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
514
+ /**
515
+ * The default {@link TokenManagerOptions.isRefreshRevoked}: everything except
516
+ * the failures mcp-utils itself can prove are transient. Deliberately
517
+ * conservative — an unrecognised error is treated as a dead credential, because
518
+ * a needless re-login costs one request and an unrecoverable one costs the
519
+ * server until a human intervenes.
520
+ */
521
+ function defaultIsRefreshRevoked(err) {
522
+ if (err instanceof RateLimitedError || err instanceof RequestTimeoutError)
523
+ return false;
524
+ if (err instanceof ApiError && err.status >= 500)
525
+ return false;
526
+ return true;
527
+ }
528
+ /** Whether a parsed record has the shape of {@link BearerTokens}. */
529
+ function isBearerTokens(raw) {
530
+ if (raw === null || typeof raw !== 'object')
531
+ return false;
532
+ const t = raw;
533
+ if (typeof t.accessToken !== 'string' || t.accessToken === '')
534
+ return false;
535
+ if (typeof t.expiresAt !== 'number' || !Number.isFinite(t.expiresAt))
536
+ return false;
537
+ return t.refreshToken === undefined || typeof t.refreshToken === 'string';
538
+ }
397
539
  /**
398
540
  * Manages a bearer access token's lifecycle:
399
541
  *
542
+ * - **Lazy bootstrap:** with a function-form {@link TokenManagerOptions.initial}
543
+ * the login runs on first use, and only if {@link TokenManagerOptions.persistence}
544
+ * has no usable token — the difference between a cold start costing a login
545
+ * and costing nothing.
400
546
  * - **Proactive:** {@link TokenManager.getAccessToken} refreshes when the token
401
547
  * is within `skewMs` (default 5 min) of expiry, returning a still-valid token.
402
548
  * - **Reactive:** {@link TokenManager.withAuth} runs a request, and on a `401`
403
549
  * refreshes once and replays exactly once (no infinite loop).
404
- * - **Race-safe:** concurrent refreshes coalesce onto a single in-flight promise
405
- * (semaphore), so a burst of callers triggers exactly ONE token exchange. The
406
- * in-flight promise is cleared on settle so a later refresh can run again.
550
+ * - **Race-safe:** concurrent refreshes (and concurrent bootstraps) coalesce
551
+ * onto a single in-flight promise, so a burst of callers triggers exactly ONE
552
+ * exchange. The in-flight promise is cleared on settle so a later attempt can
553
+ * run again — a rejected bootstrap never sticks.
554
+ * - **Recoverable:** when a refresh fails and a bootstrap function is available,
555
+ * the stored credential is discarded and the login re-runs. A refresh token
556
+ * revoked between two runs of the process must not brick the server.
407
557
  */
408
558
  export class TokenManager {
409
- accessToken;
410
- refreshToken;
411
- expiresAt;
559
+ tokens;
560
+ bootstrapFn;
412
561
  refreshFn;
413
562
  skewMs;
563
+ persistence;
564
+ now;
565
+ isRefreshRevokedFn;
414
566
  inFlight;
567
+ bootstrapInFlight;
568
+ /**
569
+ * Persistence is consulted at most once per process. Without this the
570
+ * revoked-token recovery below re-reads the SAME rejected record — `clear()`
571
+ * is optional on {@link StatePersistence} and its failures are swallowed, so
572
+ * recovery must not depend on it. After the first read the in-memory tokens
573
+ * (or their deliberate absence) are the truth.
574
+ */
575
+ persistenceRead = false;
415
576
  constructor(opts) {
416
- this.accessToken = opts.initial.accessToken;
417
- this.refreshToken = opts.initial.refreshToken;
418
- this.expiresAt = opts.initial.expiresAt;
577
+ if (typeof opts.initial === 'function') {
578
+ this.bootstrapFn = opts.initial;
579
+ }
580
+ else {
581
+ this.tokens = { ...opts.initial };
582
+ }
419
583
  this.refreshFn = opts.refresh;
420
584
  this.skewMs = opts.skewMs ?? TOKEN_REFRESH_SKEW_MS;
585
+ this.persistence = opts.persistence;
586
+ this.now = opts.now ?? Date.now;
587
+ this.isRefreshRevokedFn = opts.isRefreshRevoked ?? defaultIsRefreshRevoked;
421
588
  }
422
589
  /** Whether the token is within the skew window of (or past) expiry. */
423
590
  needsRefresh() {
424
- return Date.now() >= this.expiresAt - this.skewMs;
591
+ if (this.tokens === undefined)
592
+ return false;
593
+ return this.now() >= this.tokens.expiresAt - this.skewMs;
594
+ }
595
+ /**
596
+ * A stored token is worth using when it is still valid, OR when it carries a
597
+ * refresh token — an expired-but-refreshable token still saves the login,
598
+ * which is the expensive half.
599
+ */
600
+ isUsable(t) {
601
+ return this.now() < t.expiresAt - this.skewMs || t.refreshToken !== undefined;
602
+ }
603
+ /** Read persisted tokens, guarding shape and usability. Never throws. */
604
+ async loadPersisted() {
605
+ if (this.persistence === undefined || this.persistenceRead)
606
+ return null;
607
+ this.persistenceRead = true;
608
+ try {
609
+ const raw = await this.persistence.load();
610
+ if (!isBearerTokens(raw) || !this.isUsable(raw))
611
+ return null;
612
+ return raw;
613
+ }
614
+ catch {
615
+ return null;
616
+ }
617
+ }
618
+ /** Write tokens. Never throws — a failed write costs a login, not a request. */
619
+ async persist(t) {
620
+ if (this.persistence === undefined)
621
+ return;
622
+ try {
623
+ await this.persistence.save(t);
624
+ }
625
+ catch {
626
+ /* in-memory tokens are still valid for this process */
627
+ }
628
+ }
629
+ /** Discard persisted tokens (a refresh they could not satisfy). Never throws. */
630
+ async clearPersisted() {
631
+ if (this.persistence?.clear === undefined)
632
+ return;
633
+ try {
634
+ await this.persistence.clear();
635
+ }
636
+ catch {
637
+ /* best-effort */
638
+ }
639
+ }
640
+ /** The current tokens, single-flighting the bootstrap if there are none. */
641
+ ensureTokens() {
642
+ if (this.tokens !== undefined)
643
+ return Promise.resolve(this.tokens);
644
+ if (this.bootstrapInFlight === undefined) {
645
+ this.bootstrapInFlight = this.runBootstrap().finally(() => {
646
+ this.bootstrapInFlight = undefined;
647
+ });
648
+ }
649
+ return this.bootstrapInFlight;
650
+ }
651
+ /** One bootstrap attempt: persisted tokens if usable, else the login. */
652
+ async runBootstrap() {
653
+ const stored = await this.loadPersisted();
654
+ if (stored !== null) {
655
+ this.tokens = stored;
656
+ return stored;
657
+ }
658
+ if (this.bootstrapFn === undefined) {
659
+ throw new Error('TokenManager: no tokens and no bootstrap function to mint them.');
660
+ }
661
+ const fresh = await this.bootstrapFn();
662
+ this.tokens = fresh;
663
+ await this.persist(fresh);
664
+ return fresh;
425
665
  }
426
666
  /**
427
667
  * Single-flight refresh. Concurrent callers share one in-flight promise; it is
428
668
  * cleared on settle (success or failure) so a subsequent refresh can proceed.
429
669
  */
430
670
  refreshNow() {
431
- if (!this.inFlight) {
432
- const rt = this.refreshToken;
433
- if (rt === undefined) {
434
- return Promise.reject(new Error('TokenManager: cannot refresh — no refresh token is available.'));
435
- }
436
- this.inFlight = (async () => {
437
- const tok = await this.refreshFn(rt);
438
- this.accessToken = tok.accessToken;
439
- if (tok.refreshToken !== undefined && tok.refreshToken !== '') {
440
- this.refreshToken = tok.refreshToken;
441
- }
442
- this.expiresAt = tok.expiresAt;
443
- })().finally(() => {
671
+ if (this.inFlight === undefined) {
672
+ this.inFlight = this.runRefresh().finally(() => {
444
673
  this.inFlight = undefined;
445
674
  });
446
675
  }
447
676
  return this.inFlight;
448
677
  }
678
+ /** One refresh attempt against the current refresh token. */
679
+ async runRefresh() {
680
+ const current = this.tokens ?? (await this.ensureTokens());
681
+ const rt = current.refreshToken;
682
+ if (rt === undefined) {
683
+ throw new Error('TokenManager: cannot refresh — no refresh token is available.');
684
+ }
685
+ const tok = await this.refreshFn(rt);
686
+ this.tokens = {
687
+ accessToken: tok.accessToken,
688
+ // Rotation is optional: keep the current refresh token when none comes back.
689
+ refreshToken: tok.refreshToken !== undefined && tok.refreshToken !== '' ? tok.refreshToken : rt,
690
+ expiresAt: tok.expiresAt,
691
+ };
692
+ await this.persist(this.tokens);
693
+ }
694
+ /**
695
+ * Recover from a refresh the current credential could not satisfy — commonly
696
+ * a refresh token restored from a previous process and revoked since. Without
697
+ * a bootstrap to fall back on this is terminal; with one, re-minting beats
698
+ * staying broken forever. Shared so the two entry points cannot diverge.
699
+ */
700
+ async reBootstrap(err) {
701
+ if (this.bootstrapFn === undefined)
702
+ throw err;
703
+ // Only a credential we believe is DEAD is worth destroying. A 5xx or a
704
+ // timeout leaves a perfectly good refresh token that the next call can use.
705
+ if (!this.isRefreshRevokedFn(err))
706
+ throw err;
707
+ this.tokens = undefined;
708
+ await this.clearPersisted();
709
+ return this.ensureTokens();
710
+ }
449
711
  /** Get a valid access token, refreshing proactively inside the skew window. */
450
712
  async getAccessToken() {
451
- if (this.needsRefresh())
452
- await this.refreshNow();
453
- return this.accessToken;
713
+ // Not `await this.ensureTokens()` unconditionally: with tokens already in
714
+ // hand that await would defer the refresh below by a microtask, and callers
715
+ // rely on a concurrent burst reaching the single-flight in the SAME tick.
716
+ let tokens = this.tokens ?? (await this.ensureTokens());
717
+ if (this.needsRefresh()) {
718
+ try {
719
+ await this.refreshNow();
720
+ }
721
+ catch (err) {
722
+ return (await this.reBootstrap(err)).accessToken;
723
+ }
724
+ tokens = this.tokens ?? tokens;
725
+ }
726
+ return tokens.accessToken;
454
727
  }
455
- /** Current absolute expiry (epoch ms). */
728
+ /** Current absolute expiry (epoch ms), or `0` before the first bootstrap. */
456
729
  getExpiresAt() {
457
- return this.expiresAt;
730
+ return this.tokens?.expiresAt ?? 0;
458
731
  }
459
732
  /**
460
733
  * Run an authenticated request with reactive 401-replay. `call` receives a
@@ -472,13 +745,31 @@ export class TokenManager {
472
745
  const usedToken = await this.getAccessToken();
473
746
  let res = await call(usedToken);
474
747
  if (res.status === 401) {
475
- if (this.accessToken === usedToken)
476
- await this.refreshNow();
477
- res = await call(this.accessToken);
748
+ if (this.tokens?.accessToken === usedToken) {
749
+ // Same revoked-credential recovery getAccessToken has: a 401 replay must
750
+ // not be the one entry point that throws where the other re-mints.
751
+ try {
752
+ await this.refreshNow();
753
+ }
754
+ catch (err) {
755
+ await this.reBootstrap(err);
756
+ }
757
+ }
758
+ res = await call(this.tokens?.accessToken ?? usedToken);
478
759
  }
479
760
  return res;
480
761
  }
481
762
  }
763
+ /** Whether a parsed record has the shape of {@link PersistedCookieSession}. */
764
+ function isPersistedCookieSession(raw) {
765
+ if (raw === null || typeof raw !== 'object')
766
+ return false;
767
+ const r = raw;
768
+ if (typeof r.sessionAt !== 'number' || !Number.isFinite(r.sessionAt))
769
+ return false;
770
+ // Field-by-field, like isBearerTokens: a primitive is not a session shape.
771
+ return typeof r.session === 'object' && r.session !== null;
772
+ }
482
773
  /**
483
774
  * Cookie-session analog of {@link TokenManager}: owns a site's cookie-session
484
775
  * lifecycle with the same single-flight / replay / clear-on-settle discipline,
@@ -531,6 +822,16 @@ export class CookieSessionManager {
531
822
  maxAgeMs;
532
823
  now;
533
824
  onReplayLoginErrorFn;
825
+ persistence;
826
+ /** Persistence is consulted once per process; a miss must not be re-read. */
827
+ persistenceRead = false;
828
+ /**
829
+ * Serializes persistence writes. `seed()` and `invalidate()` are synchronous
830
+ * by contract and so fire-and-forget their save/clear; with an async backend a
831
+ * slow save could otherwise land AFTER the clear that followed it and leave an
832
+ * invalidated session on disk.
833
+ */
834
+ persistChain = Promise.resolve();
534
835
  constructor(opts) {
535
836
  this.loginFn = opts.login;
536
837
  // Optional: ensure-only consumers (no per-request expiry path) omit it; the
@@ -540,6 +841,7 @@ export class CookieSessionManager {
540
841
  this.maxAgeMs = opts.maxAgeMs;
541
842
  this.now = opts.now ?? Date.now;
542
843
  this.onReplayLoginErrorFn = opts.onReplayLoginError;
844
+ this.persistence = opts.persistence;
543
845
  }
544
846
  /** The current session, or `undefined` before the first successful login. */
545
847
  get current() {
@@ -588,6 +890,12 @@ export class CookieSessionManager {
588
890
  this.session = session;
589
891
  this.sessionAt = this.now();
590
892
  this.inFlight = undefined; // detach any in-flight login (it won't re-stamp)
893
+ // The caller has installed a session, so the stored one is superseded and
894
+ // must never be restored over it (or over a later invalidate()).
895
+ this.persistenceRead = true;
896
+ // Fire-and-forget: seed() is synchronous by contract, and a persistence
897
+ // failure must not change what the caller just installed.
898
+ void this.persist(session, this.sessionAt);
591
899
  }
592
900
  /**
593
901
  * One login attempt. Self-clears `inFlight` on settle so a rejected login
@@ -602,11 +910,26 @@ export class CookieSessionManager {
602
910
  const holder = {};
603
911
  holder.p = (async () => {
604
912
  try {
913
+ // A restored session is the whole point: skip the login entirely.
914
+ // Guarded rather than awaited unconditionally — with no persistence the
915
+ // await would defer loginFn() past the tick a concurrent burst needs.
916
+ const restored = this.persistence !== undefined && !this.persistenceRead
917
+ ? await this.restoreFromPersistence()
918
+ : null;
919
+ if (restored !== null) {
920
+ if (this.inFlight === holder.p) {
921
+ this.session = restored.session;
922
+ this.sessionAt = restored.sessionAt;
923
+ }
924
+ return restored.session;
925
+ }
605
926
  const session = await this.loginFn();
927
+ const at = this.now();
606
928
  if (this.inFlight === holder.p) {
607
929
  this.session = session;
608
- this.sessionAt = this.now();
930
+ this.sessionAt = at;
609
931
  }
932
+ await this.persist(session, at);
610
933
  return session;
611
934
  }
612
935
  catch (err) {
@@ -630,6 +953,70 @@ export class CookieSessionManager {
630
953
  invalidate() {
631
954
  this.session = undefined;
632
955
  this.inFlight = undefined;
956
+ // Fire-and-forget, and unconditional: the stored copy is the same session
957
+ // that just proved unusable. Leaving it would have the next ensure() read
958
+ // it back and loop on the very expiry that caused this call.
959
+ void this.clearPersisted();
960
+ }
961
+ /**
962
+ * The persisted session, if there is one worth using. Read at most once per
963
+ * process — after that the in-memory session (or its absence) is the truth,
964
+ * so an invalidate() cannot be undone by a stale file.
965
+ */
966
+ async restoreFromPersistence() {
967
+ if (this.persistence === undefined || this.persistenceRead)
968
+ return null;
969
+ this.persistenceRead = true;
970
+ try {
971
+ // Through the chain, not around it: `invalidate()` queues its `clear()`,
972
+ // and a read that jumped that queue would restore the very session the
973
+ // clear is about to remove.
974
+ let raw = null;
975
+ await this.enqueuePersist(async () => {
976
+ raw = await this.persistence?.load();
977
+ });
978
+ if (!isPersistedCookieSession(raw))
979
+ return null;
980
+ // Honour the proactive TTL against the ORIGINAL login time.
981
+ if (this.maxAgeMs !== undefined && this.now() - raw.sessionAt >= this.maxAgeMs)
982
+ return null;
983
+ return raw;
984
+ }
985
+ catch {
986
+ return null;
987
+ }
988
+ }
989
+ /** Append a persistence op to the chain, preserving call order. Never throws. */
990
+ enqueuePersist(op) {
991
+ const next = this.persistChain.then(op);
992
+ this.persistChain = next.catch(() => undefined);
993
+ return next;
994
+ }
995
+ /** Write the session. Never throws — a failed write costs a login, not a request. */
996
+ persist(session, sessionAt) {
997
+ return this.enqueuePersist(async () => {
998
+ if (this.persistence === undefined)
999
+ return;
1000
+ try {
1001
+ await this.persistence.save({ session, sessionAt });
1002
+ }
1003
+ catch {
1004
+ /* the in-memory session is still usable for this process */
1005
+ }
1006
+ });
1007
+ }
1008
+ /** Discard the persisted session. Never throws. */
1009
+ clearPersisted() {
1010
+ return this.enqueuePersist(async () => {
1011
+ if (this.persistence?.clear === undefined)
1012
+ return;
1013
+ try {
1014
+ await this.persistence.clear();
1015
+ }
1016
+ catch {
1017
+ /* best-effort */
1018
+ }
1019
+ });
633
1020
  }
634
1021
  /**
635
1022
  * Run an authenticated `call` with the current session and reactive