@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/assets/auth.sqlite.schema.ts +479 -0
  4. package/assets/bun-sqlite.test.ts +46 -0
  5. package/assets/d1.test.ts +41 -0
  6. package/assets/drizzle.config.ts +2 -0
  7. package/assets/generate-auth-schema.mjs +25 -0
  8. package/assets/platform.sqlite.schema.ts +46 -0
  9. package/assets/polar-checkout.ts +10 -0
  10. package/assets/polar-route.ts +19 -0
  11. package/assets/polar.test.ts +30 -0
  12. package/assets/polar.ts +12 -0
  13. package/assets/purchase.bun-sqlite.ts +38 -0
  14. package/assets/purchase.sqlite.ts +39 -0
  15. package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
  16. package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
  17. package/assets/sqlite-migrations/meta/_journal.json +13 -0
  18. package/assets/turso.test.ts +40 -0
  19. package/package.json +44 -5
  20. package/src/adapters/database.mjs +448 -0
  21. package/src/adapters/deployment.mjs +101 -0
  22. package/src/adapters/email.mjs +75 -0
  23. package/src/adapters/payments.mjs +270 -0
  24. package/src/catalog.mjs +128 -0
  25. package/src/cli.mjs +294 -0
  26. package/src/generate.mjs +183 -0
  27. package/src/template.mjs +93 -0
  28. package/template/.env.docker +16 -0
  29. package/template/.env.example +67 -0
  30. package/template/DESIGN.md +58 -0
  31. package/template/LICENSE +21 -0
  32. package/template/biome.json +46 -0
  33. package/template/docker-compose.yml +47 -0
  34. package/template/docs/project-guide.md +61 -0
  35. package/template/drizzle/0000_create-app-role.sql +54 -0
  36. package/template/drizzle/0001_add-rls-context.sql +21 -0
  37. package/template/drizzle/0002_platform_tables.sql +320 -0
  38. package/template/drizzle/meta/0002_snapshot.json +2125 -0
  39. package/template/drizzle/meta/_journal.json +27 -0
  40. package/template/drizzle.config.ts +25 -0
  41. package/template/e2e/authenticated-ui.spec.ts +40 -0
  42. package/template/e2e/headless-design.spec.ts +66 -0
  43. package/template/e2e/navigation-loading.spec.ts +70 -0
  44. package/template/e2e/theme-responsive.spec.ts +260 -0
  45. package/template/gitignore +24 -0
  46. package/template/messages/de.json +212 -0
  47. package/template/messages/en.json +212 -0
  48. package/template/package.json +101 -0
  49. package/template/paraglide.config.ts +49 -0
  50. package/template/playwright.config.ts +58 -0
  51. package/template/project.inlang/settings.json +12 -0
  52. package/template/public/drizzle.svg +1 -0
  53. package/template/public/favicon.ico +0 -0
  54. package/template/public/logo192.png +0 -0
  55. package/template/public/logo512.png +0 -0
  56. package/template/public/manifest.json +25 -0
  57. package/template/public/robots.txt +3 -0
  58. package/template/scripts/check-client-bundle.ts +150 -0
  59. package/template/scripts/check-server-fns.ts +290 -0
  60. package/template/scripts/check-sql-raw.ts +50 -0
  61. package/template/scripts/compile-paraglide.ts +12 -0
  62. package/template/scripts/postgres/README.md +76 -0
  63. package/template/scripts/postgres/create-app-role.sh +60 -0
  64. package/template/scripts/seed-local.ts +331 -0
  65. package/template/scripts/validate-prod-env.ts +53 -0
  66. package/template/src/__tests__/README.md +9 -0
  67. package/template/src/__tests__/account-security.test.ts +664 -0
  68. package/template/src/__tests__/admin-http-surface.test.ts +311 -0
  69. package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
  70. package/template/src/__tests__/auth-harness.ts +104 -0
  71. package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
  72. package/template/src/__tests__/auth-redirect.test.ts +120 -0
  73. package/template/src/__tests__/body-limit.test.ts +47 -0
  74. package/template/src/__tests__/creem.test.ts +62 -0
  75. package/template/src/__tests__/demo-checkout.test.ts +141 -0
  76. package/template/src/__tests__/email-locale.test.ts +110 -0
  77. package/template/src/__tests__/global-setup.ts +80 -0
  78. package/template/src/__tests__/input-rules.test.ts +0 -0
  79. package/template/src/__tests__/message-parity.test.ts +38 -0
  80. package/template/src/__tests__/navigation-loading.test.tsx +252 -0
  81. package/template/src/__tests__/nowpayments.test.ts +106 -0
  82. package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
  83. package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
  84. package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
  85. package/template/src/__tests__/rls-policy.test.ts +120 -0
  86. package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
  87. package/template/src/__tests__/search-params-oauth.test.ts +57 -0
  88. package/template/src/__tests__/security-headers.test.ts +12 -0
  89. package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
  90. package/template/src/__tests__/stripe.test.ts +84 -0
  91. package/template/src/__tests__/test-db.ts +32 -0
  92. package/template/src/__tests__/test-rls-db.ts +23 -0
  93. package/template/src/__tests__/user-update-guard.test.ts +109 -0
  94. package/template/src/components/LocaleSwitcher.tsx +79 -0
  95. package/template/src/components/ThemeToggle.tsx +47 -0
  96. package/template/src/components/auth/social.tsx +90 -0
  97. package/template/src/components/auth/turnstile.tsx +67 -0
  98. package/template/src/components/root/navigation-progress.tsx +32 -0
  99. package/template/src/components/root/not-found.tsx +20 -0
  100. package/template/src/components/root/pending.tsx +9 -0
  101. package/template/src/components/root/root-document.tsx +37 -0
  102. package/template/src/components/root/root-error.tsx +34 -0
  103. package/template/src/components/shells.tsx +201 -0
  104. package/template/src/components/ui/styles.ts +9 -0
  105. package/template/src/db/auth.schema.ts +455 -0
  106. package/template/src/db/helper.ts +38 -0
  107. package/template/src/db/index.ts +15 -0
  108. package/template/src/db/platform.schema.ts +122 -0
  109. package/template/src/db/schema.ts +2 -0
  110. package/template/src/env.ts +65 -0
  111. package/template/src/hooks/use-theme.ts +32 -0
  112. package/template/src/integrations/better-auth/header-user.tsx +36 -0
  113. package/template/src/integrations/better-auth/session-query.ts +11 -0
  114. package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
  115. package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
  116. package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
  117. package/template/src/integrations/react-email/theme.ts +75 -0
  118. package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
  119. package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
  120. package/template/src/lib/auth-client.ts +27 -0
  121. package/template/src/lib/auth-paths.ts +94 -0
  122. package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
  123. package/template/src/lib/auth-plugins/field-rules.ts +84 -0
  124. package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
  125. package/template/src/lib/auth-timing.ts +9 -0
  126. package/template/src/lib/auth.ts +279 -0
  127. package/template/src/lib/captcha.ts +11 -0
  128. package/template/src/lib/email.tsx +162 -0
  129. package/template/src/lib/github.ts +18 -0
  130. package/template/src/lib/locale-cookie.ts +12 -0
  131. package/template/src/lib/localized-pages.ts +25 -0
  132. package/template/src/lib/oauth-metadata.ts +19 -0
  133. package/template/src/lib/permissions.ts +29 -0
  134. package/template/src/lib/security-headers.ts +20 -0
  135. package/template/src/lib/site.ts +59 -0
  136. package/template/src/lib/theme.ts +42 -0
  137. package/template/src/lib/user-update-guard.ts +35 -0
  138. package/template/src/middlewares/admin.ts +46 -0
  139. package/template/src/middlewares/auth-rate-limit.ts +37 -0
  140. package/template/src/middlewares/better-auth.ts +15 -0
  141. package/template/src/middlewares/body-limit.ts +62 -0
  142. package/template/src/middlewares/database.ts +13 -0
  143. package/template/src/middlewares/protected.ts +45 -0
  144. package/template/src/routeTree.gen.ts +536 -0
  145. package/template/src/router.tsx +53 -0
  146. package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
  147. package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
  148. package/template/src/routes/__root.tsx +175 -0
  149. package/template/src/routes/admin/audit.tsx +74 -0
  150. package/template/src/routes/admin/index.tsx +7 -0
  151. package/template/src/routes/admin/oauth-apps.tsx +164 -0
  152. package/template/src/routes/admin/route.tsx +27 -0
  153. package/template/src/routes/admin/users.tsx +215 -0
  154. package/template/src/routes/api/auth/$.ts +14 -0
  155. package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
  156. package/template/src/routes/api/webhooks/creem.ts +69 -0
  157. package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
  158. package/template/src/routes/api/webhooks/stripe.ts +74 -0
  159. package/template/src/routes/dashboard/account.tsx +430 -0
  160. package/template/src/routes/dashboard/index.tsx +128 -0
  161. package/template/src/routes/dashboard/route.tsx +17 -0
  162. package/template/src/routes/forgot-password.tsx +65 -0
  163. package/template/src/routes/index.tsx +85 -0
  164. package/template/src/routes/oauth/consent.tsx +77 -0
  165. package/template/src/routes/reset-password.tsx +78 -0
  166. package/template/src/routes/sign-in.tsx +174 -0
  167. package/template/src/routes/sign-up.tsx +135 -0
  168. package/template/src/routes/two-factor.tsx +74 -0
  169. package/template/src/server/README.md +3 -0
  170. package/template/src/server/account-security.ts +163 -0
  171. package/template/src/server/admin-audit.ts +37 -0
  172. package/template/src/server/admin-users.ts +112 -0
  173. package/template/src/server/auth.ts +44 -0
  174. package/template/src/server/demo-checkout.ts +67 -0
  175. package/template/src/server/oauth-clients.ts +83 -0
  176. package/template/src/server/payment-toggles.ts +39 -0
  177. package/template/src/server.ts +20 -0
  178. package/template/src/services/README.md +3 -0
  179. package/template/src/services/account-security.ts +893 -0
  180. package/template/src/services/admin-security.ts +164 -0
  181. package/template/src/services/admin-users.ts +181 -0
  182. package/template/src/services/auth-rate-limit.ts +32 -0
  183. package/template/src/services/creem.ts +83 -0
  184. package/template/src/services/demo-checkout.ts +100 -0
  185. package/template/src/services/nowpayments.ts +154 -0
  186. package/template/src/services/oauth-client.ts +85 -0
  187. package/template/src/services/oauth-clients.ts +15 -0
  188. package/template/src/services/payment-providers.ts +157 -0
  189. package/template/src/services/payment-toggles.ts +48 -0
  190. package/template/src/services/purchase.ts +233 -0
  191. package/template/src/services/stripe.ts +106 -0
  192. package/template/src/start.ts +35 -0
  193. package/template/src/styles/layout.css +93 -0
  194. package/template/src/styles/theme.css +84 -0
  195. package/template/src/styles/ui.css +233 -0
  196. package/template/src/styles.css +90 -0
  197. package/template/src/utils/api-error.ts +129 -0
  198. package/template/src/utils/auth-errors.ts +157 -0
  199. package/template/src/utils/auth-redirect.ts +61 -0
  200. package/template/src/utils/body-limit.ts +84 -0
  201. package/template/src/utils/cn.ts +6 -0
  202. package/template/src/utils/input-rules.ts +172 -0
  203. package/template/src/utils/locale-paths.ts +18 -0
  204. package/template/src/utils/same-page-locale-href.ts +20 -0
  205. package/template/src/utils/search-params.ts +71 -0
  206. package/template/tsconfig.json +30 -0
  207. package/template/tsr.config.json +6 -0
  208. package/template/vite.config.ts +24 -0
  209. package/template/vitest.config.ts +26 -0
  210. package/template/worker-configuration.d.ts +14660 -0
  211. package/template/wrangler.jsonc +39 -0
@@ -0,0 +1,893 @@
1
+ /**
2
+ * Account-security domain logic (CONTEXT.md "Account security").
3
+ *
4
+ * Verified-email invariant: every account's email is verified. GitHub
5
+ * sign-up may only proceed when GitHub supplies an email it has itself
6
+ * verified — otherwise the attempt fails outright (no user, no session).
7
+ */
8
+
9
+ import { APIError } from "better-auth";
10
+ import {
11
+ generateRandomString,
12
+ type SecretConfig,
13
+ symmetricEncrypt,
14
+ } from "better-auth/crypto";
15
+
16
+ /** Adapter where-clause slice: no operator means equality. */
17
+ type SecurityWhere = {
18
+ field: string;
19
+ value: string | Date;
20
+ operator?: "starts_with" | "gt";
21
+ };
22
+
23
+ /**
24
+ * The slice of a Better Auth instance this service needs. Structural on
25
+ * purpose: production `createAuth(db)` satisfies it, and tests can build a
26
+ * minimal instance without the full plugin stack. `$context` is used for
27
+ * the second-factor attempt counter, the OTP resend cooldown, the
28
+ * stale-session replacement and the password-free two-factor mutations —
29
+ * none of which have public api endpoints.
30
+ */
31
+ export type SecurityAuth = {
32
+ api: {
33
+ // `disableCookieCache` forces a database read. Every credential-change
34
+ // gate below must pass it: the signed cookie-cache blob keeps answering
35
+ // for up to `cookieCache.maxAge` (60s) after the session row is deleted,
36
+ // so a plain read lets a caller whose session was just evicted from
37
+ // another browser — or wiped by `revokeSessionsOnPasswordReset` mid-flow —
38
+ // pass the gate, mutate the credential, and only then hit the 401 that
39
+ // `revokeOtherSessions`/`replaceSession` (authoritative reads) raise.
40
+ getSession(ctx: {
41
+ headers: Headers;
42
+ query?: { disableCookieCache?: boolean };
43
+ }): Promise<{
44
+ user: { id: string; email: string; twoFactorEnabled?: boolean | null };
45
+ session: { token: string; createdAt: Date | string };
46
+ } | null>;
47
+ requestPasswordResetEmailOTP(ctx: {
48
+ body: { email: string };
49
+ }): Promise<unknown>;
50
+ resetPasswordEmailOTP(ctx: {
51
+ body: { email: string; otp: string; password: string };
52
+ }): Promise<unknown>;
53
+ checkVerificationOTP(ctx: {
54
+ body: { email: string; type: "forget-password" | "sign-in"; otp: string };
55
+ }): Promise<unknown>;
56
+ revokeOtherSessions(ctx: { headers: Headers }): Promise<unknown>;
57
+ sendVerificationOTP(ctx: {
58
+ body: { email: string; type: "sign-in" };
59
+ }): Promise<unknown>;
60
+ verifyTOTP(ctx: {
61
+ body: { code: string };
62
+ headers: Headers;
63
+ }): Promise<unknown>;
64
+ verifyBackupCode(ctx: {
65
+ body: { code: string };
66
+ headers: Headers;
67
+ }): Promise<unknown>;
68
+ createVerificationOTP(ctx: {
69
+ body: { email: string; type: "sign-in" };
70
+ }): Promise<string>;
71
+ signInEmailOTP(ctx: {
72
+ body: { email: string; otp: string };
73
+ headers: Headers;
74
+ }): Promise<unknown>;
75
+ };
76
+ $context: Promise<{
77
+ secretConfig: string | SecretConfig;
78
+ adapter: {
79
+ count(args: { model: string; where: SecurityWhere[] }): Promise<number>;
80
+ create(args: {
81
+ model: string;
82
+ data: Record<string, unknown>;
83
+ }): Promise<unknown>;
84
+ update(args: {
85
+ model: string;
86
+ where: SecurityWhere[];
87
+ update: Record<string, unknown>;
88
+ }): Promise<unknown>;
89
+ deleteMany(args: {
90
+ model: string;
91
+ where: SecurityWhere[];
92
+ }): Promise<unknown>;
93
+ };
94
+ internalAdapter: {
95
+ createVerificationValue(value: {
96
+ identifier: string;
97
+ value: string;
98
+ expiresAt: Date;
99
+ }): Promise<unknown>;
100
+ findVerificationValue(
101
+ identifier: string,
102
+ ): Promise<{ expiresAt: Date | string } | null | undefined>;
103
+ deleteVerificationByIdentifier(identifier: string): Promise<unknown>;
104
+ deleteSession(token: string): Promise<unknown>;
105
+ updateUser(
106
+ userId: string,
107
+ data: Record<string, unknown>,
108
+ ): Promise<unknown>;
109
+ };
110
+ }>;
111
+ };
112
+
113
+ // -------------------------------------------------------- session eviction
114
+
115
+ /** Everything `evictOtherSessions` needs — satisfied by every auth slice here. */
116
+ type RevocableAuth = {
117
+ api: { revokeOtherSessions(ctx: { headers: Headers }): Promise<unknown> };
118
+ };
119
+
120
+ /**
121
+ * The account-security invariant (CONTEXT.md "Account security"): a change to
122
+ * a credential or to a second factor signs out every session on the account
123
+ * except the caller's own. Written down once so a seventh credential-changing
124
+ * flow cannot silently omit it.
125
+ *
126
+ * Two ordering rules, both load-bearing:
127
+ * - it authenticates by the caller's *current* cookie, so it must run before
128
+ * `replaceSession` — afterwards that cookie names a deleted row;
129
+ * - it is an `auth.api.*` call on a different pooled connection, so in any
130
+ * handler that also writes `context.db` it comes first (AGENTS.md).
131
+ */
132
+ async function evictOtherSessions(
133
+ auth: RevocableAuth,
134
+ headers: Headers,
135
+ ): Promise<void> {
136
+ await auth.api.revokeOtherSessions({ headers });
137
+ }
138
+
139
+ // ------------------------------------------------------------ send cooldown
140
+
141
+ const OTP_RESEND_COOLDOWN_SECONDS = 30;
142
+
143
+ export type OtpSendResult =
144
+ | { ok: true; retryAfterSeconds: number }
145
+ | {
146
+ ok: false;
147
+ code: "unauthorized" | "totp_required" | "cooldown";
148
+ retryAfterSeconds?: number;
149
+ };
150
+
151
+ /**
152
+ * Server-side resend throttle shared by every OTP email flow: one send per
153
+ * user per flow per 30s window. The UI countdown mirrors
154
+ * `retryAfterSeconds`, but this gate is the authority — closing the modal
155
+ * or reloading the page cannot sidestep it.
156
+ */
157
+ async function otpSendGate(
158
+ auth: SecurityAuth,
159
+ kind: "password" | "reauth",
160
+ userId: string,
161
+ ): Promise<OtpSendResult> {
162
+ const { internalAdapter } = await auth.$context;
163
+ const identifier = `otp-cooldown-${kind}-${userId}`;
164
+ const existing = await internalAdapter.findVerificationValue(identifier);
165
+ if (existing) {
166
+ const remainingMs = new Date(existing.expiresAt).getTime() - Date.now();
167
+ if (remainingMs > 0)
168
+ return {
169
+ ok: false,
170
+ code: "cooldown",
171
+ retryAfterSeconds: Math.ceil(remainingMs / 1000),
172
+ };
173
+ await internalAdapter.deleteVerificationByIdentifier(identifier);
174
+ }
175
+ await internalAdapter.createVerificationValue({
176
+ identifier,
177
+ value: "1",
178
+ expiresAt: new Date(Date.now() + OTP_RESEND_COOLDOWN_SECONDS * 1000),
179
+ });
180
+ return { ok: true, retryAfterSeconds: OTP_RESEND_COOLDOWN_SECONDS };
181
+ }
182
+
183
+ /**
184
+ * The cooldown throttles sends while a code is in flight — once the code is
185
+ * consumed, the next flow may send immediately (e.g. opening the 2FA-enable
186
+ * dialog right after a re-auth spent the previous code).
187
+ */
188
+ async function clearOtpCooldown(
189
+ auth: SecurityAuth,
190
+ kind: "password" | "reauth",
191
+ userId: string,
192
+ ): Promise<void> {
193
+ const { internalAdapter } = await auth.$context;
194
+ await internalAdapter.deleteVerificationByIdentifier(
195
+ `otp-cooldown-${kind}-${userId}`,
196
+ );
197
+ }
198
+
199
+ // -------------------------------------------------------- password change
200
+
201
+ export type PasswordChangeResult =
202
+ | { ok: true }
203
+ | {
204
+ ok: false;
205
+ code:
206
+ | "unauthorized"
207
+ | "invalid_otp"
208
+ | "invalid_password"
209
+ | "too_many_attempts";
210
+ };
211
+
212
+ /**
213
+ * Email a password-change OTP to the calling session's own verified email.
214
+ * The email is taken from the session, never from client input.
215
+ */
216
+ export async function requestPasswordChangeOtp(
217
+ auth: SecurityAuth,
218
+ headers: Headers,
219
+ ): Promise<OtpSendResult> {
220
+ const session = await auth.api.getSession({ headers });
221
+ if (!session) return { ok: false, code: "unauthorized" };
222
+ const gate = await otpSendGate(auth, "password", session.user.id);
223
+ if (!gate.ok) return gate;
224
+ await auth.api.requestPasswordResetEmailOTP({
225
+ body: { email: session.user.email },
226
+ });
227
+ return gate;
228
+ }
229
+
230
+ /**
231
+ * Step-1 verification for the two-modal password flow: proves the code is
232
+ * right WITHOUT consuming it (Better Auth's check endpoint only burns
233
+ * attempts on wrong guesses), so the same code still authorizes the actual
234
+ * change in step 2. Wrong guesses here count against the shared 3-attempt
235
+ * budget of the code.
236
+ */
237
+ export async function checkPasswordChangeOtp(
238
+ auth: SecurityAuth,
239
+ headers: Headers,
240
+ input: { otp: string },
241
+ ): Promise<PasswordChangeResult> {
242
+ const session = await auth.api.getSession({ headers });
243
+ if (!session) return { ok: false, code: "unauthorized" };
244
+ try {
245
+ await auth.api.checkVerificationOTP({
246
+ body: {
247
+ email: session.user.email,
248
+ type: "forget-password",
249
+ otp: input.otp,
250
+ },
251
+ });
252
+ } catch (err) {
253
+ if (err instanceof APIError) {
254
+ const status = (err as { statusCode?: number }).statusCode;
255
+ return {
256
+ ok: false,
257
+ code: status === 403 ? "too_many_attempts" : "invalid_otp",
258
+ };
259
+ }
260
+ throw err;
261
+ }
262
+ return { ok: true };
263
+ }
264
+
265
+ /**
266
+ * OTP-gated password change (CONTEXT.md "Account security"): verify the OTP,
267
+ * set the new password (creating the credential for passwordless users),
268
+ * then force every session except the caller's off.
269
+ */
270
+ export async function changePasswordWithOtp(
271
+ auth: SecurityAuth,
272
+ headers: Headers,
273
+ input: { otp: string; newPassword: string },
274
+ ): Promise<PasswordChangeResult> {
275
+ const session = await auth.api.getSession({ headers });
276
+ if (!session) return { ok: false, code: "unauthorized" };
277
+ try {
278
+ await auth.api.resetPasswordEmailOTP({
279
+ body: {
280
+ email: session.user.email,
281
+ otp: input.otp,
282
+ password: input.newPassword,
283
+ },
284
+ });
285
+ } catch (err) {
286
+ if (err instanceof APIError) {
287
+ const status = (err as { statusCode?: number }).statusCode;
288
+ if (status === 403) return { ok: false, code: "too_many_attempts" };
289
+ return {
290
+ ok: false,
291
+ code:
292
+ status === 400 && /password/i.test(err.message ?? "")
293
+ ? "invalid_password"
294
+ : "invalid_otp",
295
+ };
296
+ }
297
+ throw err;
298
+ }
299
+ // Two shapes, one outcome: the caller signed in and nobody else. Which one
300
+ // applies depends on `revokeSessionsOnPasswordReset`, which the reset-by-link
301
+ // arm needs ON and which this OTP endpoint honours too — when it fires, the
302
+ // account-wide wipe has already taken the caller's own session with it, so
303
+ // they get a replacement rather than an eviction they can no longer authorize.
304
+ // The probe must bypass the cookie cache, or under that flag it reports the
305
+ // just-deleted session as alive and takes the eviction branch, which then
306
+ // 401s with the password already changed and no session left.
307
+ if (
308
+ await auth.api.getSession({ headers, query: { disableCookieCache: true } })
309
+ ) {
310
+ await evictOtherSessions(auth, headers);
311
+ } else {
312
+ await replaceSession(
313
+ auth,
314
+ session.user.email,
315
+ headers,
316
+ session.session.token,
317
+ );
318
+ }
319
+ await clearOtpCooldown(auth, "password", session.user.id);
320
+ return { ok: true };
321
+ }
322
+
323
+ // -------------------------------------------------- fresh-session re-auth
324
+
325
+ export type ReauthResult =
326
+ | { ok: true }
327
+ | {
328
+ ok: false;
329
+ code:
330
+ | "unauthorized"
331
+ | "totp_required"
332
+ | "invalid_code"
333
+ | "too_many_attempts";
334
+ };
335
+
336
+ const REAUTH_ATTEMPT_PREFIX = "reauth-totp-attempt-";
337
+ const REAUTH_ATTEMPT_LIMIT = 5;
338
+ const REAUTH_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
339
+
340
+ /**
341
+ * Email a re-auth code to the calling session's own email — the no-2FA arm
342
+ * of the fresh-session challenge. Refused when 2FA is on: the authenticator
343
+ * (or a backup code) is the factor then, and a sign-in OTP would sidestep
344
+ * it.
345
+ */
346
+ export async function requestReauthOtp(
347
+ auth: SecurityAuth,
348
+ headers: Headers,
349
+ ): Promise<OtpSendResult> {
350
+ const session = await auth.api.getSession({ headers });
351
+ if (!session) return { ok: false, code: "unauthorized" };
352
+ if (session.user.twoFactorEnabled)
353
+ return { ok: false, code: "totp_required" };
354
+ const gate = await otpSendGate(auth, "reauth", session.user.id);
355
+ if (!gate.ok) return gate;
356
+ await auth.api.sendVerificationOTP({
357
+ body: { email: session.user.email, type: "sign-in" },
358
+ });
359
+ return gate;
360
+ }
361
+
362
+ /**
363
+ * Fresh-session re-auth (sudo mode). Passkey registration and account
364
+ * unlinking sit behind Better Auth's freshSessionMiddleware, which compares
365
+ * `session.createdAt` against `freshAge` — the only way through is a newly
366
+ * minted session. The caller re-proves identity (TOTP or a single-use
367
+ * backup code when 2FA is on, the emailed code otherwise) and on success a
368
+ * one-shot sign-in OTP is created server-side (never emailed) and redeemed
369
+ * via `signInEmailOTP`, whose fresh session cookie reaches the browser
370
+ * through tanstackStartCookies.
371
+ *
372
+ * `/sign-in/email-otp` bypassing the 2FA hook is safe here: the HTTP path is
373
+ * in disabledPaths, and this server-side call happens only after the factor
374
+ * appropriate to the account was just verified.
375
+ */
376
+ export async function reauthenticate(
377
+ auth: SecurityAuth,
378
+ headers: Headers,
379
+ input: { code: string; backupCode?: boolean },
380
+ ): Promise<ReauthResult> {
381
+ const session = await auth.api.getSession({ headers });
382
+ if (!session) return { ok: false, code: "unauthorized" };
383
+ const email = session.user.email;
384
+
385
+ if (session.user.twoFactorEnabled) {
386
+ const verified = await verifySecondFactor(auth, headers, session.user.id, {
387
+ code: input.code,
388
+ backupCode: input.backupCode,
389
+ });
390
+ if (!verified.ok) return verified;
391
+ await replaceSession(auth, email, headers, session.session.token);
392
+ return { ok: true };
393
+ }
394
+
395
+ try {
396
+ // Wrong-code budget is the plugin's own allowedAttempts (3 per code).
397
+ // Redeeming the code IS the fresh-session mint for this arm.
398
+ await auth.api.signInEmailOTP({
399
+ body: { email, otp: input.code },
400
+ headers,
401
+ });
402
+ } catch (err) {
403
+ if (err instanceof APIError) {
404
+ const status = (err as { statusCode?: number }).statusCode;
405
+ return {
406
+ ok: false,
407
+ code: status === 403 ? "too_many_attempts" : "invalid_code",
408
+ };
409
+ }
410
+ throw err;
411
+ }
412
+ // The browser just overwrote its cookie with the fresh session's, so the
413
+ // stale token must not stay redeemable.
414
+ const { internalAdapter } = await auth.$context;
415
+ await internalAdapter.deleteSession(session.session.token);
416
+ await clearOtpCooldown(auth, "reauth", session.user.id);
417
+ return { ok: true };
418
+ }
419
+
420
+ /**
421
+ * Mint a brand-new session for the caller and revoke their current one.
422
+ * Mechanism: a one-shot sign-in OTP created server-side (never emailed) and
423
+ * redeemed immediately — the only sanctioned session-minting door reachable
424
+ * from here. The fresh cookie reaches the browser via tanstackStartCookies;
425
+ * with the cookie session cache enabled it also refreshes the cached user
426
+ * snapshot (e.g. after twoFactorEnabled flips).
427
+ */
428
+ async function replaceSession(
429
+ auth: SecurityAuth,
430
+ email: string,
431
+ headers: Headers,
432
+ staleToken: string,
433
+ ): Promise<void> {
434
+ const otp = await auth.api.createVerificationOTP({
435
+ body: { email, type: "sign-in" },
436
+ });
437
+ await auth.api.signInEmailOTP({ body: { email, otp }, headers });
438
+ const { internalAdapter } = await auth.$context;
439
+ await internalAdapter.deleteSession(staleToken);
440
+ }
441
+
442
+ /**
443
+ * Prove the caller's second factor (TOTP, or a single-use backup code) under
444
+ * the shared attempt gate. Used by every 2FA-sensitive mutation.
445
+ */
446
+ async function verifySecondFactor(
447
+ auth: SecurityAuth,
448
+ headers: Headers,
449
+ userId: string,
450
+ input: { code: string; backupCode?: boolean },
451
+ ): Promise<
452
+ { ok: true } | { ok: false; code: "invalid_code" | "too_many_attempts" }
453
+ > {
454
+ const gate = await recordSecondFactorAttempt(auth, userId);
455
+ if (!gate.ok) return gate;
456
+ try {
457
+ if (input.backupCode)
458
+ await auth.api.verifyBackupCode({ body: { code: input.code }, headers });
459
+ else await auth.api.verifyTOTP({ body: { code: input.code }, headers });
460
+ } catch (err) {
461
+ if (err instanceof APIError) return { ok: false, code: "invalid_code" };
462
+ throw err;
463
+ }
464
+ await gate.clear();
465
+ return { ok: true };
466
+ }
467
+
468
+ /**
469
+ * Attempt gate for the 2FA arm (TOTP and backup codes): the session-scoped
470
+ * verify endpoints have no built-in throttle, and server-side api calls
471
+ * skip HTTP rate limiting — without this, a stolen session cookie could
472
+ * brute-force the 6-digit space. Insert-then-count so parallel bursts see
473
+ * each other's rows (a read-modify-write counter would let a flood
474
+ * through). Rows expire with the window; a successful re-auth clears them.
475
+ */
476
+ async function recordSecondFactorAttempt(
477
+ auth: SecurityAuth,
478
+ userId: string,
479
+ ): Promise<
480
+ | { ok: false; code: "too_many_attempts" }
481
+ | { ok: true; clear: () => Promise<unknown> }
482
+ > {
483
+ const ctx = await auth.$context;
484
+ const prefix = `${REAUTH_ATTEMPT_PREFIX}${userId}-`;
485
+ await ctx.internalAdapter.createVerificationValue({
486
+ identifier: `${prefix}${crypto.randomUUID()}`,
487
+ value: "1",
488
+ expiresAt: new Date(Date.now() + REAUTH_ATTEMPT_WINDOW_MS),
489
+ });
490
+ const attempts = await ctx.adapter.count({
491
+ model: "verification",
492
+ where: [
493
+ { field: "identifier", operator: "starts_with", value: prefix },
494
+ { field: "expiresAt", operator: "gt", value: new Date() },
495
+ ],
496
+ });
497
+ if (attempts > REAUTH_ATTEMPT_LIMIT)
498
+ return { ok: false, code: "too_many_attempts" };
499
+ return {
500
+ ok: true,
501
+ clear: () =>
502
+ ctx.adapter.deleteMany({
503
+ model: "verification",
504
+ where: [
505
+ { field: "identifier", operator: "starts_with", value: prefix },
506
+ ],
507
+ }),
508
+ };
509
+ }
510
+
511
+ // ---------------------------------------------------- passkey removal (fresh)
512
+
513
+ /**
514
+ * Focused slice for passkey removal — the only operation here that needs the
515
+ * delete endpoint and the freshness config. Kept separate from SecurityAuth
516
+ * so callers/tests that never touch passkeys aren't forced to include the
517
+ * passkey plugin.
518
+ */
519
+ /** All the freshness gate itself needs — no passkey plugin required. */
520
+ export type FreshSessionAuth = {
521
+ api: {
522
+ getSession(ctx: {
523
+ headers: Headers;
524
+ query?: { disableCookieCache?: boolean };
525
+ }): Promise<{
526
+ session: { createdAt: Date | string };
527
+ } | null>;
528
+ };
529
+ $context: Promise<{ sessionConfig: { freshAge: number } }>;
530
+ };
531
+
532
+ export type PasskeyRemovalAuth = FreshSessionAuth &
533
+ RevocableAuth & {
534
+ api: {
535
+ deletePasskey(ctx: {
536
+ body: { id: string };
537
+ headers: Headers;
538
+ }): Promise<unknown>;
539
+ };
540
+ };
541
+
542
+ export type PasskeyRemoveResult =
543
+ | { ok: true }
544
+ | { ok: false; code: "unauthorized" | "not_fresh" };
545
+
546
+ /**
547
+ * Whether the session clears Better Auth's freshness bar, replicating
548
+ * `freshSessionMiddleware` exactly (`sessionConfig.freshAge`, 0 = disabled).
549
+ * The fresh check must be identical to the one guarding passkey ADD so the
550
+ * two operations demand the same proof of a recent login.
551
+ */
552
+ async function isSessionFresh(
553
+ auth: FreshSessionAuth,
554
+ createdAt: Date | string,
555
+ maxAgeSeconds?: number,
556
+ ): Promise<boolean> {
557
+ const { sessionConfig } = await auth.$context;
558
+ const freshAge = maxAgeSeconds ?? sessionConfig.freshAge;
559
+ if (freshAge === 0) return true;
560
+ const age = Date.now() - new Date(createdAt).getTime();
561
+ return age < freshAge * 1000;
562
+ }
563
+
564
+ /**
565
+ * How recently a session must have proved itself for high-risk product
566
+ * mutations (short “sudo” window; see ADR 0008).
567
+ *
568
+ * Deliberately far shorter than Better Auth's `freshAge` (24h), which the
569
+ * passkey gates use: a cookie stolen from someone who signed in this morning
570
+ * clears 24h without proving anything, which is exactly the theft this is meant
571
+ * to stop. `reauthenticate` mints a new session in both arms, so the retried
572
+ * operation lands inside this window. The Starter documents the constant;
573
+ * product pages that use it live in clones.
574
+ */
575
+ export const MONEY_REAUTH_MAX_AGE_SECONDS = 5 * 60;
576
+
577
+ /**
578
+ * The shared gate behind every operation that demands a recent proof of
579
+ * identity. Defaults to Better Auth's own freshness bar, so the passkey paths
580
+ * keep asking for exactly what they asked for before; high-risk product
581
+ * operations may pass the short sudo window above.
582
+ *
583
+ * A refusal is a *code*, not a throw: the UI turns `not_fresh` into the
584
+ * re-authentication dialog and replays the original call, and an operation the
585
+ * user abandons at that prompt has changed nothing.
586
+ */
587
+ export async function requireFreshSession(
588
+ auth: FreshSessionAuth,
589
+ headers: Headers,
590
+ maxAgeSeconds: number = MONEY_REAUTH_MAX_AGE_SECONDS,
591
+ ): Promise<{ ok: true } | { ok: false; code: "unauthorized" | "not_fresh" }> {
592
+ // Authoritative read: a freshness gate must never trust the cookie cache,
593
+ // which would report a just-evicted session as still present (and fresh).
594
+ const session = await auth.api.getSession({
595
+ headers,
596
+ query: { disableCookieCache: true },
597
+ });
598
+ if (!session) return { ok: false, code: "unauthorized" };
599
+ if (!(await isSessionFresh(auth, session.session.createdAt, maxAgeSeconds))) {
600
+ return { ok: false, code: "not_fresh" };
601
+ }
602
+ return { ok: true };
603
+ }
604
+
605
+ /**
606
+ * Remove a passkey behind the SAME fresh-session gate as adding one. Better
607
+ * Auth's `/passkey/delete-passkey` uses only `sessionMiddleware`, so a stolen
608
+ * stale cookie could otherwise strip a victim's passkeys (a security
609
+ * downgrade / lock-out) even though it cannot add one. A stale session gets
610
+ * `not_fresh`, which the UI turns into the re-auth dialog (TOTP for 2FA
611
+ * users) and retries — matching passkey addition and account unlink.
612
+ *
613
+ * Retiring a sign-in credential evicts every other session: the device whose
614
+ * passkey this was must not keep a live session on the account.
615
+ */
616
+ export async function removePasskeyWithFreshSession(
617
+ auth: PasskeyRemovalAuth,
618
+ headers: Headers,
619
+ input: { passkeyId: string },
620
+ ): Promise<PasskeyRemoveResult> {
621
+ const { sessionConfig } = await auth.$context;
622
+ const fresh = await requireFreshSession(
623
+ auth,
624
+ headers,
625
+ sessionConfig.freshAge,
626
+ );
627
+ if (!fresh.ok) return fresh;
628
+ await auth.api.deletePasskey({ body: { id: input.passkeyId }, headers });
629
+ await evictOtherSessions(auth, headers);
630
+ return { ok: true };
631
+ }
632
+
633
+ // ------------------------------------------- two-factor management (no password)
634
+
635
+ /**
636
+ * Single source for the TOTP issuer — `twoFactor({ issuer })` in the auth
637
+ * config must use this same value so enrollment URIs stay consistent.
638
+ */
639
+ export const TWO_FACTOR_ISSUER = "Starter";
640
+
641
+ export type TwoFactorEnableResult =
642
+ | { ok: true; totpURI: string; backupCodes: string[] }
643
+ | {
644
+ ok: false;
645
+ code:
646
+ | "unauthorized"
647
+ | "invalid_otp"
648
+ | "too_many_attempts"
649
+ | "already_enabled";
650
+ };
651
+
652
+ export type TwoFactorManageResult =
653
+ | { ok: true }
654
+ | {
655
+ ok: false;
656
+ code:
657
+ | "unauthorized"
658
+ | "not_enabled"
659
+ | "invalid_code"
660
+ | "too_many_attempts";
661
+ };
662
+
663
+ export type BackupCodesResult =
664
+ | { ok: true; backupCodes: string[] }
665
+ | Extract<TwoFactorManageResult, { ok: false }>;
666
+
667
+ /** RFC 4648 base32 (unpadded) — how otpauth URIs carry the raw secret. */
668
+ function base32Encode(input: string): string {
669
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
670
+ let bits = 0;
671
+ let value = 0;
672
+ let out = "";
673
+ for (const byte of new TextEncoder().encode(input)) {
674
+ value = (value << 8) | byte;
675
+ bits += 8;
676
+ while (bits >= 5) {
677
+ out += alphabet[(value >>> (bits - 5)) & 31];
678
+ bits -= 5;
679
+ }
680
+ }
681
+ if (bits > 0) out += alphabet[(value << (5 - bits)) & 31];
682
+ return out;
683
+ }
684
+
685
+ /** Same shape as Better Auth's default backup codes: 10 × `xxxxx-xxxxx`. */
686
+ function newBackupCodes(): string[] {
687
+ return Array.from({ length: 10 }, () => {
688
+ const code = generateRandomString(10, "a-z", "0-9", "A-Z");
689
+ return `${code.slice(0, 5)}-${code.slice(5)}`;
690
+ });
691
+ }
692
+
693
+ /** Better Auth's default at-rest format for the twoFactor.backupCodes
694
+ * column: the JSON array encrypted with the auth secret (pinned by the
695
+ * round-trip tests against the official verify endpoint). */
696
+ function encodeBackupCodes(
697
+ secretConfig: string | SecretConfig,
698
+ codes: string[],
699
+ ): Promise<string> {
700
+ return symmetricEncrypt({ key: secretConfig, data: JSON.stringify(codes) });
701
+ }
702
+
703
+ /**
704
+ * Enable 2FA gated by an emailed one-time code — never a password (the
705
+ * password may be exactly what the user no longer trusts, and GitHub-only
706
+ * users have none). Better Auth's own `/two-factor/enable` cannot skip the
707
+ * password check for credential users, so this replicates its mutation
708
+ * (same secret encryption via `symmetricEncrypt`, same plaintext-JSON
709
+ * backup-code storage as our config) and the round-trip is pinned by tests
710
+ * against the official verify endpoints. The HTTP management endpoints are
711
+ * in disabledPaths; this is the only enable path.
712
+ *
713
+ * `twoFactorEnabled` stays false until the user confirms a code from their
714
+ * app (the official enrollment completion via `/two-factor/verify-totp`).
715
+ *
716
+ * The other sessions go at this point rather than at that confirmation: this
717
+ * is the step that proves control of the verified email, and it is the only
718
+ * enable path, so the invariant cannot be reached from the plugin's own
719
+ * `/two-factor/verify-totp` — which also serves the routine sign-in challenge
720
+ * and must never sign a returning user out of their other devices. The cost
721
+ * is that abandoning enrolment after the QR code still evicts them.
722
+ */
723
+ export async function enableTwoFactorWithOtp(
724
+ auth: SecurityAuth,
725
+ headers: Headers,
726
+ input: { otp: string },
727
+ ): Promise<TwoFactorEnableResult> {
728
+ // Authoritative: the flow evicts every other session after writing the
729
+ // twoFactor row, so a caller whose own row is already gone must be refused
730
+ // here — before the write — not left with a written row and a thrown 401.
731
+ const session = await auth.api.getSession({
732
+ headers,
733
+ query: { disableCookieCache: true },
734
+ });
735
+ if (!session) return { ok: false, code: "unauthorized" };
736
+ if (session.user.twoFactorEnabled)
737
+ return { ok: false, code: "already_enabled" };
738
+ try {
739
+ await auth.api.checkVerificationOTP({
740
+ body: { email: session.user.email, type: "sign-in", otp: input.otp },
741
+ });
742
+ } catch (err) {
743
+ if (err instanceof APIError) {
744
+ const status = (err as { statusCode?: number }).statusCode;
745
+ return {
746
+ ok: false,
747
+ code: status === 403 ? "too_many_attempts" : "invalid_otp",
748
+ };
749
+ }
750
+ throw err;
751
+ }
752
+ const ctx = await auth.$context;
753
+ const secret = generateRandomString(32);
754
+ const backupCodes = newBackupCodes();
755
+ // Replaces any abandoned enrollment for this user.
756
+ await ctx.adapter.deleteMany({
757
+ model: "twoFactor",
758
+ where: [{ field: "userId", value: session.user.id }],
759
+ });
760
+ await ctx.adapter.create({
761
+ model: "twoFactor",
762
+ data: {
763
+ secret: await symmetricEncrypt({ key: ctx.secretConfig, data: secret }),
764
+ backupCodes: await encodeBackupCodes(ctx.secretConfig, backupCodes),
765
+ userId: session.user.id,
766
+ verified: false,
767
+ },
768
+ });
769
+ await evictOtherSessions(auth, headers);
770
+ const issuer = encodeURIComponent(TWO_FACTOR_ISSUER);
771
+ const totpURI = `otpauth://totp/${issuer}:${encodeURIComponent(
772
+ session.user.email,
773
+ )}?secret=${base32Encode(secret)}&issuer=${issuer}&digits=6&period=30`;
774
+ return { ok: true, totpURI, backupCodes };
775
+ }
776
+
777
+ /**
778
+ * Disable 2FA gated by the factor itself (TOTP or a backup code) — a stolen
779
+ * password must not be enough to strip the account's second factor. Mirrors
780
+ * the official endpoint's effects: flag off, row deleted, session swapped
781
+ * (so the cookie cache stops saying twoFactorEnabled), and trusted-device
782
+ * records revoked — for ALL devices, since we can't expire the calling
783
+ * device's cookie from here; without its verification row a trust cookie is
784
+ * inert.
785
+ */
786
+ export async function disableTwoFactorWithCode(
787
+ auth: SecurityAuth,
788
+ headers: Headers,
789
+ input: { code: string; backupCode?: boolean },
790
+ ): Promise<TwoFactorManageResult> {
791
+ // Authoritative: this deletes the twoFactor row and evicts other sessions,
792
+ // so a caller whose own row is already gone is refused before either write.
793
+ const session = await auth.api.getSession({
794
+ headers,
795
+ query: { disableCookieCache: true },
796
+ });
797
+ if (!session) return { ok: false, code: "unauthorized" };
798
+ if (!session.user.twoFactorEnabled) return { ok: false, code: "not_enabled" };
799
+ const verified = await verifySecondFactor(
800
+ auth,
801
+ headers,
802
+ session.user.id,
803
+ input,
804
+ );
805
+ if (!verified.ok) return verified;
806
+
807
+ const ctx = await auth.$context;
808
+ await ctx.internalAdapter.updateUser(session.user.id, {
809
+ twoFactorEnabled: false,
810
+ });
811
+ await ctx.adapter.deleteMany({
812
+ model: "twoFactor",
813
+ where: [{ field: "userId", value: session.user.id }],
814
+ });
815
+ await ctx.adapter.deleteMany({
816
+ model: "verification",
817
+ where: [
818
+ { field: "identifier", operator: "starts_with", value: "trust-device-" },
819
+ { field: "value", value: session.user.id },
820
+ ],
821
+ });
822
+ // Before the swap: `headers` still names a live session here, and after it
823
+ // they would not.
824
+ await evictOtherSessions(auth, headers);
825
+ await replaceSession(
826
+ auth,
827
+ session.user.email,
828
+ headers,
829
+ session.session.token,
830
+ );
831
+ return { ok: true };
832
+ }
833
+
834
+ /**
835
+ * Rotate backup codes, gated like disable (the old codes are invalidated,
836
+ * which is 2FA-sensitive). Storage format matches enable.
837
+ */
838
+ export async function regenerateBackupCodesWithCode(
839
+ auth: SecurityAuth,
840
+ headers: Headers,
841
+ input: { code: string; backupCode?: boolean },
842
+ ): Promise<BackupCodesResult> {
843
+ // Authoritative: this rotates the backup codes and evicts other sessions,
844
+ // so a caller whose own row is already gone is refused before the rotation
845
+ // overwrites the codes.
846
+ const session = await auth.api.getSession({
847
+ headers,
848
+ query: { disableCookieCache: true },
849
+ });
850
+ if (!session) return { ok: false, code: "unauthorized" };
851
+ if (!session.user.twoFactorEnabled) return { ok: false, code: "not_enabled" };
852
+ const verified = await verifySecondFactor(
853
+ auth,
854
+ headers,
855
+ session.user.id,
856
+ input,
857
+ );
858
+ if (!verified.ok) return verified;
859
+
860
+ const ctx = await auth.$context;
861
+ const backupCodes = newBackupCodes();
862
+ await ctx.adapter.update({
863
+ model: "twoFactor",
864
+ where: [{ field: "userId", value: session.user.id }],
865
+ update: {
866
+ backupCodes: await encodeBackupCodes(ctx.secretConfig, backupCodes),
867
+ },
868
+ });
869
+ await evictOtherSessions(auth, headers);
870
+ return { ok: true, backupCodes };
871
+ }
872
+
873
+ // ------------------------------------------------------------ github gate
874
+
875
+ export type GithubSignupVerdict =
876
+ | { ok: true }
877
+ | { ok: false; code: "email_not_found" | "github_email_unverified" };
878
+
879
+ /**
880
+ * Gate for creating a user from a GitHub profile. Deny codes double as the
881
+ * `/sign-in?error=<code>` redirect contract: `email_not_found` matches the
882
+ * code Better Auth's callback already emits when the profile has no email
883
+ * (this gate is the defense-in-depth backstop behind that check).
884
+ */
885
+ export function evaluateGithubSignup(profile: {
886
+ email: string | null | undefined;
887
+ emailVerified: boolean;
888
+ }): GithubSignupVerdict {
889
+ if (!profile.email) return { ok: false, code: "email_not_found" };
890
+ if (!profile.emailVerified)
891
+ return { ok: false, code: "github_email_unverified" };
892
+ return { ok: true };
893
+ }