@payez/next-mvp 4.1.20 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/components/account/UserAvatarMenu.js +12 -0
  31. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  32. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  33. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  34. package/dist/hooks/useAuthSettings.js +14 -2
  35. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  36. package/dist/hooks/usePublicAuthSettings.js +28 -14
  37. package/dist/lib/client-key.d.ts +61 -0
  38. package/dist/lib/client-key.js +72 -0
  39. package/dist/lib/demo-mode.js +2 -1
  40. package/dist/lib/ensure-fresh-access-token.js +12 -1
  41. package/dist/lib/idp-client-config.d.ts +25 -2
  42. package/dist/lib/idp-client-config.js +381 -36
  43. package/dist/lib/idp-fetch.d.ts +2 -0
  44. package/dist/lib/idp-fetch.js +31 -1
  45. package/dist/lib/idp-url-guard.d.ts +29 -0
  46. package/dist/lib/idp-url-guard.js +38 -0
  47. package/dist/lib/redis.js +77 -5
  48. package/dist/lib/session-cookie-shape.d.ts +69 -0
  49. package/dist/lib/session-cookie-shape.js +126 -0
  50. package/dist/lib/session-store.d.ts +35 -2
  51. package/dist/lib/session-store.js +135 -4
  52. package/dist/lib/single-flight.d.ts +35 -0
  53. package/dist/lib/single-flight.js +30 -0
  54. package/dist/lib/token-lifecycle.js +12 -0
  55. package/dist/middleware/create-middleware.js +12 -0
  56. package/dist/models/SessionModel.d.ts +41 -0
  57. package/dist/models/SessionModel.js +9 -0
  58. package/dist/pages/admin-login/page.d.ts +1 -1
  59. package/dist/pages/admin-login/page.js +5 -41
  60. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  61. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  62. package/dist/pages/security/index.d.ts +1 -0
  63. package/dist/pages/security/index.js +3 -0
  64. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  65. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  66. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  67. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  68. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  69. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  70. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  71. package/dist/pages/test-env/TestEnvPage.js +16 -4
  72. package/dist/pages/test-env/guarded.d.ts +4 -0
  73. package/dist/pages/test-env/guarded.js +86 -0
  74. package/dist/pages/test-env/index.d.ts +50 -7
  75. package/dist/pages/test-env/index.js +58 -12
  76. package/dist/routes/auth/logout.js +3 -2
  77. package/dist/routes/auth/refresh.js +27 -1
  78. package/dist/routes/auth/session.d.ts +12 -3
  79. package/dist/routes/auth/session.js +92 -9
  80. package/dist/routes/auth/settings.d.ts +34 -4
  81. package/dist/routes/auth/settings.js +87 -33
  82. package/dist/routes/auth/viability.d.ts +1 -1
  83. package/dist/routes/auth/viability.js +122 -11
  84. package/dist/server/auth-guard.js +22 -15
  85. package/dist/server/auth.d.ts +170 -4
  86. package/dist/server/auth.js +42 -75
  87. package/dist/server/decode-session.js +84 -10
  88. package/dist/types/auth.d.ts +18 -0
  89. package/package.json +969 -958
  90. package/scripts/check-internal-url-usage.sh +73 -73
  91. package/scripts/check-publish-health.mjs +120 -0
  92. package/scripts/dev-broker.ps1 +71 -6
  93. package/scripts/print-client-config.mjs +162 -0
  94. package/scripts/server-only-stub.cjs +4 -0
  95. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  96. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  97. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  98. package/src/api-handlers/account/reset-password.ts +35 -2
  99. package/src/api-handlers/account/validate-password.ts +22 -3
  100. package/src/api-handlers/admin/analytics.ts +2 -1
  101. package/src/api-handlers/admin/audit.ts +2 -1
  102. package/src/api-handlers/admin/sessions.ts +2 -1
  103. package/src/api-handlers/admin/stats.ts +2 -1
  104. package/src/api-handlers/admin/users.ts +2 -1
  105. package/src/api-handlers/admin/vibe-data.ts +2 -1
  106. package/src/api-handlers/auth/jwks.ts +10 -0
  107. package/src/api-handlers/auth/login.ts +5 -2
  108. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  109. package/src/api-handlers/auth/refresh.ts +112 -15
  110. package/src/api-handlers/auth/signout.ts +4 -3
  111. package/src/api-handlers/auth/verify-code.ts +5 -2
  112. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  113. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  114. package/src/api-handlers/mfa/methods.ts +46 -46
  115. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  116. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  117. package/src/api-handlers/passkeys/list.ts +31 -31
  118. package/src/api-handlers/passkeys/manage.ts +54 -54
  119. package/src/api-handlers/passkeys/register-options.ts +42 -42
  120. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  121. package/src/api-handlers/session/viability.ts +29 -1
  122. package/src/api-handlers/test/force-expire.ts +118 -13
  123. package/src/auth/better-auth.ts +486 -51
  124. package/src/auth/utils/idp-client.ts +1 -1
  125. package/src/client/AuthContext.tsx +55 -19
  126. package/src/components/account/UserAvatarMenu.tsx +11 -0
  127. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  128. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  129. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  130. package/src/hooks/useAuthSettings.ts +14 -2
  131. package/src/hooks/usePublicAuthSettings.ts +38 -16
  132. package/src/lib/client-key.ts +88 -0
  133. package/src/lib/demo-mode.ts +2 -1
  134. package/src/lib/ensure-fresh-access-token.ts +12 -1
  135. package/src/lib/idp-client-config.ts +399 -36
  136. package/src/lib/idp-fetch.ts +36 -1
  137. package/src/lib/idp-url-guard.ts +37 -0
  138. package/src/lib/redis.ts +84 -6
  139. package/src/lib/session-cookie-shape.ts +184 -0
  140. package/src/lib/session-store.ts +152 -6
  141. package/src/lib/single-flight.ts +64 -0
  142. package/src/lib/token-lifecycle.ts +12 -0
  143. package/src/middleware/create-middleware.ts +11 -0
  144. package/src/models/SessionModel.ts +49 -0
  145. package/src/pages/admin-login/page.tsx +17 -127
  146. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  147. package/src/pages/security/index.ts +3 -0
  148. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  149. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  150. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  151. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  152. package/src/pages/test-env/guarded.tsx +81 -0
  153. package/src/pages/test-env/index.ts +59 -7
  154. package/src/routes/auth/logout.ts +4 -3
  155. package/src/routes/auth/passkey-signin.ts +13 -13
  156. package/src/routes/auth/refresh.ts +30 -1
  157. package/src/routes/auth/session.ts +98 -9
  158. package/src/routes/auth/settings.ts +144 -42
  159. package/src/routes/auth/viability.ts +129 -11
  160. package/src/routes/mfa/backup-code-verify.ts +1 -1
  161. package/src/routes/mfa/backup-codes.ts +10 -10
  162. package/src/routes/mfa/methods.ts +22 -22
  163. package/src/routes/mfa/totp-confirm.ts +10 -10
  164. package/src/routes/mfa/totp-enroll.ts +10 -10
  165. package/src/routes/mfa/totp-verify.ts +14 -14
  166. package/src/routes/passkeys/list.ts +10 -10
  167. package/src/routes/passkeys/manage.ts +23 -23
  168. package/src/routes/passkeys/register-options.ts +10 -10
  169. package/src/routes/passkeys/register-verify.ts +10 -10
  170. package/src/server/auth-guard.ts +24 -17
  171. package/src/server/auth.ts +54 -74
  172. package/src/server/decode-session.ts +95 -11
  173. package/src/templates/instrumentation.ts +21 -2
  174. package/src/types/auth.ts +20 -0
  175. package/dist/lib/auth-secret.d.ts +0 -17
  176. package/dist/lib/auth-secret.js +0 -116
  177. package/src/lib/auth-secret.ts +0 -137
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Name the missing variable instead of letting undici say "Failed to parse URL".
3
+ *
4
+ * Every caller builds its target as `${ENV_CONFIG.IDP_URL}/api/...`. IDP_URL is a
5
+ * BARE PASSTHROUGH in src/config/env.ts - no fallback, no validation - so when it
6
+ * is missing the interpolation yields a url that is not absolute. BOTH spellings
7
+ * of "missing" are caught here, which is why this tests for an absolute url
8
+ * rather than for a leading slash:
9
+ * IDP_URL= (empty, as the quickstart now ships) -> "/api/Account/profile"
10
+ * IDP_URL unset (absent from the env entirely) -> "undefined/api/Account/profile"
11
+ * A leading-slash test would have caught only the first.
12
+ *
13
+ * This already fails CLOSED: undici rejects a relative url, so the guard changes
14
+ * no outcome - it only makes the existing failure self-describing. It does NOT
15
+ * silently retarget the app's own origin. (QAPert tested that hypothesis and
16
+ * reported it disproved, mail 48139.)
17
+ *
18
+ * WHY THIS IS A LEAF MODULE WITH NO IMPORTS, AND NOT A THROW IN env.ts:
19
+ * - env.ts is the wrong home: ENV_CONFIG is imported by 26 modules and
20
+ * re-exported from the package index, so a module-load throw would crash
21
+ * consumers that only render components and never call the IDP. QAPert
22
+ * proposed exactly that (48139); it is the one shape this cannot take.
23
+ * - idp-fetch.ts is the wrong home for the SHARED copy: importing it pulls
24
+ * server-only, the session store and the logger, so the three direct-fetch
25
+ * callers would drag that whole chain in for one regex - and the predicate
26
+ * could not be unit-tested without a live server environment.
27
+ * Keep this file dependency-free so both stay true.
28
+ */
29
+ export declare function assertAbsoluteIdpUrl(targetUrl: string): void;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertAbsoluteIdpUrl = assertAbsoluteIdpUrl;
4
+ /**
5
+ * Name the missing variable instead of letting undici say "Failed to parse URL".
6
+ *
7
+ * Every caller builds its target as `${ENV_CONFIG.IDP_URL}/api/...`. IDP_URL is a
8
+ * BARE PASSTHROUGH in src/config/env.ts - no fallback, no validation - so when it
9
+ * is missing the interpolation yields a url that is not absolute. BOTH spellings
10
+ * of "missing" are caught here, which is why this tests for an absolute url
11
+ * rather than for a leading slash:
12
+ * IDP_URL= (empty, as the quickstart now ships) -> "/api/Account/profile"
13
+ * IDP_URL unset (absent from the env entirely) -> "undefined/api/Account/profile"
14
+ * A leading-slash test would have caught only the first.
15
+ *
16
+ * This already fails CLOSED: undici rejects a relative url, so the guard changes
17
+ * no outcome - it only makes the existing failure self-describing. It does NOT
18
+ * silently retarget the app's own origin. (QAPert tested that hypothesis and
19
+ * reported it disproved, mail 48139.)
20
+ *
21
+ * WHY THIS IS A LEAF MODULE WITH NO IMPORTS, AND NOT A THROW IN env.ts:
22
+ * - env.ts is the wrong home: ENV_CONFIG is imported by 26 modules and
23
+ * re-exported from the package index, so a module-load throw would crash
24
+ * consumers that only render components and never call the IDP. QAPert
25
+ * proposed exactly that (48139); it is the one shape this cannot take.
26
+ * - idp-fetch.ts is the wrong home for the SHARED copy: importing it pulls
27
+ * server-only, the session store and the logger, so the three direct-fetch
28
+ * callers would drag that whole chain in for one regex - and the predicate
29
+ * could not be unit-tested without a live server environment.
30
+ * Keep this file dependency-free so both stay true.
31
+ */
32
+ function assertAbsoluteIdpUrl(targetUrl) {
33
+ if (!/^https?:\/\//i.test(targetUrl)) {
34
+ throw new Error(`IDP_URL is not set. Refusing to call the IDP with the non-absolute URL "${targetUrl}". ` +
35
+ `Set IDP_URL in your environment (dev: http://10.0.0.93:32785). ` +
36
+ `Never set it to a production host in a development worktree.`);
37
+ }
38
+ }
package/dist/lib/redis.js CHANGED
@@ -8,14 +8,86 @@ exports.getRedis = getRedis;
8
8
  // E:\Repos\PayEz-Next-MVP\packages\next-mvp\src\lib\redis.ts
9
9
  const ioredis_1 = __importDefault(require("ioredis"));
10
10
  let client = null;
11
+ // ── 183496: fail FAST and NAMED, never a 20s spinner ───────────────────────
12
+ // The DEFAULTS were the defect (Nextpert-Scout's killable-proxy measurement,
13
+ // BAPert 22626): no commandTimeout, so a command against a cut Redis path
14
+ // hung 20s+, and maxRetriesPerRequest defaults to 20, so every command was
15
+ // retried through every reconnect attempt. In the room that reads as "the
16
+ // app hung", not "Redis blipped" - and self-heal was instant all along, so
17
+ // the failure MODE was the whole defect.
18
+ //
19
+ // commandTimeout covers OFFLINE-QUEUED commands too (ioredis 5.8.2 Redis.js:
20
+ // command.setTimeout runs BEFORE the queue/writable check), which makes it
21
+ // the bounded offline queue the card asks for: no command, queued or sent,
22
+ // outlives 3s. The queue itself stays ENABLED - a transient blip still
23
+ // self-heals inside the window; only the failure mode changes. The throw
24
+ // lands in with-auth's catch and comes out a fast 5xx, not a spinner.
25
+ const REDIS_OPTIONS = {
26
+ commandTimeout: 3000,
27
+ maxRetriesPerRequest: 1,
28
+ };
11
29
  function createClient() {
12
30
  const url = process.env.REDIS_URL;
13
- if (url && url.trim() !== '') {
14
- // Use a standard configuration for better Docker compatibility
15
- return new ioredis_1.default(url);
31
+ // ── THE COMMENT THAT USED TO BE HERE WAS FALSE (Nextpert-Scout 35159). ──
32
+ //
33
+ // It said: "No REDIS_URL set, create a client that will fail fast."
34
+ // IT DOES NOT FAIL. Measured, not read:
35
+ //
36
+ // new Redis({ commandTimeout, maxRetriesPerRequest, lazyConnect: true })
37
+ // -> options.host === 'localhost', options.port === 6379
38
+ // (ioredis DEFAULT_REDIS_OPTIONS: port 6379, host "localhost")
39
+ //
40
+ // `lazyConnect` only DEFERS the connection to the first command; it does not
41
+ // prevent one. So with REDIS_URL absent this client silently targets
42
+ // localhost:6379. Whether that is a loud failure or a SILENT CONNECTION TO
43
+ // THE WRONG STORE depends entirely on what happens to be listening on the
44
+ // local loopback - which is not a property this module controls, and is
45
+ // exactly the kind of thing that differs between a container and a dev box.
46
+ //
47
+ // This mattered operationally: I told the team that deleting REDIS_URL from a
48
+ // configmap would be fail-closed, and recommended it as a hardening step for
49
+ // the 249808 remediation. That advice was wrong and rested on this comment.
50
+ // A loopback-exempt firewall rule also does not stop a client that has
51
+ // fallen back to loopback.
52
+ //
53
+ // Corrected rather than deleted, because the comment's INTENT was right and
54
+ // is worth preserving as a stated goal: an absent REDIS_URL SHOULD fail
55
+ // loudly. Making it actually do so means changing behaviour at module load
56
+ // (`const redis = getRedis()` at the bottom of this file runs on import), so
57
+ // it is carded rather than changed at speed - throwing on import would break
58
+ // every consumer that imports this module without needing Redis.
59
+ //
60
+ // What IS done here: the misconfiguration is now ANNOUNCED, so it can never
61
+ // again be silent even though it is not yet fatal.
62
+ if (!url || url.trim() === '') {
63
+ console.error('[redis] MISCONFIGURED: REDIS_URL is unset or empty. ' +
64
+ 'This client will fall back to ioredis defaults (localhost:6379) and may ' +
65
+ 'connect to whatever is listening there, or fail on first command. ' +
66
+ 'It does NOT fail fast. Set REDIS_URL explicitly.');
16
67
  }
17
- // No REDIS_URL set, create a client that will fail fast.
18
- return new ioredis_1.default({ lazyConnect: true });
68
+ const instance = url && url.trim() !== ''
69
+ // Use a standard configuration for better Docker compatibility
70
+ ? new ioredis_1.default(url, REDIS_OPTIONS)
71
+ // NOTE: this does NOT fail fast - see the block above. It targets
72
+ // localhost:6379 by ioredis default.
73
+ : new ioredis_1.default({ ...REDIS_OPTIONS, lazyConnect: true });
74
+ // Loud AND load-bearing: an ioredis 'error' event with NO listener is an
75
+ // unhandled EventEmitter error, which can take the whole PROCESS down -
76
+ // a Redis blip becoming a dead server is worse than the spinner was.
77
+ instance.on('error', (err) => {
78
+ console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${err instanceof Error ? err.message : String(err)}`);
79
+ });
80
+ // 185121 line 3: the 16:05 event left no named trigger because ONLY 'error'
81
+ // was listened for. A reconnect or a clean end is silent in the artifact,
82
+ // so a session-death window could not be correlated with a redis event.
83
+ // One line per event; a recurrence now says whether redis moved at all.
84
+ instance.on('reconnecting', (delayMs) => {
85
+ console.warn(`[redis] RECONNECTING ${new Date().toISOString()}: next attempt in ${delayMs}ms`);
86
+ });
87
+ instance.on('end', () => {
88
+ console.error(`[redis] CONNECTION ENDED ${new Date().toISOString()}: no further reconnects — every session read now fails or misses`);
89
+ });
90
+ return instance;
19
91
  }
20
92
  function getRedis() {
21
93
  if (!client) {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Session-cookie SHAPE detection — shared by every fallback path that must
3
+ * recover a Redis session token from a raw session-cookie value without
4
+ * going through Better Auth's `auth.api.getSession()`.
5
+ *
6
+ * WHY THIS EXISTS (183047 post-mortem, BAPert/QAPert/Scout, 2026-08-08):
7
+ *
8
+ * Two cookie shapes exist in this codebase's history, and until now no
9
+ * fallback path recognized both:
10
+ *
11
+ * - JWS (3 dot-separated segments): the legacy NextAuth-era format. The
12
+ * session token lives in the payload's `sessionToken` claim, base64-JSON
13
+ * encoded in the middle segment.
14
+ * - BA-hmac (2 segments — exactly ONE dot): Better Auth's own hand-rolled
15
+ * cookie, `{token}.{base64(hmac)}` (see better-auth.ts's
16
+ * CreateBetterAuthInstanceOptions.plugins doc comment). The token here
17
+ * IS the first segment verbatim — there is no claim to decode.
18
+ *
19
+ * Every fallback that existed before this file assumed the JWS shape
20
+ * unconditionally:
21
+ * - decode-session.ts's legacy path called jose's `jwtVerify()` directly on
22
+ * the cookie value. `jwtVerify` requires a genuine compact JWS and throws
23
+ * "Invalid Compact JWS" on a 2-segment value — secret-independent, before
24
+ * any signature is even checked.
25
+ * - auth.ts's `decodeJwtBody()` required `parts.length === 3` and returned
26
+ * null otherwise.
27
+ *
28
+ * Since Better Auth is the only session minter in production, EVERY
29
+ * BA-shaped cookie hit both fallbacks' shape gate and failed structurally.
30
+ * That meant any single BA-primary miss (secondary-storage eviction,
31
+ * in-flight secret divergence, cookie-cache disabled) had no working
32
+ * fallback at all — a single cookie's failure read as total session death
33
+ * instead of degrading to one clean Redis-canonical lookup (or a genuine
34
+ * 401 if the record really is gone).
35
+ *
36
+ * No signature is verified for the BA-hmac shape here — same posture the
37
+ * pre-existing JWS fallback already took (see auth.ts's `getSession` Path B
38
+ * comment): Redis is the liveness authority downstream, so a forged token
39
+ * still has to land on a live session record to do anything. This function
40
+ * only recovers the CANDIDATE token; callers must still look it up.
41
+ */
42
+ export type SessionCookieShape = 'jws' | 'ba-hmac';
43
+ export interface ExtractedSessionToken {
44
+ token: string;
45
+ shape: SessionCookieShape;
46
+ }
47
+ /**
48
+ * Extract a candidate session token from a raw session-cookie value,
49
+ * regardless of which minter produced it.
50
+ *
51
+ * Returns null if the value doesn't match either known shape, or (for the
52
+ * JWS shape) if the payload segment doesn't decode to a `sessionToken`
53
+ * claim.
54
+ */
55
+ export declare function extractSessionTokenFromCookieValue(rawValue: string | null | undefined): ExtractedSessionToken | null;
56
+ /** Extract the session token from the Better Auth session cookie WITHOUT
57
+ * going through Better Auth's `auth.api.getSession()`. Handles both
58
+ * single-cookie and chunked-cookie cases (Better Auth chunks long values
59
+ * across `{name}.0`, `{name}.1`, …), and BOTH cookie shapes the codebase
60
+ * has minted — JWS (legacy NextAuth) and BA-hmac (current Better Auth,
61
+ * `{token}.{hmac}`) — via `extractSessionTokenFromCookieValue` (183047:
62
+ * the shape-blind version of this function is what turned a single
63
+ * BA-primary miss into total, secret-independent fallback failure for
64
+ * every current cookie, since Better Auth never mints the JWS shape this
65
+ * used to require). No signature check — Redis is the liveness gate, so
66
+ * an attacker forging a token still has to land on a real Redis session
67
+ * (infeasible against high-entropy UUIDs). Returns null if no cookie is
68
+ * present or neither known shape matches. */
69
+ export declare function extractSessionTokenFromCookie(request: Request): string | null;
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractSessionTokenFromCookieValue = extractSessionTokenFromCookieValue;
4
+ exports.extractSessionTokenFromCookie = extractSessionTokenFromCookie;
5
+ const app_slug_1 = require("./app-slug");
6
+ /**
7
+ * Extract a candidate session token from a raw session-cookie value,
8
+ * regardless of which minter produced it.
9
+ *
10
+ * Returns null if the value doesn't match either known shape, or (for the
11
+ * JWS shape) if the payload segment doesn't decode to a `sessionToken`
12
+ * claim.
13
+ */
14
+ function extractSessionTokenFromCookieValue(rawValue) {
15
+ if (!rawValue)
16
+ return null;
17
+ const segments = rawValue.split('.');
18
+ if (segments.length === 3) {
19
+ // JWS shape: base64-decode the payload segment (part 1), read the
20
+ // sessionToken claim. No signature check — see file header.
21
+ try {
22
+ const decoded = Buffer.from(segments[1], 'base64').toString('utf-8');
23
+ const payload = JSON.parse(decoded);
24
+ if (payload && typeof payload === 'object' && typeof payload.sessionToken === 'string') {
25
+ return { token: payload.sessionToken, shape: 'jws' };
26
+ }
27
+ }
28
+ catch {
29
+ // fall through to null
30
+ }
31
+ return null;
32
+ }
33
+ if (segments.length === 2 && segments[0] && segments[1]) {
34
+ // BA-hmac shape: `{token}.{base64(hmac)}` — the token is the first
35
+ // segment verbatim, no decoding required.
36
+ return { token: segments[0], shape: 'ba-hmac' };
37
+ }
38
+ return null;
39
+ }
40
+ /** Extract the session token from the Better Auth session cookie WITHOUT
41
+ * going through Better Auth's `auth.api.getSession()`. Handles both
42
+ * single-cookie and chunked-cookie cases (Better Auth chunks long values
43
+ * across `{name}.0`, `{name}.1`, …), and BOTH cookie shapes the codebase
44
+ * has minted — JWS (legacy NextAuth) and BA-hmac (current Better Auth,
45
+ * `{token}.{hmac}`) — via `extractSessionTokenFromCookieValue` (183047:
46
+ * the shape-blind version of this function is what turned a single
47
+ * BA-primary miss into total, secret-independent fallback failure for
48
+ * every current cookie, since Better Auth never mints the JWS shape this
49
+ * used to require). No signature check — Redis is the liveness gate, so
50
+ * an attacker forging a token still has to land on a real Redis session
51
+ * (infeasible against high-entropy UUIDs). Returns null if no cookie is
52
+ * present or neither known shape matches. */
53
+ function extractSessionTokenFromCookie(request) {
54
+ const cookieHeader = request.headers.get('cookie');
55
+ if (!cookieHeader)
56
+ return null;
57
+ // 240654: read EVERY name this session cookie is minted under, not just the
58
+ // bare one.
59
+ //
60
+ // getSessionCookieName() is ALWAYS `{slug}.session-token`. But the package's
61
+ // own read contract is getJwtCookieName() — "the cookie name that getToken()
62
+ // should use to READ" — and in production that resolves to the
63
+ // `__Secure-`-prefixed name. The admin-login door (238225) mints the legacy
64
+ // JWS session under the __Secure- name in prod, exactly per that contract.
65
+ //
66
+ // So this resolver — the Path-B fallback, and the ONLY resolver for legacy
67
+ // JWS sessions — could not see a production session at all. Measured live on
68
+ // portal.keelauth.com: the session was minted and present in Redis at
69
+ // `idp_admin_ui_graft:sess:{token}`, yet invisible to viability and
70
+ // middleware, so every page bounced to /sign-in. Dev survived because in
71
+ // development both helpers return the SAME string, which is precisely why
72
+ // the dev-93 walks passed while prod died.
73
+ //
74
+ // Checking BOTH names rather than just swapping to getJwtCookieName(): the
75
+ // graft currently double-writes both cookie names as an app-local mitigation
76
+ // (graft 8a5ac21), and a rollout necessarily spans sessions minted under
77
+ // either name. Reading both is correct before, during and after that
78
+ // double-write is retired, and costs one extra map lookup.
79
+ const candidateNames = Array.from(new Set([(0, app_slug_1.getJwtCookieName)(), (0, app_slug_1.getSecureSessionCookieName)(), (0, app_slug_1.getSessionCookieName)()]));
80
+ // Parse the header ONCE, then resolve per candidate. Chunked cookies are
81
+ // kept per-name: Better Auth splits long values across `{name}.0/.1/.2…`,
82
+ // and chunks from different cookie names must never be concatenated.
83
+ const direct = new Map();
84
+ const chunksByName = new Map();
85
+ for (const part of cookieHeader.split(';')) {
86
+ const trimmed = part.trim();
87
+ const eq = trimmed.indexOf('=');
88
+ if (eq === -1)
89
+ continue;
90
+ const k = trimmed.slice(0, eq);
91
+ const v = trimmed.slice(eq + 1);
92
+ for (const name of candidateNames) {
93
+ if (k === name) {
94
+ if (!direct.has(name))
95
+ direct.set(name, decodeURIComponent(v));
96
+ break;
97
+ }
98
+ if (k.startsWith(`${name}.`)) {
99
+ const idx = parseInt(k.split('.').pop() || '0', 10);
100
+ if (Number.isFinite(idx)) {
101
+ const list = chunksByName.get(name) ?? [];
102
+ list.push({ idx, value: decodeURIComponent(v) });
103
+ chunksByName.set(name, list);
104
+ }
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ let rawJwt = null;
110
+ for (const name of candidateNames) {
111
+ const whole = direct.get(name);
112
+ if (whole) {
113
+ rawJwt = whole;
114
+ break;
115
+ }
116
+ const parts = chunksByName.get(name);
117
+ if (parts && parts.length > 0) {
118
+ parts.sort((a, b) => a.idx - b.idx);
119
+ rawJwt = parts.map(c => c.value).join('');
120
+ break;
121
+ }
122
+ }
123
+ if (!rawJwt)
124
+ return null;
125
+ return extractSessionTokenFromCookieValue(rawJwt)?.token ?? null;
126
+ }
@@ -40,7 +40,10 @@ export declare function getSession(sessionToken: string): Promise<SessionData |
40
40
  * Better Auth uses key format: ba:{appSlug}:{token}
41
41
  *
42
42
  * @param sessionToken The session token to look up.
43
- * @param appSlug The app slug (defaults to 'idealvibe_online' or extracted from env).
43
+ * @param appSlug The app slug. Defaults to getAppSlug() - the SAME source Better Auth
44
+ * writes under - so reader and writer cannot disagree. Pass explicitly
45
+ * only if a consumer sets createBetterAuthInstance's clientSlug to
46
+ * something other than getAppSlug().
44
47
  * @returns The session data, or null if not found.
45
48
  */
46
49
  export declare function getBetterAuthSession(sessionToken: string, appSlug?: string): Promise<SessionData | null>;
@@ -75,6 +78,21 @@ export declare function isAccessTokenFresh(sessionToken: string, currentAccessTo
75
78
  * @param sessionToken The session token to delete.
76
79
  */
77
80
  export declare function deleteSession(sessionToken: string): Promise<void>;
81
+ /**
82
+ * Deletes a session from EVERY store it can live in (181922): the canonical
83
+ * {slug}:sess:{token} + version key AND the Better Auth secondary
84
+ * ba:{slug}:{token}. Deleting only one lets the survivor resurrect the
85
+ * session — getSession's Path B reads the ba: record when the canonical is
86
+ * gone, so a replayed cookie still authenticated after "logout" (QAPert-
87
+ * NightHawk, exercised on :4702). Every sign-out entry point must use THIS,
88
+ * not deleteSession.
89
+ *
90
+ * The ba: key slug mirrors createBetterAuthInstance's
91
+ * `config.clientSlug || getAppSlug()` — on every current consumer those are
92
+ * the same value (the client-id aliases make it so). If a consumer ever sets
93
+ * them differently, pass appSlug explicitly.
94
+ */
95
+ export declare function deleteSessionEverywhere(sessionToken: string, appSlug?: string): Promise<void>;
78
96
  /**
79
97
  * Sets a session directly (for testing or migrations).
80
98
  * @param sessionToken The session token.
@@ -117,10 +135,25 @@ export declare function transitionTo2FASession(sessionToken: string, tokens: {
117
135
  export declare function updateTokens(sessionToken: string, idpAccessToken: string, idpRefreshToken: string, idpAccessTokenExpires: number, idpRefreshTokenExpires?: number): Promise<SessionData | null>;
118
136
  /**
119
137
  * Marks a session as having completed MFA.
138
+ *
139
+ * *** 339563: PASS `accessToken` - THE STEPPED-UP ONE THE IDP JUST RETURNED. ***
140
+ * Without it this writes a `mfaVerified: true` that no consumer can ever expire
141
+ * (see mfaLifetimeFromAccessToken above). The parameter is OPTIONAL only so
142
+ * that this published package keeps its existing signature for external
143
+ * callers; every in-tree caller supplies it, and omitting it re-opens the
144
+ * fail-open for that call site.
145
+ *
146
+ * It is NOT defaulted to the session's stored token on purpose. Reading it back
147
+ * would work today only because the sole caller happens to run `updateTokens()`
148
+ * first, and depending on undocumented call ORDERING is how this class of bug
149
+ * gets reintroduced. The token is an input, so take it as one.
150
+ *
120
151
  * @param sessionToken The session token to update.
152
+ * @param accessToken The access token minted AFTER MFA completion, carrying the
153
+ * mfa_time / mfa_expires / mfa_validity_hours claim set.
121
154
  * @returns The updated session data.
122
155
  */
123
- export declare function mark2FAComplete(sessionToken: string): Promise<SessionData | null>;
156
+ export declare function mark2FAComplete(sessionToken: string, accessToken?: string): Promise<SessionData | null>;
124
157
  /**
125
158
  * Checks if MFA is complete for a session.
126
159
  * @param sessionToken The session token.
@@ -21,6 +21,7 @@ exports.touchSession = touchSession;
21
21
  exports.getSessionWithVersion = getSessionWithVersion;
22
22
  exports.isAccessTokenFresh = isAccessTokenFresh;
23
23
  exports.deleteSession = deleteSession;
24
+ exports.deleteSessionEverywhere = deleteSessionEverywhere;
24
25
  exports.setSession = setSession;
25
26
  exports.updateSession = updateSession;
26
27
  exports.transitionTo2FASession = transitionTo2FASession;
@@ -108,15 +109,37 @@ async function getSession(sessionToken) {
108
109
  * Better Auth uses key format: ba:{appSlug}:{token}
109
110
  *
110
111
  * @param sessionToken The session token to look up.
111
- * @param appSlug The app slug (defaults to 'idealvibe_online' or extracted from env).
112
+ * @param appSlug The app slug. Defaults to getAppSlug() - the SAME source Better Auth
113
+ * writes under - so reader and writer cannot disagree. Pass explicitly
114
+ * only if a consumer sets createBetterAuthInstance's clientSlug to
115
+ * something other than getAppSlug().
112
116
  * @returns The session data, or null if not found.
113
117
  */
114
118
  async function getBetterAuthSession(sessionToken, appSlug) {
115
119
  if (!sessionToken) {
116
120
  return null;
117
121
  }
118
- // Try to get appSlug from env if not provided
119
- const slug = appSlug || process.env.CLIENT_ID || 'idealvibe_online';
122
+ // 241875 / 213416 (partially-applied fix, completed here): this line used to be
123
+ // appSlug || process.env.CLIENT_ID || 'idealvibe_online'
124
+ // which is a THIRD slug resolution, disagreeing with the two canonical ones in
125
+ // this same file - getSessionKey() uses getSessionPrefix() (i.e. getAppSlug()),
126
+ // and deleteSessionEverywhere() uses `appSlug || getAppSlug()`.
127
+ //
128
+ // WRITER vs READER, same keyspace, different answers. Better Auth WRITES
129
+ // ba:{getAppSlug()}:{token} - deleteSessionEverywhere's own docstring records
130
+ // that it mirrors createBetterAuthInstance's `config.clientSlug || getAppSlug()`.
131
+ // This function READ ba:{CLIENT_ID or 'idealvibe_online'}:{token}.
132
+ //
133
+ // MEASURED IN PRODUCTION (keelauth-portal-config, 2026-08-24): APP_SLUG is
134
+ // declared and CLIENT_ID is NOT. So the old chain fell through to the literal and
135
+ // every one of this function's 14 call sites looked under a key Better Auth never
136
+ // writes. It could not match, and it returned null silently rather than failing -
137
+ // so the BA-session fallback documented in 4.1.3 (viability falling back to BA
138
+ // Redis when the canonical store misses) has been a safety net with a hole in it.
139
+ //
140
+ // 213416 fixed this exact writer/reader split at deleteSessionEverywhere and did
141
+ // not reach here. Using getAppSlug() makes the two agree by construction.
142
+ const slug = appSlug || (0, app_slug_1.getAppSlug)();
120
143
  const key = getBetterAuthSessionKey(sessionToken, slug);
121
144
  const json = await redis_1.default.get(key);
122
145
  if (!json) {
@@ -135,6 +158,11 @@ async function getBetterAuthSession(sessionToken, appSlug) {
135
158
  idpRefreshToken: data.idpTokens?.idpRefreshToken,
136
159
  idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
137
160
  mfaVerified: data.idpTokens?.mfaVerified ?? false,
161
+ // R807 (362550): the `?? false` above is CORRECT and stays - fail-closed for
162
+ // every security consumer. This records the bit it destroys, for viability
163
+ // only. Note `!== undefined`, not a truthiness test: an explicit `false` IS
164
+ // a signal, and is precisely the case viability needs to see.
165
+ mfaSignalPresent: data.idpTokens?.mfaVerified !== undefined,
138
166
  // Cards 141688/141741 — the IDP's routing decision, carried not derived.
139
167
  // `null` is a real value here (IDP said nothing is outstanding), so it
140
168
  // must stay distinguishable from `undefined` (we never captured it —
@@ -216,6 +244,26 @@ async function deleteSession(sessionToken) {
216
244
  const versionKey = getSessionVersionKey(sessionToken);
217
245
  await redis_1.default.del(key, versionKey);
218
246
  }
247
+ /**
248
+ * Deletes a session from EVERY store it can live in (181922): the canonical
249
+ * {slug}:sess:{token} + version key AND the Better Auth secondary
250
+ * ba:{slug}:{token}. Deleting only one lets the survivor resurrect the
251
+ * session — getSession's Path B reads the ba: record when the canonical is
252
+ * gone, so a replayed cookie still authenticated after "logout" (QAPert-
253
+ * NightHawk, exercised on :4702). Every sign-out entry point must use THIS,
254
+ * not deleteSession.
255
+ *
256
+ * The ba: key slug mirrors createBetterAuthInstance's
257
+ * `config.clientSlug || getAppSlug()` — on every current consumer those are
258
+ * the same value (the client-id aliases make it so). If a consumer ever sets
259
+ * them differently, pass appSlug explicitly.
260
+ */
261
+ async function deleteSessionEverywhere(sessionToken, appSlug) {
262
+ if (!sessionToken) {
263
+ return;
264
+ }
265
+ await redis_1.default.del(getSessionKey(sessionToken), getSessionVersionKey(sessionToken), getBetterAuthSessionKey(sessionToken, appSlug || (0, app_slug_1.getAppSlug)()));
266
+ }
219
267
  /**
220
268
  * Sets a session directly (for testing or migrations).
221
269
  * @param sessionToken The session token.
@@ -271,6 +319,53 @@ async function updateSession(sessionToken, updates) {
271
319
  .exec();
272
320
  return updatedSession;
273
321
  }
322
+ /**
323
+ * Derives the MFA lifetime fields from an IDP access token.
324
+ *
325
+ * *** CARD 339563. THE FLAG AND THE EXPIRY ARE ONE UNIT - WRITE THEM TOGETHER. ***
326
+ *
327
+ * The 2FA-completion writers below used to set `mfaVerified: true` and never
328
+ * write `mfaExpiresAt`, while every consumer reads the expiry as
329
+ * const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
330
+ * const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
331
+ * The `> 0` guard makes a MISSING expiry unfalsifiable, so `mfaExpired` is
332
+ * forced false forever and the session's MFA state never ages out. The IDP is
333
+ * NOT at fault: IdentityBearerTokenService emits mfa_time + mfa_expires +
334
+ * mfa_validity_hours together inside one `if` block, so the claims arrive as an
335
+ * atomic set or not at all. We were dropping them on the floor.
336
+ *
337
+ * *** THIS RETURNS `{}` WHEN THE TOKEN CARRIES NO mfa_expires, AND THAT IS
338
+ * *** DELIBERATE - DO NOT DEFAULT IT TO `Date.now()` OR TO A CONSTANT. ***
339
+ * The step-up policy omits mfa_time on purpose when the only factor was a
340
+ * primary credential (e.g. passwordless email login): inventing a timestamp
341
+ * because one was missing is forging the evidence. A lane with no claim is a
342
+ * lane where we do not KNOW the expiry, and the honest fix for that case is to
343
+ * make the consumers fail CLOSED on a missing expiry - a separate change, on
344
+ * this same card, which MUST land after this one (the card's option (b)).
345
+ *
346
+ * Mirrors ensure-fresh-access-token.ts and api-handlers/auth/refresh.ts, which
347
+ * already derive these three fields off the token on every exchange (170007
348
+ * rule 2: re-derive from the token, never carry forward).
349
+ */
350
+ function mfaLifetimeFromAccessToken(accessToken) {
351
+ if (!accessToken)
352
+ return {};
353
+ const decoded = (0, token_utils_1.decodeIdpAccessToken)(accessToken);
354
+ if (!decoded)
355
+ return {};
356
+ const fields = {};
357
+ // Claims are Unix SECONDS; SessionData stores milliseconds.
358
+ if (decoded.mfa_expires !== undefined && decoded.mfa_expires !== null) {
359
+ fields.mfaExpiresAt = Number(decoded.mfa_expires) * 1000;
360
+ }
361
+ if (decoded.mfa_time !== undefined && decoded.mfa_time !== null) {
362
+ fields.mfaCompletedAt = Number(decoded.mfa_time) * 1000;
363
+ }
364
+ if (decoded.mfa_validity_hours !== undefined && decoded.mfa_validity_hours !== null) {
365
+ fields.mfaValidityHours = Number(decoded.mfa_validity_hours);
366
+ }
367
+ return fields;
368
+ }
274
369
  /**
275
370
  * Transitions a session to a MFA-completed state.
276
371
  * @param sessionToken The session token to update.
@@ -306,6 +401,9 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
306
401
  mfaMethod: mfaMethod,
307
402
  // Update bearerKeyId if extracted from new token
308
403
  ...(bearerKeyId && { bearerKeyId }),
404
+ // 339563: write the expiry in the SAME update that sets the flag. We already
405
+ // hold the stepped-up token here and already decode its header above.
406
+ ...mfaLifetimeFromAccessToken(newAccessToken),
309
407
  };
310
408
  const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
311
409
  if (refreshExpires !== undefined) {
@@ -316,6 +414,12 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
316
414
  twoFactorComplete: updates.twoFactorComplete,
317
415
  mfaMethod: updates.mfaMethod,
318
416
  hasIdpAccessToken: !!updates.idpAccessToken,
417
+ // 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
418
+ // the passwordless/primary-credential lane (token carried no mfa_expires),
419
+ // NOT a bug - but it IS the case the consumers currently fail OPEN on, so
420
+ // it needs to be visible in the log rather than inferred from its absence.
421
+ mfaExpiresAt: updates.mfaExpiresAt,
422
+ mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
319
423
  });
320
424
  const result = await updateSession(sessionToken, updates);
321
425
  console.log('[transitionTo2FASession] Result:', {
@@ -343,12 +447,39 @@ async function updateTokens(sessionToken, idpAccessToken, idpRefreshToken, idpAc
343
447
  }
344
448
  /**
345
449
  * Marks a session as having completed MFA.
450
+ *
451
+ * *** 339563: PASS `accessToken` - THE STEPPED-UP ONE THE IDP JUST RETURNED. ***
452
+ * Without it this writes a `mfaVerified: true` that no consumer can ever expire
453
+ * (see mfaLifetimeFromAccessToken above). The parameter is OPTIONAL only so
454
+ * that this published package keeps its existing signature for external
455
+ * callers; every in-tree caller supplies it, and omitting it re-opens the
456
+ * fail-open for that call site.
457
+ *
458
+ * It is NOT defaulted to the session's stored token on purpose. Reading it back
459
+ * would work today only because the sole caller happens to run `updateTokens()`
460
+ * first, and depending on undocumented call ORDERING is how this class of bug
461
+ * gets reintroduced. The token is an input, so take it as one.
462
+ *
346
463
  * @param sessionToken The session token to update.
464
+ * @param accessToken The access token minted AFTER MFA completion, carrying the
465
+ * mfa_time / mfa_expires / mfa_validity_hours claim set.
347
466
  * @returns The updated session data.
348
467
  */
349
- async function mark2FAComplete(sessionToken) {
468
+ async function mark2FAComplete(sessionToken, accessToken) {
469
+ const mfaLifetime = mfaLifetimeFromAccessToken(accessToken);
470
+ if (!accessToken) {
471
+ console.warn('[mark2FAComplete] 339563: called with NO access token, so no mfaExpiresAt ' +
472
+ 'can be derived. This session\'s MFA state will not expire until its next ' +
473
+ 'token refresh repairs it. Pass the stepped-up access token.');
474
+ }
475
+ else if (mfaLifetime.mfaExpiresAt === undefined) {
476
+ console.warn('[mark2FAComplete] 339563: access token carries NO mfa_expires claim, so ' +
477
+ 'this session has no MFA expiry. Expected on a primary-credential lane ' +
478
+ '(e.g. passwordless email); unexpected on an ordinary 2FA completion.');
479
+ }
350
480
  return updateSession(sessionToken, {
351
481
  mfaVerified: true,
482
+ ...mfaLifetime,
352
483
  });
353
484
  }
354
485
  /**
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Single-flight memoisation that does NOT memoise failure — card 244549.
3
+ *
4
+ * The bug this exists to prevent: caching an in-flight promise to de-duplicate
5
+ * concurrent callers is correct, but if the cached promise REJECTS and is never
6
+ * cleared, the rejection is memoised for the life of the process. Every later
7
+ * caller re-awaits the same settled rejection and re-throws the same stale
8
+ * error, long after the underlying dependency has recovered. Recovery becomes
9
+ * restart-only, and — because the error text is identical every time — it looks
10
+ * exactly like the dependency is still down.
11
+ *
12
+ * Measured instance: @payez/next-mvp getAuthInstance() memoised a failed IDP
13
+ * config load. Re-injecting correct config into three running rigs on
14
+ * 2026-08-23 did not heal them; the processes were replaying a cached
15
+ * rejection rather than retrying.
16
+ *
17
+ * Contract:
18
+ * - concurrent callers during a successful load share ONE underlying call
19
+ * - a rejection is re-thrown to every caller awaiting that attempt (failures
20
+ * stay LOUD — this never converts an error into a silent retry)
21
+ * - the NEXT call after a failure starts a fresh attempt
22
+ * - a slow failure never clears a newer in-flight attempt
23
+ *
24
+ * Zero imports on purpose: this must be unit-testable without loading the
25
+ * server auth stack, which is why the bug it fixes went untested for so long.
26
+ */
27
+ export interface SingleFlight<T> {
28
+ /** Run (or join) the in-flight attempt. Rejects if the attempt rejects. */
29
+ get(): Promise<T>;
30
+ /** Test/diagnostic seam: is an attempt currently cached? */
31
+ isPending(): boolean;
32
+ /** Drop any cached attempt. */
33
+ reset(): void;
34
+ }
35
+ export declare function createSingleFlight<T>(factory: () => Promise<T>): SingleFlight<T>;