@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
@@ -11,17 +11,29 @@
11
11
 
12
12
  import 'server-only';
13
13
  import { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { createHash } from 'crypto';
14
15
  import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
15
16
  import { betterAuth } from 'better-auth';
16
17
  import { nextCookies } from 'better-auth/next-js';
17
18
  import { toNextJsHandler } from 'better-auth/next-js';
18
19
  import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
20
+ import { resolveClientKey } from '../lib/client-key';
21
+ import { genericOAuth } from 'better-auth/plugins/generic-oauth';
19
22
  import type { IDPClientConfig } from '../lib/idp-client-config';
20
23
  import { getIDPClientConfig } from '../lib/idp-client-config';
21
24
  import { getAppSlug } from '../lib/app-slug';
22
25
  import { getRedis } from '../lib/redis';
23
26
  import { SESSION_TTL } from '../lib/session-store';
24
27
 
28
+ /**
29
+ * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
30
+ * to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
31
+ * = 48 bits; collision-tellable across a deployment, useless as a credential.
32
+ */
33
+ export function secretFingerprint(value: string): string {
34
+ return createHash('sha256').update(value).digest('hex').slice(0, 12);
35
+ }
36
+
25
37
  /**
26
38
  * Better Auth social provider config shape.
27
39
  */
@@ -32,6 +44,13 @@ export interface BetterAuthSocialProvider {
32
44
  prompt?: string;
33
45
  accessType?: 'offline' | 'online';
34
46
  hd?: string;
47
+ /**
48
+ * 260419: better-auth's ONLY guard against OAuth auto-provisioning
49
+ * (oauth2/link-account.mjs handleOAuthUserInfo: `if (disableSignUp) return
50
+ * {error:"signup disabled"}`). Unset means ANY unknown Google identity gets a
51
+ * local account created at callback. We wire it from the tenant's own policy.
52
+ */
53
+ disableSignUp?: boolean;
35
54
  }
36
55
 
37
56
  /**
@@ -60,7 +79,118 @@ export interface BetterAuthSocialProvider {
60
79
  * function. Outside that scope the store is undefined and the body simply omits
61
80
  * id_token, which is the correct additive behaviour.
62
81
  */
63
- const idTokenScope = new AsyncLocalStorage<{ idToken?: string }>();
82
+ /**
83
+ * ON globalThis FOR THE SAME REASON THE BETTER AUTH INSTANCE IS (181814, see STATE_KEY
84
+ * below) — and it was NOT, which is the whole defect.
85
+ *
86
+ * This module is loaded more than once per process: instrumentation and the auth route
87
+ * are different chunks with their own module registries. `state` was moved to globalThis
88
+ * so both copies share the Better Auth INSTANCE. This AsyncLocalStorage was left in
89
+ * module scope, so each copy got its own.
90
+ *
91
+ * The result, measured on UMI 2026-08-26 against idp.payez.net:
92
+ *
93
+ * copy A builds the instance -> the getUserInfo closure captures A's idTokenScope
94
+ * copy B serves the callback -> createAuthGetHandler runs .run({}) on B's
95
+ * getUserInfo fires, reads A -> getStore() is undefined, `if (scope)` skips
96
+ * the exchange reads B -> empty, so id_token is omitted from the body
97
+ *
98
+ * Sign-in SUCCEEDS at every visible step. The user gets a session, an avatar, an email.
99
+ * The IDP then answers 401 UNVERIFIED_ASSERTION with reason="id_token missing"
100
+ * (confirmed in its own logs, correlationId 0HNO2755RESR1) — so the client half of 138635
101
+ * has been shipping as a no-op wherever the module double-loads, and the security fix it
102
+ * exists to deliver never arrived. A shared instance plus a per-copy scope is the worst
103
+ * combination: everything looks wired, and the one field that proves identity is dropped.
104
+ *
105
+ * globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
106
+ * carrier needs to be reachable from every copy that might run inside the request.
107
+ */
108
+ const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
109
+ const gScope = globalThis as unknown as Record<
110
+ string,
111
+ AsyncLocalStorage<{ idToken?: string }> | undefined
112
+ >;
113
+ gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<{ idToken?: string }>();
114
+ const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
115
+
116
+ /**
117
+ * EXCHANGE-MOVE (175047): route OAuth through OUR IDP instead of holding
118
+ * provider credentials in the app.
119
+ *
120
+ * ARCHITECTURE LOCKED (BAPert 21318): genericOAuth pointed at the IDP - NOT at
121
+ * Google - which is simultaneously pkce + no-secret AND route-through-IDP, and
122
+ * is uniform with the ratified desktop leg. The IDP holds the provider secret
123
+ * and performs the provider exchange server-side.
124
+ *
125
+ * ── CUSTOM SHAPE, NEVER A PROVIDER PRESET ───────────────────────────────────
126
+ * QAPert-NightHawk 21304: better-auth's provider-PRESET shape makes
127
+ * clientSecret REQUIRED again. Same plugin, two config shapes, opposite answers
128
+ * on whether we need a secret. This builds the generic/custom shape
129
+ * deliberately; reaching for a preset because it looks tidier would silently
130
+ * kill keyless-on-web and nobody would connect the two changes.
131
+ *
132
+ * ── OPT-IN, AND NOT A FALLBACK ──────────────────────────────────────────────
133
+ * Returns null unless the IDP's OAuth endpoints are explicitly configured. That
134
+ * makes this ADDITIVE: the existing socialProviders path keeps working until an
135
+ * operator turns this on. It is NOT a runtime failover - nothing silently
136
+ * chooses between them, because a silent chooser is how a migration ends up
137
+ * half-applied and undetectable.
138
+ *
139
+ * ⚠ WHAT MUST MOVE TO THE IDP WITH THIS (flagged in 21317, not solvable here):
140
+ * the app stops talking to Google, so it can no longer capture the raw
141
+ * id_token that oauth-callback verifies today, and the per-provider settings
142
+ * below stop being ours to set - profile scope, prompt=select_account,
143
+ * accessType, and hd (hosted-domain, a SECURITY control). The IDP's own
144
+ * exchange must verify signature/iss/aud/exp itself and preserve those four, or
145
+ * they are lost silently.
146
+ */
147
+ export function buildIdpOAuthProviderConfig(): {
148
+ providerId: string;
149
+ discoveryUrl: string;
150
+ clientId: string;
151
+ pkce: true;
152
+ scopes: string[];
153
+ } | null {
154
+ const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
155
+ const clientId = process.env.CLIENT_ID;
156
+ if (!discoveryUrl || !clientId) return null;
157
+
158
+ return {
159
+ providerId: 'payez-idp',
160
+ discoveryUrl,
161
+ clientId,
162
+ // NO clientSecret. The whole point: this app is not a credential holder.
163
+ pkce: true,
164
+ scopes: ['openid', 'email', 'profile'],
165
+
166
+ /**
167
+ * ⚠ DO NOT ADD `authentication: 'basic'` HERE. IT SILENTLY BREAKS KEYLESS.
168
+ *
169
+ * Found by QAPert-NightHawk (21348) — the second config trap in this plugin
170
+ * after the preset one, and the same shape: a field that looks like a
171
+ * harmless transport detail and quietly changes whether we are a public
172
+ * client at all.
173
+ *
174
+ * @better-auth/core validate-authorization-code.mjs:
175
+ * if (authentication === "basic") {
176
+ * headers.authorization = `Basic ${base64(`${clientId}:${clientSecret ?? ""}`)}`;
177
+ * } else {
178
+ * body.set("client_id", clientId);
179
+ * if (options.clientSecret) body.set("client_secret", options.clientSecret);
180
+ * }
181
+ *
182
+ * Note what the 'basic' branch does with NO secret: it sends an EMPTY
183
+ * password, `Basic base64("<clientId>:")`. That is not "no authentication",
184
+ * it is WRONG authentication — we would be presenting ourselves as a
185
+ * confidential client with a blank credential. The default branch is the
186
+ * one that produces a genuine public-client PKCE request.
187
+ *
188
+ * Leaving this field UNSET is load-bearing, which is exactly why it needs a
189
+ * comment: nothing about an absent field advertises that its absence is a
190
+ * decision.
191
+ */
192
+ };
193
+ }
64
194
 
65
195
  export function buildBetterAuthProviders(
66
196
  config: IDPClientConfig
@@ -81,10 +211,40 @@ export function buildBetterAuthProviders(
81
211
  scopes.push('profile');
82
212
  }
83
213
 
214
+ /**
215
+ * 224721: better-auth's google provider UNCONDITIONALLY seeds its default
216
+ * scopes ['email','profile','openid'] and then pushes our `scope` verbatim
217
+ * (@better-auth/core social-providers/google.mjs createAuthorizationURL) —
218
+ * the row's "openid email profile" reached Google as
219
+ * "email profile openid openid email profile". Semantically harmless
220
+ * (Google dedupes) but sloppy on a URL a demo audience can read. Hand over
221
+ * only what the defaults do not already cover; undefined when nothing
222
+ * remains, so the provider emits exactly its default set.
223
+ */
224
+ const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
225
+ const wireScopes =
226
+ name === 'google'
227
+ ? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
228
+ : scopes;
229
+
84
230
  providers[name] = {
85
231
  clientId: oauth.clientId,
86
232
  clientSecret: oauth.clientSecret,
87
- scope: scopes.length > 0 ? scopes : undefined,
233
+ scope: wireScopes.length > 0 ? wireScopes : undefined,
234
+ /**
235
+ * 260419 — CLOSE THE SECOND SELF-SERVE PROVISIONING DOOR. 259805 gated
236
+ * the signup BFF on allow_public_registration, but the social lane never
237
+ * touches that code: better-auth creates the user through its own
238
+ * internalAdapter at the OAuth callback, and its ONLY guard is this flag.
239
+ *
240
+ * Wired from the tenant's own policy, explicit-true-opens: the IDP config
241
+ * keeps allowPublicRegistration UNDEFINED when the tenant never answered
242
+ * (idp-client-config.ts, the no-fabricated-default rule), and an
243
+ * unanswered policy question must not provision accounts. That matches
244
+ * the FE's silent-default-is-decline posture for the "Create account"
245
+ * affordance. A tenant that wants Google self-serve says so.
246
+ */
247
+ disableSignUp: config.authSettings?.allowPublicRegistration !== true,
88
248
  // Google is overly eager to reuse the last account unless we
89
249
  // explicitly ask for account selection on each social login.
90
250
  prompt: typeof rawPrompt === 'string'
@@ -137,7 +297,22 @@ export function buildBetterAuthProviders(
137
297
 
138
298
  // The only new behaviour: hand the raw token to this request's scope.
139
299
  const scope = idTokenScope.getStore();
140
- if (scope) scope.idToken = token.idToken;
300
+ if (scope) {
301
+ scope.idToken = token.idToken;
302
+ } else {
303
+ // NOT a silent skip. A missing scope here means 138635 degrades to a no-op:
304
+ // the login still succeeds, and the IDP rejects the exchange with
305
+ // reason="id_token missing" a second later, with nothing connecting the two.
306
+ // That cost a day on UMI. If this fires, the OAuth callback is running outside
307
+ // createAuthGetHandler — either the route mounts the bare
308
+ // `routes/auth/nextauth` re-export instead of createAuthGetHandler, or this
309
+ // module has double-loaded and the scope is not shared (see 181814 above).
310
+ console.warn(
311
+ '[BETTER_AUTH] id_token captured but NO REQUEST SCOPE to put it in — it will ' +
312
+ 'NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
313
+ 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
314
+ );
315
+ }
141
316
 
142
317
  // Mirrors the upstream default (google.mjs) verbatim, minus the hd
143
318
  // branch that cannot fire here — so overriding the hook changes WHAT WE
@@ -206,6 +381,17 @@ export function createBetterAuthInstance(
206
381
  ) {
207
382
  const appSlug = idpConfig.clientSlug || getAppSlug();
208
383
 
384
+ // 185121 line 1: name the secret this instance PINNED, as a hash, never the
385
+ // secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
386
+ // caused by a divergent secret is only diagnosable if every instance
387
+ // creation logged WHICH secret it bound. Both init paths (this module's
388
+ // getBetterAuthInstance and server/auth.ts getAuthInstance) funnel through
389
+ // here, so this one line covers both; getAuthInstance adds its own line
390
+ // naming the path, because it previously created an instance INVISIBLY.
391
+ console.log(
392
+ `[BETTER_AUTH] createBetterAuthInstance: slug=${appSlug} authSecret ${idpConfig.authSecret ? `sha256:${secretFingerprint(idpConfig.authSecret as string)}` : 'ABSENT'}`
393
+ );
394
+
209
395
  // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
210
396
  // Must include /api/auth since that's where the catch-all route is mounted
211
397
  const rawBaseURL = process.env.BETTER_AUTH_URL
@@ -213,6 +399,8 @@ export function createBetterAuthInstance(
213
399
  || `http://localhost:${process.env.PORT || '3000'}`;
214
400
  const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
215
401
 
402
+ const idpOAuth = buildIdpOAuthProviderConfig();
403
+
216
404
  return betterAuth({
217
405
  baseURL,
218
406
  secret: idpConfig.authSecret as string,
@@ -224,32 +412,137 @@ export function createBetterAuthInstance(
224
412
  rawBaseURL,
225
413
  baseURL,
226
414
  ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
227
- 'http://localhost:3000',
228
- 'http://localhost:3400',
229
- 'http://localhost:3600',
415
+ // MEASURED 2026-08-26: this list ended with the literals localhost:3000, :3400
416
+ // and :3600. payez_idp_admin_web runs on :3200 - correctly configured, with
417
+ // BETTER_AUTH_URL=http://localhost:3200 - and every GET /api/auth/get-session
418
+ // returned 400, because the port it actually runs on was not one of the three
419
+ // someone had typed in. A config-shaped constant is not configuration.
420
+ //
421
+ // The app's own origin is already covered by rawBaseURL/baseURL above; the dev
422
+ // origin is DERIVED from PORT, and anything genuinely external comes from
423
+ // BETTER_AUTH_TRUSTED_ORIGINS (comma-separated).
424
+ `http://localhost:${process.env.PORT || '3000'}`,
425
+ ...(process.env.BETTER_AUTH_TRUSTED_ORIGINS
426
+ ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
427
+ : []),
230
428
  ],
231
429
 
232
430
  // Redis-backed session storage via secondaryStorage
233
431
  secondaryStorage: {
432
+ /**
433
+ * 170033 (QAPert 22654, load-bearing precondition for 183483): DO NOT
434
+ * swallow a Redis failure into `null` here.
435
+ *
436
+ * `null` from `get` is Better Auth's OWN signal for "no session at this
437
+ * key" (see node_modules/better-auth db/internal-adapter.mjs
438
+ * `findSession`: `if (!sessionStringified ...) return null`). Catching
439
+ * a Redis error and returning `null` therefore told better-auth "this
440
+ * session does not exist" when the true state was "the store could not
441
+ * be asked" — indistinguishable from a genuine sign-out. With
442
+ * `cookieCache` disabled (session.cookieCache.enabled above), EVERY
443
+ * `getSession()` call hits this path for real, so a Redis blip
444
+ * manufactured a `200 { session: null }` for every live session at
445
+ * once — the exact channel 183483's client-side guard (verify-code
446
+ * page's session-liveness check) was built to survive, and the one it
447
+ * could not survive while this swallow stood: a fabricated "no
448
+ * session" 200 is not distinguishable from a real one by any client.
449
+ *
450
+ * Letting the error propagate is safe: better-auth's own router
451
+ * (better-call/dist/router.mjs) wraps every endpoint handler in a
452
+ * top-level try/catch and converts an uncaught throw to a genuine
453
+ * `500`, not a crash. A `500` IS the "inconclusive" signal 183483's
454
+ * corrected client guard already knows how to hold a session through —
455
+ * so a Redis outage now surfaces as "couldn't check" instead of
456
+ * silently lying "confirmed logged out".
457
+ */
234
458
  get: async (key: string) => {
235
- try {
236
- return await getRedis().get(`ba:${appSlug}:${key}`);
237
- } catch { return null; }
459
+ const value = await getRedis().get(`ba:${appSlug}:${key}`);
460
+ // 185121 line 2: the 183047 discriminator. A null here means
461
+ // RECORD-MISS — the store was reachable and the key is absent. If the
462
+ // record IS present but BA still rejects the session, this line does
463
+ // NOT fire and the failure is upstream of storage (HMAC/secret gate).
464
+ // BA-primary logs nothing on failure, so without this line the two
465
+ // surviving forks are indistinguishable in the artifact. Key is
466
+ // fingerprinted: it embeds the session token.
467
+ if (value === null) {
468
+ console.warn(
469
+ `[BETTER_AUTH] secondaryStorage.get MISS ba:${appSlug}:sha256:${secretFingerprint(key)} — record absent, store reachable`
470
+ );
471
+ }
472
+ return value;
238
473
  },
474
+ /**
475
+ * 170033 fix 1 — DO NOT SWALLOW A FAILED WRITE. A SESSION THAT WAS NOT
476
+ * PERSISTED IS NOT A SESSION.
477
+ *
478
+ * This previously caught every Redis error and returned normally, so
479
+ * better-auth was told the session had been stored when nothing had been
480
+ * written. The user got a successful login, a session cookie, and a
481
+ * rendered signed-in shell on top of a store that holds no session — and
482
+ * the very next `getSession()` reads `null` and signs them straight back
483
+ * out. Nothing logged, nothing thrown, no banner: a login that "worked"
484
+ * and a sign-out nobody can explain.
485
+ *
486
+ * Letting it propagate is the same contract `get` above already relies on:
487
+ * better-auth's router (better-call/dist/router.mjs) wraps every endpoint
488
+ * handler and converts an uncaught throw into a genuine 500. A 500 on the
489
+ * login write is honest — "we could not store your session" — and it is
490
+ * distinguishable by any client from a successful login, which a swallowed
491
+ * failure is not.
492
+ *
493
+ * THE COMMENT THIS REPLACES SAID "Redis unavailable — cookie cache still
494
+ * works". It does not: `session.cookieCache.enabled` is FALSE (see below).
495
+ * The comment described a safety net that had been removed, which is why
496
+ * this swallow looked survivable to every reader who trusted it (170033
497
+ * fix 3).
498
+ */
239
499
  set: async (key: string, value: string, ttl?: number) => {
240
- try {
241
- const redis = getRedis();
242
- if (ttl) {
243
- await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
244
- } else {
245
- await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
246
- }
247
- } catch { /* Redis unavailable — cookie cache still works */ }
500
+ const redis = getRedis();
501
+ await redis.setex(`ba:${appSlug}:${key}`, ttl ?? SESSION_TTL, value);
248
502
  },
249
503
  delete: async (key: string) => {
250
504
  try {
251
505
  await getRedis().del(`ba:${appSlug}:${key}`);
252
- } catch { /* ignore */ }
506
+ } catch (err) {
507
+ /**
508
+ * 170033 fix 2 — a failed delete must be VISIBLE, not `/* ignore *\/`.
509
+ *
510
+ * Deliberately logged rather than thrown, and the distinction is not
511
+ * timidity: the CANONICAL store is what decides session liveness (see
512
+ * 181922 below), and its delete runs immediately after this one and is
513
+ * NOT swallowed. So a BA record that outlives its canonical twin does
514
+ * not authenticate anyone — throwing here would fail a sign-out that
515
+ * actually succeeded where it counts.
516
+ *
517
+ * What was unacceptable is that it happened SILENTLY. A revocation
518
+ * that half-failed left no trace anywhere, so the leftover key could
519
+ * only ever be found by someone already reading the keyspace for
520
+ * another reason. `error`, not `warn`: this is a revocation that did
521
+ * not complete, not a retryable blip.
522
+ */
523
+ console.error(
524
+ `[BETTER_AUTH] secondary delete FAILED for ba:${appSlug}:${key} — ` +
525
+ `the better-auth session record may still exist. Canonical delete ` +
526
+ `below is what governs liveness; if it also failed, the session is ` +
527
+ `NOT revoked:`,
528
+ err
529
+ );
530
+ }
531
+ /**
532
+ * 181922 — teardown is BOTH stores. BA's sign-out deletes only its
533
+ * secondary record; the canonical {slug}:sess:{token} would survive
534
+ * and keep the session alive (a replayed cookie still authenticates).
535
+ * `key` here IS the session token (the ba: prefix is applied above),
536
+ * so the canonical delete is one call. This also fires on session
537
+ * rotation/expiry deletions, which is correct: a session BA drops
538
+ * must not live on in the canonical store.
539
+ */
540
+ try {
541
+ const { deleteSession } = await import('../lib/session-store');
542
+ await deleteSession(key);
543
+ } catch (err) {
544
+ console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
545
+ }
253
546
  },
254
547
  },
255
548
 
@@ -290,6 +583,9 @@ export function createBetterAuthInstance(
290
583
 
291
584
  plugins: [
292
585
  nextCookies(),
586
+ // Exchange-move: present ONLY when the IDP's OAuth endpoints are
587
+ // configured. Absent otherwise, so this cannot alter existing behaviour.
588
+ ...(idpOAuth ? [genericOAuth({ config: [idpOAuth] })] : []),
293
589
  ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
294
590
  // App-supplied plugins last: they may depend on the built-ins above, and
295
591
  // ordering is BA's own concern once they are in the array.
@@ -308,16 +604,36 @@ export function isBetterAuthEnabled(): boolean {
308
604
  /**
309
605
  * Get Better Auth Next.js route handlers (GET, POST).
310
606
  * Initializes Better Auth from IDP config on first call, caches the instance.
607
+ *
608
+ * STATE LIVES ON globalThis, NOT IN MODULE SCOPE (181814, measured 2026-08-07):
609
+ * under Turbopack, instrumentation.ts and the /api/auth/[...all] route are
610
+ * different chunks and each loads its OWN copy of this module. Module-level
611
+ * `configuredOpts` set by configureBetterAuth() in instrumentation never
612
+ * reached the copy getBetterAuthInstance() read in the route — measured
613
+ * identity probe: sameInstance=false, route-side plugins=["next-cookies"]
614
+ * while the instrumentation-side instance carried every configured plugin.
615
+ * No throw anywhere (the double-init guard never sees the other copy), core
616
+ * auth green, plugin routes 404. globalThis is per-process, per-runtime —
617
+ * exactly the scope this state must have.
311
618
  */
312
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
313
- let cachedInstance: any = null;
314
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
- let initPromise: Promise<any> | null = null;
316
- let configuredOpts: CreateBetterAuthInstanceOptions = {};
619
+ interface BetterAuthModuleState {
620
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
621
+ cachedInstance: any;
622
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
623
+ initPromise: Promise<any> | null;
624
+ configuredOpts: CreateBetterAuthInstanceOptions;
625
+ }
317
626
 
318
- // Expose for server-side session access (decode-session.ts)
319
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
320
- export { cachedInstance as __betterAuthInstance };
627
+ const STATE_KEY = '__payezBetterAuthState';
628
+ const g = globalThis as unknown as Record<string, BetterAuthModuleState | undefined>;
629
+ g[STATE_KEY] ??= { cachedInstance: null, initPromise: null, configuredOpts: {} };
630
+ const state = g[STATE_KEY];
631
+
632
+ // Expose for server-side session access (decode-session.ts). A getter so the
633
+ // export tracks the SHARED state, not a chunk-local snapshot.
634
+ export function __getBetterAuthInstance(): unknown {
635
+ return state.cachedInstance;
636
+ }
321
637
 
322
638
  /**
323
639
  * Configure Better Auth instance options for this process.
@@ -331,28 +647,28 @@ export { cachedInstance as __betterAuthInstance };
331
647
  * cannot be applied retroactively.
332
648
  */
333
649
  export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
334
- if (cachedInstance) {
650
+ if (state.cachedInstance) {
335
651
  throw new Error(
336
652
  '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
337
653
  'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
338
654
  );
339
655
  }
340
- configuredOpts = opts;
656
+ state.configuredOpts = opts;
341
657
  }
342
658
 
343
659
  export async function getBetterAuthInstance() {
344
- if (cachedInstance) return cachedInstance;
660
+ if (state.cachedInstance) return state.cachedInstance;
345
661
 
346
- if (!initPromise) {
347
- initPromise = getIDPClientConfig(true).then(config => {
348
- const instance = createBetterAuthInstance(config, configuredOpts);
349
- cachedInstance = instance;
662
+ if (!state.initPromise) {
663
+ state.initPromise = getIDPClientConfig(true).then(config => {
664
+ const instance = createBetterAuthInstance(config, state.configuredOpts);
665
+ state.cachedInstance = instance;
350
666
  console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
351
667
  return instance;
352
668
  });
353
669
  }
354
670
 
355
- return initPromise;
671
+ return state.initPromise;
356
672
  }
357
673
 
358
674
  /**
@@ -432,7 +748,7 @@ export async function exchangeOAuthForIdpTokens(
432
748
  email,
433
749
  name,
434
750
  image,
435
- client_id: config.clientSlug || String(config.clientId),
751
+ client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? '',
436
752
  /**
437
753
  * Card 138635 — the RAW provider id_token, forwarded verbatim.
438
754
  *
@@ -465,6 +781,42 @@ export async function exchangeOAuthForIdpTokens(
465
781
 
466
782
  if (!oauthRes.ok) {
467
783
  console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
784
+
785
+ /**
786
+ * WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
787
+ *
788
+ * UNVERIFIED_ASSERTION has exactly two causes and the response body cannot tell
789
+ * them apart: we sent no id_token for the IDP to verify, or we sent one whose
790
+ * signature/aud/iss the IDP rejected. Without this line the operator sees a 401 on
791
+ * a login that visibly succeeded and has nowhere to go — which is where UMI sat on
792
+ * 2026-08-26.
793
+ *
794
+ * NEVER THE TOKEN ITSELF. An id_token is credential material and this whole code
795
+ * path exists to keep it out of anything persistent; a log file is persistent. We
796
+ * print presence, length, and the three claims that decide verification. `aud` is
797
+ * the Google client id — not a secret, and the single most likely mismatch, since
798
+ * the IDP checks it against what IT has registered for this client.
799
+ */
800
+ const forwarded = idTokenScope.getStore()?.idToken;
801
+ let claims: Record<string, unknown> = {};
802
+ if (forwarded) {
803
+ try {
804
+ claims = decodeJwt(forwarded) as Record<string, unknown>;
805
+ } catch {
806
+ claims = { decode: 'FAILED — not a well-formed JWT' };
807
+ }
808
+ }
809
+ console.error('[BETTER_AUTH] exchange diagnostics:', {
810
+ id_token_forwarded: !!forwarded,
811
+ id_token_length: forwarded ? forwarded.length : 0,
812
+ // Absent id_token => the IDP had nothing to verify. Present => compare these
813
+ // against the client's registered Google credentials on the IDP.
814
+ aud: claims.aud ?? null,
815
+ iss: claims.iss ?? null,
816
+ email_verified: claims.email_verified ?? null,
817
+ sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
818
+ idp_url: idpUrl,
819
+ });
468
820
  return false;
469
821
  }
470
822
 
@@ -478,22 +830,69 @@ export async function exchangeOAuthForIdpTokens(
478
830
  }
479
831
 
480
832
  // Build IDP token data
481
- // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
482
- // !hasRefreshToken was a reliable "2FA required" signal.
483
833
  //
484
- // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
485
- // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
486
- // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
487
- // bypassing the 2FA gate entirely.
834
+ // 170007 CONTRACT (BAPert ruling on 121350, agreed with DotNetPert
835
+ // 2026-08-08 — see mail thread, not re-derived here):
836
+ // 1. The IDP emits `requires_two_factor` (snake_case, both serializers,
837
+ // per the estate law) as a NON-NULLABLE bool on OAuthCallbackResponse
838
+ // (PayEz.DTOs OAuthCallbackResponse.cs) — ALWAYS present. No IDP
839
+ // change needed or wanted: emitting a second, camelCase name for the
840
+ // same fact would be the dialect problem the estate law exists to
841
+ // prevent, not a fix.
842
+ // 2. Read requires_two_factor (snake) ONLY. CORRECTED mid-review:
843
+ // BAPert's first draft of this contract said "read both casings,
844
+ // defensively" — DotNetPert rejected that: a dual-casing client-side
845
+ // read gives one fact two wire names just as surely as a dual-casing
846
+ // IDP emit would, and it is the exact audience-dialect mistake
847
+ // (186443) made deliberately instead of by accident. This package
848
+ // previously read camel ONLY (card 170083, now corrected — it must
849
+ // NOT record "emit both casings" as the IDP-side fix), which is why
850
+ // this line silently fell through to the heuristic below on every
851
+ // real call. The fix is reading the one name the IDP actually sends,
852
+ // not tolerating both.
853
+ // 3. The old !hasRefreshToken fallback (IDP-2FA-BUG below) is RETIRED,
854
+ // not patched. DotNetPert traced ExternalAuthController's
855
+ // oauth-callback: IssueRefreshToken and RequiresTwoFactor both derive
856
+ // from the SAME isFullyAuthenticated variable, so they cannot
857
+ // disagree on this path — the divergence this fallback existed for
858
+ // is not present here. Worse, the heuristic would currently produce
859
+ // the RIGHT answer by coincidence, which hides the day it stops
860
+ // being derived from one variable.
861
+ // 4. requires_two_factor is non-nullable, so reaching the `true`
862
+ // default below means the response ISN'T an OAuthCallbackResponse at
863
+ // all (wrong endpoint / error body / failed parse) — logged as that,
864
+ // not as "2FA status unknown". Fail-closed to requiresTwoFactor=true
865
+ // either way (rule 3).
866
+ // 5. mfaVerified = !requiresTwoFactor, computed fresh from THIS
867
+ // response — no hardcoding, no carrying a prior session value
868
+ // forward (that half of the monotonic-OR defect lived in
869
+ // refresh.ts / ensure-fresh-access-token.ts, fixed separately).
488
870
  //
489
- // FIX: Always set mfaVerified=false on the initial OAuth exchange.
490
- // The tenant's require_2fa setting is enforced by the viability endpoint
491
- // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
492
- // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
493
- // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
494
- // IDP correctly withholds refresh_token until post-2FA.
495
- const hasRefreshToken = !!result.refresh_token;
496
- const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
871
+ // PRIOR STATE, for the record: this used to hardcode mfaVerified=false on
872
+ // every OAuth exchange as a blunt workaround for a since-disproven IDP
873
+ // bug, which itself caused every Google user to be sent to /mfa even on
874
+ // require_2fa=FALSE tenants (NextPert 13450's blocker, failing T4 on the
875
+ // demo tenant). That workaround is removed along with the bug it worked
876
+ // around.
877
+ const requiresTwoFactor: boolean = (() => {
878
+ // SNAKE ONLY (BAPert's corrected ruling, 2026-08-08): reading camel as
879
+ // a "defensive" secondary would give one fact two wire names — exactly
880
+ // the audience-dialect mistake (186443) being made deliberately. The
881
+ // estate law is explicit-snake-per-field-both-serializers; the IDP
882
+ // already complies (OAuthCallbackResponse.cs, both System.Text.Json
883
+ // and Newtonsoft). No IDP change, no camel read, no dual-casing.
884
+ const v = result.requires_two_factor ?? result.user?.requires_two_factor;
885
+ if (v === undefined) {
886
+ console.warn(
887
+ '[BETTER_AUTH] oauth-callback response has no requires_two_factor field at all — ' +
888
+ 'unexpected response shape (requires_two_factor is non-nullable on OAuthCallbackResponse; ' +
889
+ 'this is not "2FA status unknown", it means we are not looking at an oauth-callback response). ' +
890
+ 'Defaulting requiresTwoFactor=true (fail closed).',
891
+ );
892
+ return true;
893
+ }
894
+ return !!v;
895
+ })();
497
896
  const idpTokenData = {
498
897
  idpAccessToken: result.access_token,
499
898
  idpRefreshToken: result.refresh_token,
@@ -505,7 +904,7 @@ export async function exchangeOAuthForIdpTokens(
505
904
  name: result.user?.full_name || result.user?.name || result.name || name,
506
905
  image: image,
507
906
  roles: result.user?.roles || result.roles || [],
508
- mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
907
+ mfaVerified: !requiresTwoFactor, // re-derived fresh — see 170007 contract above
509
908
  // Cards 141688/141741 — RELAYED, NOT DERIVED.
510
909
  //
511
910
  // The IDP already decides this (ExternalAuthController:943 and :2024,
@@ -671,7 +1070,43 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
671
1070
  const location = response.headers.get('location') || '';
672
1071
  if (location.includes('/api/auth/error') || location.includes('please_restart')) {
673
1072
  console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
674
- return Response.redirect(new URL(loginPath, request.url), 302);
1073
+ // *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
1074
+ // Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
1075
+ // runtime and again on node 24:
1076
+ // Response.redirect('/account-auth/login', 302)
1077
+ // -> TypeError: Failed to parse URL from /account-auth/login
1078
+ // That requirement is the whole cause of the bug this replaces. The old line
1079
+ // was:
1080
+ // return Response.redirect(new URL(loginPath, request.url), 302);
1081
+ // `request.url` inside a containerised Next standalone server carries the
1082
+ // LISTEN address, not the address the browser used, so a container bound to
1083
+ // 0.0.0.0:8080 handed the browser `http://0.0.0.0:8080/sign-in` — an address
1084
+ // that can NEVER load (net::ERR_ADDRESS_INVALID, confirmed in a real browser
1085
+ // by QAPert). Reload cannot recover it, because the address is invalid rather
1086
+ // than down. It fired on the ORDINARY path: the oauth_state cookie is
1087
+ // Max-Age=600, so anyone spending >10 minutes at Google's account chooser
1088
+ // landed on a dead tab.
1089
+ //
1090
+ // A RELATIVE Location header is legal (RFC 7231 §7.1.2) and the browser
1091
+ // resolves it against the origin it is ACTUALLY on, so no origin has to be
1092
+ // guessed, configured, or derived from a header. This is the same shape the
1093
+ // app's own middleware already emits successfully (307 + relative), which is
1094
+ // why page redirects survived while this one did not.
1095
+ //
1096
+ // WHY NOT REBUILD FROM BETTER_AUTH_URL / rawBaseURL, the other proposal:
1097
+ // (a) rawBaseURL is NOT in scope here — it is a local inside
1098
+ // createBetterAuthInstance (~:397), a different function ~676 lines up.
1099
+ // Reaching it means recomputing the env precedence or exporting a
1100
+ // helper, i.e. more moving parts for a strictly worse result.
1101
+ // (b) It would reintroduce a guessed origin and make this path depend on
1102
+ // BETTER_AUTH_URL being set correctly per leg. Relative needs no env.
1103
+ // (c) Anything Host-derived would open host-header injection on a redirect
1104
+ // that is currently host-INVARIANT. Relative keeps that property.
1105
+ //
1106
+ // Works whether loginPath is relative (package default '/account-auth/login',
1107
+ // and the graft passes '/sign-in') or absolute — an absolute Location is also
1108
+ // legal, so a consumer passing a full URL still behaves.
1109
+ return new Response(null, { status: 302, headers: { location: loginPath } });
675
1110
  }
676
1111
  }
677
1112