@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,877 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.__refreshDiagnostics = exports.UNKNOWN_401_BUDGET = void 0;
7
+ exports.installLockExitHandlers = installLockExitHandlers;
8
+ exports.withAuthLock = withAuthLock;
9
+ exports.__resetUnknown401StreaksForTest = __resetUnknown401StreaksForTest;
10
+ exports.refreshDaemonToken = refreshDaemonToken;
11
+ exports.revokeDaemonRefreshSession = revokeDaemonRefreshSession;
12
+ exports.persistReauthState = persistReauthState;
13
+ exports.needsReauthState = needsReauthState;
14
+ exports.clearReauthState = clearReauthState;
15
+ exports.AUTH_LOCK_PATH_FN = authLockPath;
16
+ exports.REAUTH_FLAG_PATH_FN = reauthFlagPath;
17
+ /**
18
+ * refresh.ts — refresh-token client for OverSky daemon-style sessions.
19
+ *
20
+ * Responsibilities:
21
+ * 1. Single-flight per daemonId: concurrent callers share one in-flight HTTP
22
+ * request so we don't fire 6 refreshes for 6 parallel API calls (which
23
+ * would race-rotate and trip the server's family-reuse detector).
24
+ * 2. File-locked read-modify-write on `<configDir>/auth.lock` so the daemon,
25
+ * Electron main, and CLI can't corrupt the stored token pair.
26
+ * 3. Structured error classification — callers get either
27
+ * PermanentAuthFailure (stop reconnecting, show re-auth UX) or
28
+ * TransientAuthFailure (network glitch, retry later).
29
+ * 4. Persists a needs-reauth flag so the next start / Electron read can
30
+ * surface the banner without another round-trip.
31
+ *
32
+ * Lock strategy: `proper-lockfile` is battle-tested on Linux/macOS/Windows.
33
+ * Every read-modify-write is wrapped in `withAuthLock()`. The lock is
34
+ * released in `finally` — crash between acquire and release leaves a stale
35
+ * `.lock` file that `proper-lockfile` invalidates after `LOCK_STALE_MS`
36
+ * (30 s, longer than any reasonable refresh RTT).
37
+ */
38
+ const node_async_hooks_1 = require("node:async_hooks");
39
+ const node_crypto_1 = __importDefault(require("node:crypto"));
40
+ const node_fs_1 = __importDefault(require("node:fs"));
41
+ const node_path_1 = __importDefault(require("node:path"));
42
+ const proper_lockfile_1 = __importDefault(require("proper-lockfile"));
43
+ const runtime_js_1 = require("./runtime.js");
44
+ const types_js_1 = require("./types.js");
45
+ /**
46
+ * P1-6: Reduced from 30 s to 5 s.
47
+ *
48
+ * The original 30 s matched the worst-case refresh RTT — meaning a
49
+ * crashed process holding `auth.lock` could block all siblings for up
50
+ * to 30 s, risking a deadlock on degraded networks. 5 s is well above
51
+ * any realistic refresh RTT (p99 ≈ 50 ms) but low enough that a stale
52
+ * lock from a crashed daemon clears before the outer reconnect backoff
53
+ * times out.
54
+ */
55
+ const LOCK_STALE_MS = 5_000;
56
+ const LOCK_RETRY_MS = 100;
57
+ const LOCK_MAX_RETRIES = 50; // ~5 s total wait before giving up
58
+ /**
59
+ * P1-6: Module-level guard so registering process-exit handlers is
60
+ * idempotent. A second import (or a unit test that re-requires the
61
+ * module) must not double-register the same handlers.
62
+ */
63
+ let _exitHandlersInstalled = false;
64
+ /**
65
+ * D-4 — Re-entrancy tracking for `withAuthLock`.
66
+ *
67
+ * `proper-lockfile` is NOT re-entrant within a process: a second
68
+ * `lockfile.lock()` call on the same file from the same process will
69
+ * block until `LOCK_STALE_MS` (5 s) elapses and treat itself as stale —
70
+ * effectively a 5 s deadlock. Several call sites legitimately nest:
71
+ *
72
+ * performRefresh → withAuthLock → saveToken → withAuthLock (new)
73
+ * consumeFdAuth → saveToken → withAuthLock (new, no outer lock)
74
+ *
75
+ * `AsyncLocalStorage` propagates across `await` boundaries within the
76
+ * same async call chain, so a nested `withAuthLock` invocation in the
77
+ * same chain can detect "outer lock already held" and skip the
78
+ * `proper-lockfile` acquire. Cross-process callers each have their own
79
+ * ALS store and always go through the real file lock.
80
+ *
81
+ * The store carries a boolean: `true` = the current async context
82
+ * already holds the auth lock.
83
+ */
84
+ const _authLockHeld = new node_async_hooks_1.AsyncLocalStorage();
85
+ function authLockPath() {
86
+ return node_path_1.default.join((0, runtime_js_1.getAuthConfigDir)(), 'auth.lock');
87
+ }
88
+ function reauthFlagPath() {
89
+ return node_path_1.default.join((0, runtime_js_1.getAuthConfigDir)(), 'needs-reauth.json');
90
+ }
91
+ /**
92
+ * P1-6 — Register best-effort lock-release handlers on process exit
93
+ * signals so a crash or SIGTERM during a refresh leaves the lock file
94
+ * in a released state. Without this, a sibling process must wait up to
95
+ * `LOCK_STALE_MS` (5 s) for `proper-lockfile` to declare the lock stale.
96
+ *
97
+ * Guards:
98
+ * - `_exitHandlersInstalled` (module-level): safe to call multiple
99
+ * times (e.g., from unit-test re-imports) — handlers are registered
100
+ * exactly once per process.
101
+ * - Every handler swallows errors: `lockfile.unlockSync` throws when
102
+ * the lock file doesn't exist or was never acquired; we must not
103
+ * crash the process during shutdown.
104
+ *
105
+ * `lockfile.unlockSync` is available in `proper-lockfile` ≥ 4.1.0 and
106
+ * removes the `.lock` companion directory that `proper-lockfile` creates
107
+ * next to `auth.lock`. It is synchronous so it runs safely in POSIX
108
+ * signal handlers and `process.on('exit')`.
109
+ */
110
+ function installLockExitHandlers() {
111
+ if (_exitHandlersInstalled)
112
+ return;
113
+ _exitHandlersInstalled = true;
114
+ function tryRelease() {
115
+ try {
116
+ proper_lockfile_1.default.unlockSync(authLockPath());
117
+ }
118
+ catch {
119
+ /* lock not held or already released — not an error */
120
+ }
121
+ }
122
+ process.on('exit', tryRelease);
123
+ process.on('SIGTERM', () => {
124
+ tryRelease();
125
+ process.exit(0);
126
+ });
127
+ process.on('SIGINT', () => {
128
+ tryRelease();
129
+ process.exit(130);
130
+ });
131
+ process.on('SIGHUP', () => {
132
+ tryRelease();
133
+ process.exit(1);
134
+ });
135
+ }
136
+ /**
137
+ * Acquire the cross-process auth lock, run the body, release on `finally`.
138
+ * Throws if the lock can't be acquired within
139
+ * `LOCK_MAX_RETRIES × LOCK_RETRY_MS`.
140
+ *
141
+ * We lock a sentinel file (auth.lock) that we create/touch if missing —
142
+ * locking the real auth.json would race with atomic-rename writes.
143
+ *
144
+ * @param daemonId When supplied, lock-lifecycle telemetry events are emitted:
145
+ * refresh_lock_acquiring, refresh_lock_acquired, refresh_lock_released.
146
+ */
147
+ async function withAuthLock(body, daemonId) {
148
+ // D-4 — Re-entrancy guard. If the current async context already holds
149
+ // the auth lock (e.g. performRefresh → withAuthLock → saveToken →
150
+ // withAuthLock), skip the proper-lockfile acquire to avoid the 5 s
151
+ // deadlock that would occur because proper-lockfile is not re-entrant
152
+ // within a process. Cross-process callers each have their own ALS store
153
+ // and always go through the real file lock.
154
+ if (_authLockHeld.getStore() === true) {
155
+ return body();
156
+ }
157
+ const lockPath = authLockPath();
158
+ const dir = node_path_1.default.dirname(lockPath);
159
+ if (!node_fs_1.default.existsSync(dir))
160
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
161
+ if (!node_fs_1.default.existsSync(lockPath)) {
162
+ node_fs_1.default.writeFileSync(lockPath, '', { mode: 0o600 });
163
+ }
164
+ const acquireStart = Date.now();
165
+ (0, runtime_js_1.emitAuthTelemetry)({
166
+ timestamp: new Date().toISOString(),
167
+ eventType: 'auth',
168
+ success: true,
169
+ metadata: { event: 'refresh_lock_acquiring', ...(daemonId ? { daemonId } : {}) },
170
+ });
171
+ const release = await proper_lockfile_1.default.lock(lockPath, {
172
+ stale: LOCK_STALE_MS,
173
+ retries: {
174
+ retries: LOCK_MAX_RETRIES,
175
+ minTimeout: LOCK_RETRY_MS,
176
+ maxTimeout: LOCK_RETRY_MS * 4,
177
+ factor: 1.5,
178
+ },
179
+ });
180
+ const acquiredAt = Date.now();
181
+ (0, runtime_js_1.emitAuthTelemetry)({
182
+ timestamp: new Date().toISOString(),
183
+ eventType: 'auth',
184
+ success: true,
185
+ metadata: {
186
+ event: 'refresh_lock_acquired',
187
+ waitedMs: acquiredAt - acquireStart,
188
+ ...(daemonId ? { daemonId } : {}),
189
+ },
190
+ });
191
+ // Run body inside ALS context marking the lock as held for this async chain.
192
+ return _authLockHeld.run(true, async () => {
193
+ try {
194
+ return await body();
195
+ }
196
+ finally {
197
+ const heldForMs = Date.now() - acquiredAt;
198
+ (0, runtime_js_1.emitAuthTelemetry)({
199
+ timestamp: new Date().toISOString(),
200
+ eventType: 'auth',
201
+ success: true,
202
+ metadata: {
203
+ event: 'refresh_lock_released',
204
+ heldForMs,
205
+ ...(daemonId ? { daemonId } : {}),
206
+ },
207
+ });
208
+ try {
209
+ await release();
210
+ }
211
+ catch (err) {
212
+ (0, runtime_js_1.getAuthLogger)().debug(`[auth/refresh] lock release failed: ${err?.message}`);
213
+ }
214
+ }
215
+ });
216
+ }
217
+ /**
218
+ * In-flight refresh promises keyed by daemonId. Coalesces concurrent callers.
219
+ *
220
+ * Keying matters: an unkeyed map silently returned the wrong pair if two
221
+ * different daemons (or a future Electron host running multiple embedded
222
+ * daemons) refreshed simultaneously in the same process. Today most
223
+ * processes are single-instance, but the invariant is cheaper to preserve
224
+ * than to debug later.
225
+ *
226
+ * Defense-in-depth: the `.finally(() => delete)` is the primary cleanup,
227
+ * but if a future caller throws inside that callback the entry would leak
228
+ * forever and silently mask every subsequent refresh attempt for the same
229
+ * daemonId. `INFLIGHT_REAP_MS` provides a forced cleanup ceiling well above
230
+ * any reasonable refresh RTT (default 60 s ≫ p99 ~50 ms).
231
+ */
232
+ const inflightRefreshes = new Map();
233
+ const INFLIGHT_REAP_MS = 60_000;
234
+ /**
235
+ * Consecutive unknown-shape-401 budget per daemonId.
236
+ *
237
+ * A 401 from `/token/refresh` whose body `code` is NOT one of the four
238
+ * credential-death codes (REFRESH_INVALID / REFRESH_REUSED /
239
+ * REFRESH_EXPIRED / SESSION_REVOKED) — an unknown code, a missing code,
240
+ * or an unparseable body — is most likely an artifact of infra OverSky
241
+ * does not fully control: a proxy/middleware regression, a clock-skew
242
+ * replay rejection (DEVICE_PROOF_REPLAYED), or a half-rolled-out server
243
+ * route. By construction it is NOT a daemon's actual refresh-family burn
244
+ * (those arrive as one of the four known codes).
245
+ *
246
+ * Pre-fix every unknown-shape 401 latched `PermanentAuthFailure('UNKNOWN_401')`
247
+ * + `persistReauthState` on the FIRST occurrence — forcing a manual
248
+ * reconnect even when the next refresh would have succeeded. Now the
249
+ * first `UNKNOWN_401_BUDGET` unknown-401s accumulated on a given daemonId
250
+ * (within `UNKNOWN_401_WINDOW_MS`, see the reset/window rules below) throw
251
+ * `TransientAuthFailure` instead, so the daemon's
252
+ * `performRefresh → return false` path lets the refreshScheduler /
253
+ * reactive retry+backoff ride out a transient regression WITHOUT latching
254
+ * needs-reauth. Only after the budget is exhausted do we fall back to the
255
+ * original permanent latch — preserving the iron rule that the interactive
256
+ * re-auth modal remains the final fallback.
257
+ *
258
+ * The four KNOWN codes are genuinely permanent and are NEVER routed
259
+ * through this budget — they latch immediately as before.
260
+ *
261
+ * Counter semantics:
262
+ * - Incremented on each consecutive unknown-shape 401 for a daemonId.
263
+ * - Reset to zero ONLY on a DEFINITIVE outcome that tells us about the
264
+ * credential's true state: a 200 success (credential alive) OR a
265
+ * KNOWN-permanent 401 (credential dead, recognized). It is DELIBERATELY
266
+ * NOT reset on a transient non-401 outcome (network error, 429, 5xx).
267
+ *
268
+ * RESET SEMANTICS (Finding 4 follow-up — deliberate). Originally the
269
+ * streak reset on ANY non-unknown-401 outcome, including transient ones.
270
+ * That created a retry-forever hole: a dead-but-unknown-code credential
271
+ * whose endpoint INTERMITTENTLY returns transient errors (a flaky proxy
272
+ * that alternates 503 ↔ unknown-401, say) could never accumulate
273
+ * `UNKNOWN_401_BUDGET` consecutive unknown-401s — every transient blip
274
+ * wiped the streak — so it would never latch and the daemon would retry
275
+ * forever, never surfacing the re-auth affordance. By treating only
276
+ * success / known-permanent-401 as streak-resetting, a flaky-but-dead
277
+ * unknown credential still converges to the permanent latch: the
278
+ * unknown-401s accumulate ACROSS interleaved transient noise (subject to
279
+ * the window below) and eventually exhaust the budget.
280
+ * - Bounded window: if the previous unknown-401 for this daemonId is
281
+ * older than `UNKNOWN_401_WINDOW_MS`, the run is considered stale and
282
+ * the counter restarts at 1. This bounds how far apart interleaved
283
+ * unknown-401s can be and still count toward the same latch, so two
284
+ * genuinely unrelated incidents hours apart don't sum into a premature
285
+ * latch — while a flaky-but-dead credential, which keeps producing
286
+ * unknown-401s well within the window, still converges.
287
+ */
288
+ /**
289
+ * BOOT-BUDGET COUPLING (Finding 4 follow-up). Exported so the daemon's
290
+ * startup-recovery loop can statically assert
291
+ * `STARTUP_RECOVERY_MAX_ATTEMPTS >= UNKNOWN_401_BUDGET`.
292
+ *
293
+ * Why the coupling is load-bearing: the boot path
294
+ * (`recoverAccessTokenAtStartup` in `daemon/src/auth.ts`) retries
295
+ * `transient_error` results up to `STARTUP_RECOVERY_MAX_ATTEMPTS` times,
296
+ * then exits `EX_TEMPFAIL` so the supervisor restarts the process. Each
297
+ * in-budget unknown-shape 401 is classified `transient_error`. If the
298
+ * boot loop's max attempts were LESS than this budget, a
299
+ * genuinely-dead-but-unknown-code credential would make the boot loop
300
+ * give up (EX_TEMPFAIL → supervisor restart) BEFORE the in-process
301
+ * budget ever exhausts and latches `PermanentAuthFailure('UNKNOWN_401')`
302
+ * + persists needs-reauth. The supervisor would then restart-loop
303
+ * forever and the user would NEVER see the re-auth affordance — a
304
+ * strand. Keeping `STARTUP_RECOVERY_MAX_ATTEMPTS >= UNKNOWN_401_BUDGET`
305
+ * guarantees the in-process budget exhausts and latches within a single
306
+ * boot, on the final boot attempt, before control returns to the
307
+ * supervisor. The daemon enforces this with a module-load static
308
+ * assertion (`assertStartupRecoveryCoversUnknown401Budget`) plus a unit
309
+ * test; see `daemon/src/auth.ts`.
310
+ */
311
+ exports.UNKNOWN_401_BUDGET = 3;
312
+ const UNKNOWN_401_WINDOW_MS = 5 * 60_000;
313
+ const unknown401Streaks = new Map();
314
+ /**
315
+ * Record one unknown-shape 401 for `daemonId` and report whether the
316
+ * consecutive budget is now exhausted (i.e. this occurrence should latch
317
+ * permanent) or still within budget (i.e. this occurrence should be
318
+ * treated as transient and retried).
319
+ *
320
+ * Returns `{ exhausted, count }` where `count` is the post-increment
321
+ * consecutive streak length. When `exhausted` is true the streak is
322
+ * cleared so a subsequent successful re-auth + future regression starts
323
+ * a fresh budget rather than latching on its first unknown-401.
324
+ */
325
+ function recordUnknown401(daemonId, now) {
326
+ const prev = unknown401Streaks.get(daemonId);
327
+ // A gap longer than the window means the prior unknown-401 belongs to a
328
+ // different, already-resolved incident — restart the streak at 1.
329
+ const count = prev && now - prev.lastAt <= UNKNOWN_401_WINDOW_MS ? prev.count + 1 : 1;
330
+ if (count >= exports.UNKNOWN_401_BUDGET) {
331
+ // Budget exhausted — clear so the next regression (after a successful
332
+ // re-auth) gets its own fresh budget instead of latching immediately.
333
+ unknown401Streaks.delete(daemonId);
334
+ return { exhausted: true, count };
335
+ }
336
+ unknown401Streaks.set(daemonId, { count, lastAt: now });
337
+ return { exhausted: false, count };
338
+ }
339
+ /**
340
+ * Reset the unknown-shape-401 streak for `daemonId`. Called ONLY on the
341
+ * two DEFINITIVE outcomes — a 200 success or a KNOWN-permanent 401 — that
342
+ * tell us the credential's true state. Transient non-401 outcomes
343
+ * (network error, 429, 5xx) intentionally do NOT call this, so a
344
+ * flaky-but-dead unknown credential still converges to the permanent
345
+ * latch instead of retrying forever. See the `UNKNOWN_401_BUDGET` /
346
+ * `recordUnknown401` block above for the full rationale.
347
+ */
348
+ function resetUnknown401Streak(daemonId) {
349
+ unknown401Streaks.delete(daemonId);
350
+ }
351
+ /** Test-only: clear all per-daemonId unknown-401 streak state. */
352
+ function __resetUnknown401StreaksForTest() {
353
+ unknown401Streaks.clear();
354
+ }
355
+ /**
356
+ * H1 — Test-only diagnostic counters that prove every outgoing refresh
357
+ * request to `/refresh` is paired with a freshly-built proof.
358
+ *
359
+ * - `requestAttemptCount`: incremented each time `buildRequestInit()`
360
+ * prepares an outgoing fetch.
361
+ * - `proofAttemptCount`: incremented each time `signRequestProof` is
362
+ * invoked (success, null return, OR thrown — they all count as
363
+ * "an attempt was made").
364
+ *
365
+ * Invariant under test: when `signRequestProof` is supplied,
366
+ * `proofAttemptCount === requestAttemptCount` after each call. A future
367
+ * retry that forgets to re-call `buildRequestInit()` (or that hoists
368
+ * the proof outside the loop) breaks this invariant immediately.
369
+ *
370
+ * Exposed only for tests — no production code reads it. Callers can
371
+ * snapshot + reset between asserts.
372
+ */
373
+ exports.__refreshDiagnostics = {
374
+ proofAttemptCount: 0,
375
+ requestAttemptCount: 0,
376
+ reset() {
377
+ this.proofAttemptCount = 0;
378
+ this.requestAttemptCount = 0;
379
+ },
380
+ };
381
+ async function refreshDaemonToken(serverUrl, currentRefreshToken, daemonId, options = {}) {
382
+ const existing = inflightRefreshes.get(daemonId);
383
+ if (existing) {
384
+ // A concurrent caller is coalescing onto the in-flight promise.
385
+ // refresh_race_resolved fires when it resolves (the caller adopted
386
+ // the sibling's token rather than firing a duplicate HTTP request).
387
+ return existing.then((pair) => {
388
+ (0, runtime_js_1.emitAuthTelemetry)({
389
+ timestamp: new Date().toISOString(),
390
+ eventType: 'auth',
391
+ success: true,
392
+ metadata: { event: 'refresh_race_resolved', daemonId, sourceTokenAdopted: true },
393
+ });
394
+ return pair;
395
+ });
396
+ }
397
+ const p = doRefresh(serverUrl, currentRefreshToken, daemonId, options).finally(() => {
398
+ // Only delete our own entry — a racing caller could have replaced it,
399
+ // but with keying by daemonId that would be a different key.
400
+ if (inflightRefreshes.get(daemonId) === p) {
401
+ inflightRefreshes.delete(daemonId);
402
+ }
403
+ });
404
+ inflightRefreshes.set(daemonId, p);
405
+ // Belt-and-suspenders reap if the `finally` ever throws (logger crash etc.).
406
+ const reaper = setTimeout(() => {
407
+ if (inflightRefreshes.get(daemonId) === p) {
408
+ try {
409
+ (0, runtime_js_1.getAuthLogger)().warn(`[auth/refresh] inflight reap fired for ${daemonId} — finally cleanup did not complete within ${INFLIGHT_REAP_MS}ms`);
410
+ }
411
+ catch {
412
+ /* logger unavailable */
413
+ }
414
+ inflightRefreshes.delete(daemonId);
415
+ }
416
+ }, INFLIGHT_REAP_MS);
417
+ if (typeof reaper.unref === 'function')
418
+ reaper.unref();
419
+ return p;
420
+ }
421
+ /**
422
+ * Best-effort server-side revocation of a refresh session. Idempotent on
423
+ * the server — never reveals whether the presented token existed. Returns
424
+ * silently on transport failure; callers still proceed with local cleanup.
425
+ *
426
+ * Kept tight: 5-second timeout so a hung server doesn't stall logout.
427
+ *
428
+ * H1 (HIGH) — wire contract: the body carries BOTH `refreshTokenHash`
429
+ * (preferred — SHA-256 hex digest of the plaintext refresh token, the
430
+ * exact value the server stores in `refreshHash`) AND the legacy
431
+ * `refreshToken` plaintext during a migration window. Newer servers
432
+ * short-circuit on `refreshTokenHash` and never read the plaintext;
433
+ * older servers ignore the unknown field and fall through to the
434
+ * legacy `refreshToken` path. Once the next API release ships the
435
+ * hash-aware revoke route to the entire fleet, the legacy plaintext
436
+ * field can be dropped from this body.
437
+ *
438
+ * Why both during migration: any intermediary that logs request
439
+ * bodies (CDN, WAF, API gateway, corporate proxy) would otherwise
440
+ * capture a usable refresh token in plaintext. The refresh path
441
+ * already only sends what the server hashes server-side; the revoke
442
+ * path's contract previously leaked the token in transit. Sending
443
+ * the hash makes the body ineligible as a credential for any future
444
+ * replay attempt while keeping back-compat with un-upgraded servers.
445
+ */
446
+ async function revokeDaemonRefreshSession(serverUrl, refreshToken) {
447
+ if (!refreshToken)
448
+ return;
449
+ const log = (0, runtime_js_1.getAuthLogger)();
450
+ const url = `${serverUrl.replace(/\/+$/, '')}/api/daemons/token/revoke`;
451
+ const controller = new AbortController();
452
+ const timer = setTimeout(() => controller.abort(), 5_000);
453
+ // H1 — SHA-256 hex digest matches the server's `hashRefreshToken`
454
+ // helper byte-for-byte: `crypto.createHash('sha256').update(token).digest('hex')`.
455
+ // 64-char lowercase hex string, the exact lookup key in the
456
+ // `refreshHash` index.
457
+ const refreshTokenHash = node_crypto_1.default.createHash('sha256').update(refreshToken, 'utf8').digest('hex');
458
+ try {
459
+ const res = await fetch(url, {
460
+ method: 'POST',
461
+ headers: { 'Content-Type': 'application/json' },
462
+ body: JSON.stringify({ refreshTokenHash, refreshToken }),
463
+ signal: controller.signal,
464
+ });
465
+ (0, runtime_js_1.notifyResponseObserver)(res);
466
+ if (!res.ok) {
467
+ log.debug(`[auth/refresh] revoke returned ${res.status}`);
468
+ }
469
+ }
470
+ catch (err) {
471
+ log.debug(`[auth/refresh] revoke network error: ${err?.message}`);
472
+ }
473
+ finally {
474
+ clearTimeout(timer);
475
+ }
476
+ }
477
+ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {}) {
478
+ const log = (0, runtime_js_1.getAuthLogger)();
479
+ const startMs = Date.now();
480
+ (0, runtime_js_1.emitAuthTelemetry)({
481
+ timestamp: new Date().toISOString(),
482
+ eventType: 'auth',
483
+ success: true,
484
+ metadata: { event: 'refresh_start', daemonId },
485
+ });
486
+ const url = `${serverUrl.replace(/\/+$/, '')}/api/daemons/token/refresh`;
487
+ // Build body with machineUuid only when supplied — preserves backward
488
+ // compat with older servers that ignore unknown fields. The server's
489
+ // soft-binding check (H7) tolerates either side missing the field.
490
+ const body = {
491
+ refreshToken: currentRefreshToken,
492
+ daemonId,
493
+ };
494
+ if (options.machineUuid) {
495
+ body.machineUuid = options.machineUuid;
496
+ }
497
+ // H1 — Per-attempt request builder. The proof MUST be minted once
498
+ // per outgoing HTTP request: every fetch carries a fresh `iat` +
499
+ // `jti` so the server-side replay cache (`seen-jti:{thumbprint}:{jti}`,
500
+ // see `api/server/services/Auth/jtiReplayCache.js`) never rejects a
501
+ // legitimate retry as a duplicate. If the proof were minted once and
502
+ // reused across retries, every retry would deterministically lose
503
+ // to the cache.
504
+ //
505
+ // Today the function only makes one HTTP attempt — but the contract
506
+ // is "every outgoing request to /refresh uses a freshly-built proof"
507
+ // and we want the structure to enforce it before any future retry
508
+ // logic re-introduces the bug. The attempt counter is exposed via
509
+ // a test-only diagnostic (`__refreshDiagnostics`) so we can assert
510
+ // the invariant without exporting a private control surface.
511
+ //
512
+ // FOOTGUN — do NOT hoist `buildRequestInit` outside this function or
513
+ // outside any future retry loop. The structural test
514
+ // "proof-per-attempt invariant: a retry loop must call buildRequestInit()
515
+ // per attempt" in `refresh.deviceProof.test.ts` asserts the function
516
+ // is defined inside `doRefresh` and will fail if it is moved.
517
+ function buildRequestInit() {
518
+ // Phase 3-R5: the stable `X-Oversky-Origin` header is the server's
519
+ // preferred identity signal on the benign-grace rotation match. Newer
520
+ // servers prefer it over the raw User-Agent; older servers ignore
521
+ // unknown headers. Safe one-sided change either way.
522
+ const headers = { 'Content-Type': 'application/json' };
523
+ if (options.origin) {
524
+ headers['X-Oversky-Origin'] = options.origin;
525
+ }
526
+ // L11 Phase 3 — attach a device-identity proof when the daemon-side
527
+ // bridge can mint one. A null/undefined return or a thrown callback
528
+ // is non-fatal: the request goes out without the header and the
529
+ // server's lenient middleware (`off` / `warn` mode default) accepts
530
+ // it. Once the operator flips `OVERSKY_DEVICE_PROOF_ENFORCE=enforce`
531
+ // on the server, a missing-proof request from an enrolled daemon
532
+ // becomes a 401 — but daemons that reach this code path always have
533
+ // device identity initialized via `prefetchAuth()`, so the only
534
+ // legitimate "missing proof" case is a daemon binary older than
535
+ // Phase 1.
536
+ exports.__refreshDiagnostics.requestAttemptCount += 1;
537
+ if (options.signRequestProof) {
538
+ try {
539
+ const proof = options.signRequestProof({
540
+ method: 'POST',
541
+ url,
542
+ refreshToken: currentRefreshToken,
543
+ });
544
+ // Count every callback invocation, including ones that
545
+ // returned null/empty/threw. The invariant we care about is
546
+ // "one mint per attempt" — whether the mint produced a usable
547
+ // header is a separate concern (legacy daemons, key not yet
548
+ // initialized, etc.).
549
+ exports.__refreshDiagnostics.proofAttemptCount += 1;
550
+ if (typeof proof === 'string' && proof.length > 0) {
551
+ headers['OverSky-DPoP'] = proof;
552
+ }
553
+ }
554
+ catch (err) {
555
+ // Even a thrown callback counts as an attempt — the daemon
556
+ // bridge tried to mint, the mint failed, and we proceed
557
+ // without the header. Without this count the invariant
558
+ // would silently drift if a future regression made the
559
+ // callback throw on every-other call.
560
+ exports.__refreshDiagnostics.proofAttemptCount += 1;
561
+ log.debug(`[auth/refresh] signRequestProof threw — proceeding without device-identity header: ${err?.message ?? 'unknown'}`);
562
+ }
563
+ }
564
+ return {
565
+ method: 'POST',
566
+ headers,
567
+ body: JSON.stringify(body),
568
+ };
569
+ }
570
+ let response;
571
+ try {
572
+ // Single attempt today. The builder structure is the load-bearing
573
+ // bit: any future retry must call `buildRequestInit()` again so
574
+ // the per-attempt invariant — fresh jti per request — holds.
575
+ response = await fetch(url, buildRequestInit());
576
+ (0, runtime_js_1.notifyResponseObserver)(response);
577
+ }
578
+ catch (err) {
579
+ log.warn(`[auth/refresh] network error: ${err?.message}`);
580
+ // RESET SEMANTICS (Finding 4 follow-up): a transient (network) outcome
581
+ // does NOT reset the unknown-401 streak. Resetting here would let a
582
+ // dead-but-unknown-code credential whose endpoint INTERMITTENTLY drops
583
+ // the connection never accumulate to the latch — it would retry forever
584
+ // because every other network blip wipes the streak. The streak resets
585
+ // ONLY on a 200 success or a KNOWN-permanent 401 (see those branches).
586
+ (0, runtime_js_1.emitAuthTelemetry)({
587
+ timestamp: new Date().toISOString(),
588
+ eventType: 'auth',
589
+ success: false,
590
+ metadata: {
591
+ event: 'refresh_failure',
592
+ daemonId,
593
+ durationMs: Date.now() - startMs,
594
+ errorCode: 'NETWORK_ERROR',
595
+ },
596
+ });
597
+ throw new types_js_1.TransientAuthFailure('Network error during refresh', err);
598
+ }
599
+ // TODO: refresh.ts could delegate to classifyRefreshResponse(response.status, body)
600
+ // from ./refreshClassification.ts once mobile parity is validated and device tests
601
+ // cover the 401/429/5xx branches. Today the inline classification is kept to avoid
602
+ // the risk of a behavioural regression in the daemon's S1/S2/S3 paths without full
603
+ // test coverage. See packages/auth-core/src/refreshClassification.ts for the
604
+ // extracted pure helper that mobile uses.
605
+ if (response.status === 200) {
606
+ const data = (await response.json());
607
+ // Soft backoff hint (production-hardening 2026-05-06). Server-side
608
+ // sets `X-Refresh-Backoff: <seconds>` when fleet refresh-QPS is
609
+ // high; the daemon's scheduler should defer its NEXT proactive
610
+ // refresh by that amount + jitter. Distinct from 429 Retry-After
611
+ // which signals "this request was rejected" — soft backoff fires
612
+ // on 200 responses, the rotation succeeded, and ignoring the hint
613
+ // is safe but causes the herd to reconverge.
614
+ //
615
+ // Parse defensively: a malformed header (negative, NaN, absurdly
616
+ // large) produces `undefined` so consumers fall through to their
617
+ // own scheduler defaults.
618
+ // Cap at 10 min so a misconfigured / compromised server cannot pin
619
+ // daemons to a multi-hour interval and starve them of refresh.
620
+ const SOFT_BACKOFF_MAX_SECONDS = 600;
621
+ let softBackoffSeconds;
622
+ const backoffHeader = response.headers.get('X-Refresh-Backoff');
623
+ if (typeof backoffHeader === 'string') {
624
+ const parsed = parseInt(backoffHeader, 10);
625
+ if (Number.isFinite(parsed) && parsed > 0) {
626
+ if (parsed > SOFT_BACKOFF_MAX_SECONDS) {
627
+ (0, runtime_js_1.getAuthLogger)().warn(`[auth/refresh] X-Refresh-Backoff=${parsed}s exceeds ${SOFT_BACKOFF_MAX_SECONDS}s cap — clamping`);
628
+ }
629
+ softBackoffSeconds = Math.min(parsed, SOFT_BACKOFF_MAX_SECONDS);
630
+ }
631
+ }
632
+ const pair = {
633
+ // Coerce empty string / undefined to null so consumers branch
634
+ // on a single canonical shape (the wire may legitimately ship
635
+ // any of three things for daemon scope: `null` literal, field
636
+ // absent, or empty string). Non-daemon scopes always supply a
637
+ // non-empty JWT here.
638
+ accessToken: typeof data.accessToken === 'string' && data.accessToken.length > 0
639
+ ? data.accessToken
640
+ : null,
641
+ accessExpiresAt: new Date(data.accessExpiresAt).getTime(),
642
+ refreshToken: data.refreshToken,
643
+ refreshExpiresAt: new Date(data.refreshExpiresAt).getTime(),
644
+ // Distinguish "field absent" (older server, leave caller's
645
+ // existing value alone) from "field present but null" (server
646
+ // explicitly cleared, callers MUST overwrite their cache).
647
+ // Three-valued semantic mirrors the daemon-side saveToken
648
+ // options bag — see daemon/src/auth.ts JSDoc.
649
+ opaqueAccessToken: Object.prototype.hasOwnProperty.call(data, 'opaqueAccessToken')
650
+ ? typeof data.opaqueAccessToken === 'string'
651
+ ? data.opaqueAccessToken
652
+ : null
653
+ : undefined,
654
+ ...(softBackoffSeconds !== undefined ? { softBackoffSeconds } : {}),
655
+ };
656
+ log.info(`[auth/refresh] rotated — access expires in ${Math.round((pair.accessExpiresAt - Date.now()) / 1000)}s`);
657
+ (0, runtime_js_1.emitAuthTelemetry)({
658
+ timestamp: new Date().toISOString(),
659
+ eventType: 'auth',
660
+ success: true,
661
+ metadata: {
662
+ event: 'refresh_success',
663
+ daemonId,
664
+ durationMs: Date.now() - startMs,
665
+ tokensRotated: true,
666
+ },
667
+ });
668
+ // RESET SEMANTICS (Finding 4 follow-up): a 200 success is one of only
669
+ // TWO outcomes that reset the unknown-401 streak (the other is a
670
+ // KNOWN-permanent 401). A success PROVES the credential is alive and the
671
+ // endpoint healthy, so any prior unknown-401 run was genuinely transient
672
+ // — the next regression starts with a fresh budget. Transient non-401
673
+ // outcomes (network/429/5xx) deliberately do NOT reset, so a flaky-but-
674
+ // dead unknown credential still converges to the permanent latch.
675
+ resetUnknown401Streak(daemonId);
676
+ try {
677
+ clearReauthState();
678
+ }
679
+ catch {
680
+ /* ignore */
681
+ }
682
+ return pair;
683
+ }
684
+ if (response.status === 429) {
685
+ // RESET SEMANTICS (Finding 4 follow-up): a 429 is transient and does
686
+ // NOT reset the unknown-401 streak. A dead-but-unknown-code credential
687
+ // whose endpoint also intermittently rate-limits must still converge to
688
+ // the permanent latch; resetting here would let it retry forever.
689
+ (0, runtime_js_1.emitAuthTelemetry)({
690
+ timestamp: new Date().toISOString(),
691
+ eventType: 'auth',
692
+ success: false,
693
+ metadata: {
694
+ event: 'refresh_failure',
695
+ daemonId,
696
+ durationMs: Date.now() - startMs,
697
+ errorCode: 'RATE_LIMITED',
698
+ statusCode: 429,
699
+ },
700
+ });
701
+ throw new types_js_1.TransientAuthFailure('Refresh rate-limited by server');
702
+ }
703
+ if (response.status === 401) {
704
+ let parsedBody = {};
705
+ try {
706
+ parsedBody = (await response.json());
707
+ }
708
+ catch {
709
+ /* ignore */
710
+ }
711
+ const code = parsedBody.code;
712
+ const permanent = [
713
+ 'REFRESH_INVALID',
714
+ 'REFRESH_REUSED',
715
+ 'REFRESH_EXPIRED',
716
+ 'SESSION_REVOKED',
717
+ ];
718
+ if (code && permanent.includes(code)) {
719
+ // A KNOWN credential-death code is genuinely permanent — latch
720
+ // immediately, exactly as before. It is NOT routed through the
721
+ // unknown-401 budget.
722
+ //
723
+ // RESET SEMANTICS (Finding 4 follow-up): this is one of only TWO
724
+ // outcomes that reset the unknown-401 streak (the other is a 200
725
+ // success). We reset here because the server just spoke a definitive,
726
+ // recognized verdict about the credential — whatever it says next
727
+ // about an unknown-shape 401 starts a clean run, and we also prevent a
728
+ // prior unknown-401 run from compounding with a real family-burn.
729
+ resetUnknown401Streak(daemonId);
730
+ log.warn(`[auth/refresh] permanent failure: ${code} — ${parsedBody.message ?? ''}`);
731
+ (0, runtime_js_1.emitAuthTelemetry)({
732
+ timestamp: new Date().toISOString(),
733
+ eventType: 'auth',
734
+ success: false,
735
+ metadata: {
736
+ event: 'refresh_failure',
737
+ daemonId,
738
+ durationMs: Date.now() - startMs,
739
+ errorCode: code,
740
+ statusCode: 401,
741
+ },
742
+ });
743
+ persistReauthState({ code, message: parsedBody.message, at: Date.now() });
744
+ throw new types_js_1.PermanentAuthFailure(code, parsedBody.message || code);
745
+ }
746
+ // Unknown-shape 401 — unknown code, missing code, or unparseable body.
747
+ // By construction this is NOT a daemon's actual refresh-family burn
748
+ // (those arrive as one of the four KNOWN codes above); it most likely
749
+ // came from infra OverSky does not fully control — a proxy/middleware
750
+ // regression, a clock-skew replay rejection (DEVICE_PROOF_REPLAYED),
751
+ // or a half-rolled-out server route.
752
+ //
753
+ // Pre-fix every such 401 latched `PermanentAuthFailure('UNKNOWN_401')`
754
+ // + `persistReauthState` on the FIRST occurrence, forcing a manual
755
+ // reconnect even when the next refresh would have succeeded. Now we
756
+ // give a bounded retry budget: the first `UNKNOWN_401_BUDGET`
757
+ // consecutive unknown-401s on this daemonId throw `TransientAuthFailure`
758
+ // (NO persistReauthState), so the daemon's `performRefresh → return false`
759
+ // path lets the scheduler / reactive retry+backoff ride out a transient
760
+ // regression. Only once the budget is exhausted do we fall back to the
761
+ // original permanent latch — keeping the interactive re-auth modal as
762
+ // the FINAL fallback. The distinct `UNKNOWN_401` telemetry tag is kept
763
+ // on BOTH branches so monitoring can still see a server-side bug
764
+ // separately from a real expiration (and watch the retry-vs-latch split).
765
+ const message = parsedBody.message || 'Unknown auth failure';
766
+ const { exhausted, count } = recordUnknown401(daemonId, Date.now());
767
+ if (!exhausted) {
768
+ log.warn(`[auth/refresh] unknown-shape 401 (${count}/${exports.UNKNOWN_401_BUDGET}) — treating as transient, will retry: ${message}`);
769
+ (0, runtime_js_1.emitAuthTelemetry)({
770
+ timestamp: new Date().toISOString(),
771
+ eventType: 'auth',
772
+ success: false,
773
+ metadata: {
774
+ event: 'refresh_failure',
775
+ daemonId,
776
+ durationMs: Date.now() - startMs,
777
+ errorCode: 'UNKNOWN_401',
778
+ statusCode: 401,
779
+ // Distinguish a transient (retrying) unknown-401 from the
780
+ // budget-exhausted latch in the telemetry stream.
781
+ transient: true,
782
+ unknown401Streak: count,
783
+ },
784
+ });
785
+ throw new types_js_1.TransientAuthFailure(`Unknown auth failure (transient, retrying): ${message}`);
786
+ }
787
+ // Budget exhausted within the window — fall back to the original
788
+ // permanent behavior: latch + persist the reauth flag so the daemon
789
+ // surfaces the banner and the interactive modal becomes the final
790
+ // recovery path.
791
+ log.warn(`[auth/refresh] unknown-shape 401 budget exhausted (${count}/${exports.UNKNOWN_401_BUDGET}) — latching permanent: ${message}`);
792
+ (0, runtime_js_1.emitAuthTelemetry)({
793
+ timestamp: new Date().toISOString(),
794
+ eventType: 'auth',
795
+ success: false,
796
+ metadata: {
797
+ event: 'refresh_failure',
798
+ daemonId,
799
+ durationMs: Date.now() - startMs,
800
+ errorCode: 'UNKNOWN_401',
801
+ statusCode: 401,
802
+ transient: false,
803
+ unknown401Streak: count,
804
+ },
805
+ });
806
+ persistReauthState({
807
+ code: 'UNKNOWN_401',
808
+ message,
809
+ at: Date.now(),
810
+ });
811
+ throw new types_js_1.PermanentAuthFailure('UNKNOWN_401', message);
812
+ }
813
+ // 5xx / unexpected — transient. Caller will back off.
814
+ // RESET SEMANTICS (Finding 4 follow-up): a 5xx is transient and does NOT
815
+ // reset the unknown-401 streak. A dead-but-unknown-code credential whose
816
+ // endpoint intermittently 5xxs must still converge to the permanent latch;
817
+ // resetting here would let it retry forever.
818
+ let bodyText = '';
819
+ try {
820
+ bodyText = await response.text();
821
+ }
822
+ catch {
823
+ /* ignore */
824
+ }
825
+ (0, runtime_js_1.emitAuthTelemetry)({
826
+ timestamp: new Date().toISOString(),
827
+ eventType: 'auth',
828
+ success: false,
829
+ metadata: {
830
+ event: 'refresh_failure',
831
+ daemonId,
832
+ durationMs: Date.now() - startMs,
833
+ errorCode: 'SERVER_ERROR',
834
+ statusCode: response.status,
835
+ },
836
+ });
837
+ throw new types_js_1.TransientAuthFailure(`Refresh failed (${response.status}): ${bodyText.slice(0, 200)}`);
838
+ }
839
+ function persistReauthState(state) {
840
+ const log = (0, runtime_js_1.getAuthLogger)();
841
+ const flagPath = reauthFlagPath();
842
+ try {
843
+ const dir = node_path_1.default.dirname(flagPath);
844
+ if (!node_fs_1.default.existsSync(dir))
845
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
846
+ node_fs_1.default.writeFileSync(flagPath, JSON.stringify(state, null, 2) + '\n', {
847
+ mode: 0o600,
848
+ });
849
+ }
850
+ catch (err) {
851
+ log.debug(`[auth/refresh] failed to persist reauth flag: ${err?.message}`);
852
+ }
853
+ }
854
+ function needsReauthState() {
855
+ try {
856
+ const flagPath = reauthFlagPath();
857
+ if (!node_fs_1.default.existsSync(flagPath))
858
+ return { needed: false };
859
+ const raw = node_fs_1.default.readFileSync(flagPath, 'utf-8');
860
+ const state = JSON.parse(raw);
861
+ return { needed: true, reason: state.code, since: state.at };
862
+ }
863
+ catch {
864
+ return { needed: false };
865
+ }
866
+ }
867
+ function clearReauthState() {
868
+ const log = (0, runtime_js_1.getAuthLogger)();
869
+ try {
870
+ const flagPath = reauthFlagPath();
871
+ if (node_fs_1.default.existsSync(flagPath))
872
+ node_fs_1.default.unlinkSync(flagPath);
873
+ }
874
+ catch (err) {
875
+ log.debug(`[auth/refresh] clearReauthState: ${err?.message}`);
876
+ }
877
+ }