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