@skrr-ai/auth-core 0.1.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.
Files changed (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. package/package.json +82 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * refreshClassification.ts — pure HTTP→{Permanent, Transient, Success} mapping.
3
+ *
4
+ * Extracted from refresh.ts so RN-side clients (which can't pull the full
5
+ * Node-bound refresh module) can apply the same response classification
6
+ * the daemon does. The byte-equal mirror in mobile is at
7
+ * `mobile/react-native/src/api/authProtocol.ts` — keep both files in sync.
8
+ * (2026-08 — the codeless/unknown-401 → `UNKNOWN_401` change below still needs
9
+ * to be ported to that mirror in the mobile repo, which is a separate git root
10
+ * gitignored from this one; until then mobile keeps the old immediate-latch
11
+ * behaviour, which is safe — it just re-auths a touch more eagerly.)
12
+ *
13
+ * Pure: no fetch, no fs, no lock. Caller passes in the parsed response and
14
+ * the http status; we return a discriminated union.
15
+ */
16
+ import { PermanentAuthFailure, TransientAuthFailure, type PermanentAuthReason } from './types.js';
17
+ export { PermanentAuthFailure, TransientAuthFailure };
18
+ export type { PermanentAuthReason };
19
+ export interface RefreshResponseShape {
20
+ /** Optional server-supplied error code, e.g. 'REFRESH_REUSED'. */
21
+ code?: string;
22
+ /** Optional human-readable error message. */
23
+ message?: string;
24
+ /** When 200, the new access token. */
25
+ token?: string;
26
+ /** When 200, the (possibly rotated) refresh token. */
27
+ refreshToken?: string;
28
+ }
29
+ export interface ClassifiedRefreshSuccess {
30
+ kind: 'success';
31
+ token: string;
32
+ refreshToken?: string;
33
+ }
34
+ export interface ClassifiedRefreshFailure {
35
+ kind: 'failure';
36
+ failure: PermanentAuthFailure | TransientAuthFailure;
37
+ }
38
+ export type ClassifiedRefreshResult = ClassifiedRefreshSuccess | ClassifiedRefreshFailure;
39
+ /**
40
+ * Classify an HTTP response from /api/auth/refresh (or /api/daemons/token/refresh)
41
+ * into a discriminated union of success or failure.
42
+ *
43
+ * Rules match the canonical doRefresh() logic in refresh.ts:
44
+ * - 200 + body.token → success
45
+ * - 429 → TransientAuthFailure (rate-limited, caller should back off)
46
+ * - 401 + known permanent code → PermanentAuthFailure with that reason
47
+ * - 401 + unknown/missing code → PermanentAuthFailure('UNKNOWN_401', ...) — AMBIGUOUS,
48
+ * a caller with a retry budget may ride it out (see the WEB interceptor and the
49
+ * daemon's UNKNOWN_401_BUDGET in refresh.ts); a caller without one fails closed.
50
+ * - 4xx (not 429) → PermanentAuthFailure('REFRESH_INVALID', ...) — server explicitly rejected
51
+ * - 5xx / null body / anything else → TransientAuthFailure (transient, retry OK)
52
+ */
53
+ export declare function classifyRefreshResponse(status: number, body: RefreshResponseShape | null): ClassifiedRefreshResult;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * refreshClassification.ts — pure HTTP→{Permanent, Transient, Success} mapping.
3
+ *
4
+ * Extracted from refresh.ts so RN-side clients (which can't pull the full
5
+ * Node-bound refresh module) can apply the same response classification
6
+ * the daemon does. The byte-equal mirror in mobile is at
7
+ * `mobile/react-native/src/api/authProtocol.ts` — keep both files in sync.
8
+ * (2026-08 — the codeless/unknown-401 → `UNKNOWN_401` change below still needs
9
+ * to be ported to that mirror in the mobile repo, which is a separate git root
10
+ * gitignored from this one; until then mobile keeps the old immediate-latch
11
+ * behaviour, which is safe — it just re-auths a touch more eagerly.)
12
+ *
13
+ * Pure: no fetch, no fs, no lock. Caller passes in the parsed response and
14
+ * the http status; we return a discriminated union.
15
+ */
16
+ import { PermanentAuthFailure, TransientAuthFailure } from './types.js';
17
+ // Re-export the failure classes so consumers of this subpath
18
+ // (`@skrr-ai/auth-core/refresh-classification`) can `instanceof`-check the
19
+ // classifier's `failure` field without separately importing from `./types.js`.
20
+ // The classes are pure (no side effects, no Node-bound deps) so they're
21
+ // browser-safe to ship in a web bundle. Mobile's mirror file at
22
+ // `mobile/react-native/src/api/authProtocol.ts` already follows this pattern.
23
+ export { PermanentAuthFailure, TransientAuthFailure };
24
+ /**
25
+ * Maps server-supplied error codes to the canonical PermanentAuthReason values
26
+ * exported from types.ts. Only the four values present in PermanentAuthReason
27
+ * are listed; anything else falls through to 'REFRESH_INVALID'.
28
+ */
29
+ const PERMANENT_CODE_SET = new Set([
30
+ 'REFRESH_INVALID',
31
+ 'REFRESH_REUSED',
32
+ 'REFRESH_EXPIRED',
33
+ 'SESSION_REVOKED',
34
+ ]);
35
+ /**
36
+ * Classify an HTTP response from /api/auth/refresh (or /api/daemons/token/refresh)
37
+ * into a discriminated union of success or failure.
38
+ *
39
+ * Rules match the canonical doRefresh() logic in refresh.ts:
40
+ * - 200 + body.token → success
41
+ * - 429 → TransientAuthFailure (rate-limited, caller should back off)
42
+ * - 401 + known permanent code → PermanentAuthFailure with that reason
43
+ * - 401 + unknown/missing code → PermanentAuthFailure('UNKNOWN_401', ...) — AMBIGUOUS,
44
+ * a caller with a retry budget may ride it out (see the WEB interceptor and the
45
+ * daemon's UNKNOWN_401_BUDGET in refresh.ts); a caller without one fails closed.
46
+ * - 4xx (not 429) → PermanentAuthFailure('REFRESH_INVALID', ...) — server explicitly rejected
47
+ * - 5xx / null body / anything else → TransientAuthFailure (transient, retry OK)
48
+ */
49
+ export function classifyRefreshResponse(status, body) {
50
+ // Success
51
+ if (status === 200 && body?.token) {
52
+ return {
53
+ kind: 'success',
54
+ token: body.token,
55
+ refreshToken: body.refreshToken,
56
+ };
57
+ }
58
+ // 429 — rate limited, transient
59
+ if (status === 429) {
60
+ return {
61
+ kind: 'failure',
62
+ failure: new TransientAuthFailure(body?.message ?? 'Refresh rate-limited by server'),
63
+ };
64
+ }
65
+ // 401 — auth failure; check for known permanent code
66
+ if (status === 401) {
67
+ const code = body?.code;
68
+ if (code && PERMANENT_CODE_SET.has(code)) {
69
+ return {
70
+ kind: 'failure',
71
+ failure: new PermanentAuthFailure(code, body?.message ?? code),
72
+ };
73
+ }
74
+ // Unknown-shape 401 (no code, or a code we don't recognise). This is NOT a
75
+ // proven credential death: the refresh endpoint returns a codeless 401 for
76
+ // benign, self-healing conditions too — the refresh cookie simply not riding
77
+ // along on THIS request (SameSite suppression on a cross-site entry / a
78
+ // restored tab), or a token another concurrent refresh already rotated (the
79
+ // loser presents a now-rotated token; its next same-origin retry carries the
80
+ // successor and succeeds). Labelling those permanent makes a single transient
81
+ // 401 sign the user out of every tab while the server-side session is still
82
+ // alive and unexpired. So classify as `UNKNOWN_401`: still a
83
+ // PermanentAuthFailure so a caller with no retry budget stays safe (fails
84
+ // closed), but a distinct code the WEB interceptor recognises and rides out
85
+ // under a bounded budget before latching (mirrors the daemon's
86
+ // UNKNOWN_401_BUDGET in refresh.ts). A KNOWN death code above still latches
87
+ // immediately.
88
+ return {
89
+ kind: 'failure',
90
+ failure: new PermanentAuthFailure('UNKNOWN_401', body?.message ?? 'Unknown auth failure'),
91
+ };
92
+ }
93
+ // Other 4xx (not 429, not 401) — server explicitly rejected, not retryable
94
+ if (status >= 400 && status < 500) {
95
+ return {
96
+ kind: 'failure',
97
+ failure: new PermanentAuthFailure('REFRESH_INVALID', body?.message ?? `HTTP ${status}`),
98
+ };
99
+ }
100
+ // 200 without a token (malformed success), 5xx, network-level errors, or any
101
+ // other status — treat as transient so the caller's retry loop can recover.
102
+ return {
103
+ kind: 'failure',
104
+ failure: new TransientAuthFailure(body?.message ?? `HTTP ${status}`),
105
+ };
106
+ }
@@ -0,0 +1,53 @@
1
+ export interface RefreshSchedulerOptions {
2
+ /**
3
+ * Invoked when the scheduler decides to refresh. Must return the fresh
4
+ * access token object on success (so the scheduler can chain-schedule
5
+ * from it) or null on any failure. On null the failure counter is bumped.
6
+ */
7
+ onRefresh: () => Promise<{
8
+ accessToken: string;
9
+ } | null>;
10
+ /**
11
+ * Current access token getter. Used on wake to reschedule from the
12
+ * canonical token when the in-memory one is stale (e.g. another process
13
+ * rotated while we were asleep).
14
+ */
15
+ getAccessToken: () => string | undefined | null;
16
+ /** Log prefix label. Default "refresh". */
17
+ label?: string;
18
+ /** How long before `exp` to fire refresh. Default 5 min. */
19
+ refreshBufferMs?: number;
20
+ /** Fallback interval if `exp` cannot be decoded. Default 30 min. */
21
+ fallbackIntervalMs?: number;
22
+ /** Max consecutive onRefresh failures before giving up. Default 3. */
23
+ maxFailures?: number;
24
+ /** Test hooks. */
25
+ setTimeoutFn?: (cb: () => void, ms: number) => NodeJS.Timeout;
26
+ clearTimeoutFn?: (timer: NodeJS.Timeout) => void;
27
+ nowFn?: () => number;
28
+ /** Backoff cap — controls onWake drift threshold (2×). Default 120_000. */
29
+ backoffCapMs?: number;
30
+ /**
31
+ * Override the kill-switch check. When undefined (default) the package's
32
+ * runtime-injected kill switches are consulted. Useful for tests.
33
+ */
34
+ disabled?: boolean;
35
+ }
36
+ export interface RefreshScheduler {
37
+ /** Schedule refresh for a token by decoding its exp. Cancels any existing timer. */
38
+ schedule(token: string): void;
39
+ /** Schedule using an explicit TTL in seconds (for opaque tokens). */
40
+ scheduleFromExpiresIn(expiresInSeconds: number): void;
41
+ /** Cancel any pending refresh. */
42
+ cancel(): void;
43
+ /** Call on wake/visibility change to detect dropped fires. */
44
+ onWake(): void;
45
+ /** Test-only: expose the current timer state. */
46
+ readonly _debugState: {
47
+ scheduledFor: number | null;
48
+ generation: number;
49
+ consecutiveFailures: number;
50
+ longTailAttempt: number;
51
+ };
52
+ }
53
+ export declare function createRefreshScheduler(opts: RefreshSchedulerOptions): RefreshScheduler;
@@ -0,0 +1,329 @@
1
+ /**
2
+ * refreshScheduler.ts — proactive, sleep-aware JWT refresh scheduler.
3
+ *
4
+ * Why: reactive refresh (wait for 4401, then rotate) loses minutes of
5
+ * latency and surfaces as user-visible reconnect flicker. Scheduling a
6
+ * refresh BEFORE exp means the socket never trips the auth floor in
7
+ * steady state. Reactive refresh stays as the correctness floor — this
8
+ * scheduler is the comfort layer.
9
+ *
10
+ * Core invariants:
11
+ *
12
+ * 1. Generation counter per scheduler. Every schedule() / cancel() bumps
13
+ * it. In-flight `doRefresh()` callbacks check generation before setting
14
+ * any follow-up timer — so a late callback from a stale schedule
15
+ * never hijacks the live timer chain.
16
+ *
17
+ * 2. Follow-up timer after every successful refresh. A one-shot timer
18
+ * is insufficient for long-lived processes: once the first proactive
19
+ * fire rotates the token, we re-derive `exp` from the new token and
20
+ * schedule the next fire. Fallback to fallbackIntervalMs (default 30 m)
21
+ * when the new token is opaque or undecodable.
22
+ *
23
+ * 3. Failure cap. If onRefresh returns null more than maxFailures times
24
+ * in a row, stop rescheduling until schedule() is called externally
25
+ * again. Prevents spinning forever against a broken backend.
26
+ *
27
+ * 4. .unref() on every timer. The scheduler must never be the reason the
28
+ * process stays alive.
29
+ *
30
+ * 5. Sleep detection. onWake() compares Date.now() against the recorded
31
+ * fire target. If wallclock has jumped past fire-target + 2×backoffCap,
32
+ * the timer fired while the CPU was frozen (laptop lid) — we lost the
33
+ * fire and must reschedule from the current token. Cheap when no drift.
34
+ */
35
+ import { getAuthLogger, getAuthKillSwitches } from './runtime.js';
36
+ import { decodeJwtExpiry } from './jwtUtils.js';
37
+ /** Refresh buffer: fire this long BEFORE exp. Default 5 min. */
38
+ const DEFAULT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
39
+ /** Fallback refresh interval when the new token's expiry is unknown. */
40
+ const DEFAULT_FALLBACK_INTERVAL_MS = 30 * 60 * 1000;
41
+ /** Max consecutive onRefresh failures before giving up. */
42
+ const DEFAULT_MAX_FAILURES = 3;
43
+ /** Retry spacing between onRefresh failures. */
44
+ const REFRESH_RETRY_DELAY_MS = 60_000;
45
+ /**
46
+ * ±60 s jitter applied to the computed delay. Spreading the fleet prevents
47
+ * a 1M-daemon thundering herd after an outage recovery.
48
+ */
49
+ const REFRESH_JITTER_MS = 60 * 1000;
50
+ /** Floor on scheduled delay — never fire sooner than this. Guards against
51
+ * clock skew producing negative delays with jitter. */
52
+ const MIN_DELAY_FLOOR_MS = 30_000;
53
+ /** Minimum effective refresh buffer — preserves SOME lead time even on
54
+ * pathologically short-TTL tokens. */
55
+ const MIN_REFRESH_BUFFER_FLOOR_MS = 5_000;
56
+ /** Sleep/wake detection threshold: 2 × backoff cap (default 4 min). */
57
+ const DEFAULT_BACKOFF_CAP_MS = 120_000;
58
+ /**
59
+ * Long-tail recovery — see S2 fix. After `maxFailures` consecutive
60
+ * `onRefresh` returns of null, instead of stopping forever the scheduler
61
+ * arms a slow exponential-backoff timer that keeps probing in the
62
+ * background. Reset on the next successful refresh. Numbers chosen so the
63
+ * worst-case dead-comfort-layer window is bounded at 30 min instead of an
64
+ * unbounded "until the socket disconnects" forever.
65
+ */
66
+ const LONG_TAIL_INITIAL_DELAY_MS = 5 * 60 * 1000;
67
+ const LONG_TAIL_MAX_DELAY_MS = 30 * 60 * 1000;
68
+ const LONG_TAIL_BACKOFF_FACTOR = 2;
69
+ function formatDuration(ms) {
70
+ if (ms < 60_000)
71
+ return `${Math.round(ms / 1000)}s`;
72
+ const m = Math.floor(ms / 60_000);
73
+ const s = Math.round((ms % 60_000) / 1000);
74
+ return s > 0 ? `${m}m ${s}s` : `${m}m`;
75
+ }
76
+ /**
77
+ * Long-tail delay for attempt N (1-indexed). N=0 returns the initial delay
78
+ * — used the first time we transition into long-tail. Capped at
79
+ * LONG_TAIL_MAX_DELAY_MS.
80
+ */
81
+ function computeLongTailDelay(currentAttempt) {
82
+ const exponent = Math.max(0, currentAttempt);
83
+ const delay = LONG_TAIL_INITIAL_DELAY_MS * Math.pow(LONG_TAIL_BACKOFF_FACTOR, exponent);
84
+ return Math.min(delay, LONG_TAIL_MAX_DELAY_MS);
85
+ }
86
+ export function createRefreshScheduler(opts) {
87
+ const log = getAuthLogger();
88
+ const label = opts.label ?? 'refresh';
89
+ // Kill-switch resolution: explicit `opts.disabled` wins, else runtime-
90
+ // injected `killSwitches.refreshScheduler`. Defaults to enabled.
91
+ const disabled = opts.disabled ?? getAuthKillSwitches().refreshScheduler ?? false;
92
+ if (disabled) {
93
+ let warnedOnce = false;
94
+ const warnDisabled = (method) => {
95
+ if (!warnedOnce) {
96
+ log.warn(`[${label}] refresh scheduler is disabled via kill switch — ${method}() is a no-op; falling back to pre-scheduler refresh path`);
97
+ warnedOnce = true;
98
+ }
99
+ };
100
+ return {
101
+ schedule(_token) {
102
+ warnDisabled('schedule');
103
+ },
104
+ scheduleFromExpiresIn(_expiresInSeconds) {
105
+ warnDisabled('scheduleFromExpiresIn');
106
+ },
107
+ cancel() {
108
+ /* no-op */
109
+ },
110
+ onWake() {
111
+ /* no-op */
112
+ },
113
+ get _debugState() {
114
+ return {
115
+ scheduledFor: null,
116
+ generation: 0,
117
+ consecutiveFailures: 0,
118
+ longTailAttempt: 0,
119
+ };
120
+ },
121
+ };
122
+ }
123
+ const refreshBufferMs = opts.refreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS;
124
+ const fallbackIntervalMs = opts.fallbackIntervalMs ?? DEFAULT_FALLBACK_INTERVAL_MS;
125
+ const maxFailures = opts.maxFailures ?? DEFAULT_MAX_FAILURES;
126
+ const backoffCapMs = opts.backoffCapMs ?? DEFAULT_BACKOFF_CAP_MS;
127
+ const setTimeoutFn = opts.setTimeoutFn ??
128
+ global.setTimeout;
129
+ const clearTimeoutFn = opts.clearTimeoutFn ?? global.clearTimeout;
130
+ const now = opts.nowFn ?? (() => Date.now());
131
+ let timer = null;
132
+ let generation = 0;
133
+ let consecutiveFailures = 0;
134
+ let scheduledFor = null;
135
+ /**
136
+ * S2 fix: when `consecutiveFailures` hits `maxFailures` we don't stop
137
+ * forever — instead we enter long-tail recovery and probe with
138
+ * exponential backoff. `longTailAttempt` is 0 outside long-tail and the
139
+ * 1-indexed attempt number while in it. Reused for delay computation:
140
+ * delay = min(INITIAL × FACTOR^(attempt-1), MAX).
141
+ */
142
+ let longTailAttempt = 0;
143
+ function bumpGeneration() {
144
+ generation += 1;
145
+ return generation;
146
+ }
147
+ function clearTimer() {
148
+ if (timer) {
149
+ try {
150
+ clearTimeoutFn(timer);
151
+ }
152
+ catch {
153
+ /* ignore */
154
+ }
155
+ timer = null;
156
+ }
157
+ scheduledFor = null;
158
+ }
159
+ function setDelayTimer(delayMs, gen) {
160
+ const effective = Math.max(MIN_DELAY_FLOOR_MS, delayMs);
161
+ scheduledFor = now() + effective;
162
+ const t = setTimeoutFn(() => {
163
+ void doRefresh(gen);
164
+ }, effective);
165
+ if (t && typeof t.unref === 'function') {
166
+ try {
167
+ t.unref();
168
+ }
169
+ catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+ timer = t;
174
+ }
175
+ /**
176
+ * H8 fix: a flat 5-minute buffer breaks for tokens with TTL < 10 min — the
177
+ * buffer meets-or-exceeds TTL, producing a <=0 baseDelay and an immediate-
178
+ * refresh loop. Clamp effective buffer to ttlMs/2 when configured buffer
179
+ * exceeds half the TTL. Never below MIN_REFRESH_BUFFER_FLOOR_MS so we
180
+ * preserve some lead time. When TTL/2 is itself below the floor (sub-10s
181
+ * tokens) the token is so short-lived we accept an immediate refresh.
182
+ */
183
+ function effectiveBufferForTtl(ttlMs) {
184
+ if (ttlMs <= 0)
185
+ return refreshBufferMs;
186
+ const half = ttlMs / 2;
187
+ if (refreshBufferMs > half) {
188
+ const clamped = Math.max(half, MIN_REFRESH_BUFFER_FLOOR_MS);
189
+ log.debug(`[${label}] refresh buffer clamped: ${formatDuration(refreshBufferMs)} → ${formatDuration(clamped)} (ttl=${formatDuration(ttlMs)})`);
190
+ return clamped;
191
+ }
192
+ return refreshBufferMs;
193
+ }
194
+ function schedule(token) {
195
+ const exp = decodeJwtExpiry(token);
196
+ clearTimer();
197
+ const gen = bumpGeneration();
198
+ // External re-arm exits long-tail mode. The generation bump already
199
+ // invalidates any in-flight long-tail callback; reset the counter here
200
+ // so the next failure run starts fresh.
201
+ longTailAttempt = 0;
202
+ consecutiveFailures = 0;
203
+ if (!exp) {
204
+ log.debug(`[${label}] token has no decodable exp; using fallback interval ${formatDuration(fallbackIntervalMs)}`);
205
+ setDelayTimer(fallbackIntervalMs, gen);
206
+ return;
207
+ }
208
+ const expMs = exp * 1000;
209
+ const ttlMs = expMs - now();
210
+ const effectiveBuffer = effectiveBufferForTtl(ttlMs);
211
+ const baseDelay = ttlMs - effectiveBuffer;
212
+ const jitter = (Math.random() * 2 - 1) * REFRESH_JITTER_MS;
213
+ const delayMs = baseDelay + jitter;
214
+ if (delayMs <= 0) {
215
+ log.debug(`[${label}] token exp=${new Date(expMs).toISOString()} is past or inside buffer — refreshing immediately`);
216
+ scheduledFor = now();
217
+ setDelayTimer(0, gen);
218
+ return;
219
+ }
220
+ log.debug(`[${label}] scheduling refresh in ${formatDuration(delayMs)} (exp=${new Date(expMs).toISOString()})`);
221
+ setDelayTimer(delayMs, gen);
222
+ }
223
+ function scheduleFromExpiresIn(expiresInSeconds) {
224
+ clearTimer();
225
+ const gen = bumpGeneration();
226
+ const ttlMs = expiresInSeconds * 1000;
227
+ const effectiveBuffer = effectiveBufferForTtl(ttlMs);
228
+ const delayMs = Math.max(ttlMs - effectiveBuffer, MIN_DELAY_FLOOR_MS);
229
+ log.debug(`[${label}] scheduling refresh in ${formatDuration(delayMs)} (expires_in=${expiresInSeconds}s, buffer=${formatDuration(effectiveBuffer)})`);
230
+ setDelayTimer(delayMs, gen);
231
+ }
232
+ function cancel() {
233
+ bumpGeneration();
234
+ clearTimer();
235
+ consecutiveFailures = 0;
236
+ longTailAttempt = 0;
237
+ }
238
+ async function doRefresh(gen) {
239
+ if (gen !== generation) {
240
+ log.debug(`[${label}] stale doRefresh (gen ${gen} vs ${generation}) — skipping`);
241
+ return;
242
+ }
243
+ let result = null;
244
+ try {
245
+ result = await opts.onRefresh();
246
+ }
247
+ catch (err) {
248
+ log.warn(`[${label}] onRefresh threw: ${err?.message}`);
249
+ result = null;
250
+ }
251
+ if (gen !== generation) {
252
+ log.debug(`[${label}] post-refresh stale (gen ${gen} vs ${generation}) — skipping follow-up`);
253
+ return;
254
+ }
255
+ if (!result) {
256
+ consecutiveFailures += 1;
257
+ if (consecutiveFailures >= maxFailures) {
258
+ // S2 fix: instead of stopping forever after maxFailures, enter
259
+ // long-tail recovery. The first time we hit the cap we log
260
+ // `daemon_token_refresh_give_up` for backwards-compatible alerting,
261
+ // then arm a slow exponential-backoff probe. On every long-tail
262
+ // attempt we emit a structured log so operators can alert on
263
+ // sustained degradation. Reset on success.
264
+ const longTailDelay = computeLongTailDelay(longTailAttempt);
265
+ const isFirstEntry = longTailAttempt === 0;
266
+ if (isFirstEntry) {
267
+ log.error('[auth/refresh] daemon_token_refresh_give_up', {
268
+ label,
269
+ failures: consecutiveFailures,
270
+ reason: 'max_consecutive_failures',
271
+ });
272
+ longTailAttempt = 1;
273
+ }
274
+ else {
275
+ longTailAttempt += 1;
276
+ }
277
+ log.warn('[auth/refresh] daemon_token_refresh_long_tail_attempt', {
278
+ label,
279
+ attempt: longTailAttempt,
280
+ nextDelayMs: longTailDelay,
281
+ });
282
+ setDelayTimer(longTailDelay, gen);
283
+ return;
284
+ }
285
+ log.warn(`[${label}] refresh failed (${consecutiveFailures}/${maxFailures}) — retrying in ${formatDuration(REFRESH_RETRY_DELAY_MS)}`);
286
+ setDelayTimer(REFRESH_RETRY_DELAY_MS, gen);
287
+ return;
288
+ }
289
+ // Successful refresh — fully recover from long-tail (if active) and
290
+ // chain-schedule from the new token's exp.
291
+ consecutiveFailures = 0;
292
+ longTailAttempt = 0;
293
+ const newExp = decodeJwtExpiry(result.accessToken);
294
+ if (newExp) {
295
+ schedule(result.accessToken);
296
+ }
297
+ else {
298
+ log.debug(`[${label}] refreshed token has no decodable exp — using fallback interval`);
299
+ clearTimer();
300
+ const nextGen = bumpGeneration();
301
+ setDelayTimer(fallbackIntervalMs, nextGen);
302
+ }
303
+ }
304
+ function onWake() {
305
+ if (scheduledFor === null)
306
+ return;
307
+ const drift = now() - scheduledFor;
308
+ const threshold = 2 * backoffCapMs;
309
+ if (drift <= threshold)
310
+ return;
311
+ log.info(`[${label}] wake detected drift ${formatDuration(drift)} past scheduled fire — rescheduling`);
312
+ const currentToken = opts.getAccessToken();
313
+ if (!currentToken) {
314
+ log.warn(`[${label}] onWake: no access token available; cancelling timer`);
315
+ cancel();
316
+ return;
317
+ }
318
+ schedule(currentToken);
319
+ }
320
+ return {
321
+ schedule,
322
+ scheduleFromExpiresIn,
323
+ cancel,
324
+ onWake,
325
+ get _debugState() {
326
+ return { scheduledFor, generation, consecutiveFailures, longTailAttempt };
327
+ },
328
+ };
329
+ }