@pithy-sh/auth 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/docs/apple-signin.md +139 -0
  4. package/docs/facebook-oauth.md +92 -0
  5. package/docs/github-oauth.md +99 -0
  6. package/docs/google-oauth.md +118 -0
  7. package/package.json +58 -0
  8. package/pithy.manifest.json +108 -0
  9. package/src/admin/users.ts +357 -0
  10. package/src/audit/actions.ts +71 -0
  11. package/src/audit/emit.ts +223 -0
  12. package/src/capability.ts +300 -0
  13. package/src/client/api.ts +501 -0
  14. package/src/client/projection.ts +55 -0
  15. package/src/cloudflare-test.d.ts +16 -0
  16. package/src/data/betterAuth.ts +210 -0
  17. package/src/data/device.ts +57 -0
  18. package/src/data/kitFields.ts +69 -0
  19. package/src/data/rotatedToken.ts +40 -0
  20. package/src/data/tables.ts +38 -0
  21. package/src/device/registry.ts +139 -0
  22. package/src/email/send.ts +67 -0
  23. package/src/http/adminRoutes.ts +368 -0
  24. package/src/http/baseUrl.ts +109 -0
  25. package/src/http/csrf.ts +98 -0
  26. package/src/http/devLoginRoute.ts +159 -0
  27. package/src/http/errors.ts +70 -0
  28. package/src/http/guards.ts +158 -0
  29. package/src/http/middleware.ts +67 -0
  30. package/src/http/rateLimit.ts +36 -0
  31. package/src/http/resolve.ts +152 -0
  32. package/src/http/responses.ts +199 -0
  33. package/src/http/routes.ts +325 -0
  34. package/src/http/schemas.ts +118 -0
  35. package/src/http/views.ts +93 -0
  36. package/src/i18n/errorCopy.es.ts +35 -0
  37. package/src/i18n/errorCopy.ts +99 -0
  38. package/src/index.ts +24 -0
  39. package/src/instance/auth.ts +309 -0
  40. package/src/instance/plugins.ts +172 -0
  41. package/src/instance/providers.ts +185 -0
  42. package/src/instance/secrets.ts +197 -0
  43. package/src/migrations/0001_init.ts +229 -0
  44. package/src/migrations/pluginTables.ts +334 -0
  45. package/src/seeds/devSession.ts +286 -0
  46. package/src/seeds/example.ts +48 -0
  47. package/src/test-utils/liveApp.ts +338 -0
  48. package/src/token/rotation.ts +104 -0
  49. package/src/version.generated.ts +16 -0
@@ -0,0 +1,93 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Session, User } from "../data/betterAuth";
5
+ import type { Device } from "../data/device";
6
+ import type { AdminDeviceView, AdminSessionView, AdminUserView } from "./responses";
7
+
8
+ /**
9
+ * The admin projections — what a management client is shown of a user, a session, and a device.
10
+ *
11
+ * **No handler returns a row.** Each function below states what leaves the Worker, and each return
12
+ * type is `z.output` of the matching object in `responses.ts`, so the shape a client validates
13
+ * against and the shape this produces are one declaration. Adding a field to one and not the other
14
+ * does not compile.
15
+ *
16
+ * Separated from `adminRoutes.ts` so they can be exercised without a request, a scope, or a
17
+ * credential: `responses.test.ts` runs each against a fully populated row and compares the result
18
+ * with its schema. A leak here is a leak in every pane, and it should not need a Workers pool to
19
+ * catch.
20
+ */
21
+
22
+ /**
23
+ * The user projection.
24
+ *
25
+ * Email and display name are personal data and they are here on purpose: identifying the right person
26
+ * is the entire job of a support pane, and `auth:users:read` is precisely the grant an adopter makes
27
+ * when they accept that. Nothing else on `pithy_auth_users` is withheld because nothing else on it is
28
+ * sensitive — the table holds no credential at all, which is what passwordless-only buys.
29
+ *
30
+ * `locale` is projected for the same reason: a support pane answering "why is this person getting
31
+ * English emails" needs to see whether they ever chose, and null — never chosen — is the answer half
32
+ * the time. It is a preference, not a credential.
33
+ */
34
+ export function userView(user: User): AdminUserView {
35
+ return {
36
+ id: user.id,
37
+ email: user.email,
38
+ name: user.name,
39
+ emailVerified: user.emailVerified,
40
+ image: user.image,
41
+ locale: user.locale,
42
+ createdAt: user.createdAt.toISOString(),
43
+ updatedAt: user.updatedAt.toISOString(),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The session projection — **without the token**.
49
+ *
50
+ * The token *is* the credential: a bearer of it is the user, everywhere, until it expires. Projecting
51
+ * it would turn a read scope into silent impersonation and would leave no trace distinguishable from
52
+ * the person's own activity, which is exactly the capability this surface refuses to offer. The `id` is
53
+ * the handle instead, and it is what `POST /admin/sessions/revoke` accepts.
54
+ *
55
+ * `familyId` is dropped too — it is internal rotation bookkeeping, and a pane that rendered it would
56
+ * invite somebody to act on a correlation the model does not promise to keep stable.
57
+ *
58
+ * The IP and user-agent stay: "where is this person signed in from" is the question the pane exists to
59
+ * answer, and it is the one that catches a stolen session.
60
+ */
61
+ export function sessionView(session: Session): AdminSessionView {
62
+ return {
63
+ id: session.id,
64
+ deviceId: session.deviceId,
65
+ ipAddress: session.ipAddress,
66
+ userAgent: session.userAgent,
67
+ createdAt: session.createdAt.toISOString(),
68
+ updatedAt: session.updatedAt.toISOString(),
69
+ expiresAt: session.expiresAt.toISOString(),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * The device projection — **without the push token**.
75
+ *
76
+ * An APNs/FCM token is the capability to put a notification on somebody's lock screen. It is a
77
+ * credential, it is useless to a dashboard, and a management client that held one could message an
78
+ * adopter's users under the adopter's own app identity. It never leaves the Worker.
79
+ */
80
+ export function deviceView(device: Device): AdminDeviceView {
81
+ return {
82
+ id: device.id,
83
+ userId: device.userId,
84
+ platform: device.platform,
85
+ name: device.name,
86
+ model: device.model,
87
+ osVersion: device.osVersion,
88
+ appVersion: device.appVersion,
89
+ lastIp: device.lastIp,
90
+ lastSeenAt: device.lastSeenAt.toISOString(),
91
+ createdAt: device.createdAt.toISOString(),
92
+ };
93
+ }
@@ -0,0 +1,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // LOCALE es — an unreviewed first pass. Not American English by design.
5
+
6
+ /**
7
+ * What this kit says in Spanish that `@better-auth/i18n` does not (#452).
8
+ *
9
+ * **Only the gaps, and only the ones a reader meets.** The plugin ships 22 languages of its own, and
10
+ * they are maintained upstream — copying them here would fork every one of them on the day it landed.
11
+ * This layers over its `es`, which covers 34 of the 52 codes the kit's composed plugin set can raise.
12
+ *
13
+ * The three that matter most were among the missing: `INVALID_OTP`, `OTP_EXPIRED` and
14
+ * `TOO_MANY_ATTEMPTS` are the whole of `emailOTP`'s vocabulary, which is to say the whole of what a
15
+ * person meets when a passwordless sign-in goes wrong. A reader who mistyped a code was reading English
16
+ * on an otherwise Spanish screen.
17
+ *
18
+ * The rest of the gap is misconfiguration an adopter causes rather than anything a reader can act on,
19
+ * and it stays English deliberately — see `ENGLISH_ON_PURPOSE` in `./errorCopy`.
20
+ */
21
+ export const AUTH_ERRORS_ES = {
22
+ /** A one-time code that is not the one. The commonest refusal there is. */
23
+ INVALID_OTP: "El código no es válido.",
24
+ /** The code was right once. Codes are short-lived on purpose. */
25
+ OTP_EXPIRED: "El código ha caducado. Pide uno nuevo.",
26
+ /** Rate limiting on the verification attempt, not on the request for a code. */
27
+ TOO_MANY_ATTEMPTS: "Demasiados intentos. Espera un momento y vuelve a intentarlo.",
28
+ /**
29
+ * The sign-in was reached by a cross-site navigation and refused.
30
+ *
31
+ * A person can meet this without doing anything wrong — an embedded browser, a link opened from
32
+ * another site — so it says what to do rather than naming the mechanism.
33
+ */
34
+ CROSS_SITE_NAVIGATION_LOGIN_BLOCKED: "Por seguridad, abre el enlace directamente en tu navegador.",
35
+ } as const satisfies Record<string, string>;
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { locales } from "@better-auth/i18n";
5
+ import type { BetterAuthPlugin } from "better-auth";
6
+ import { BASE_ERROR_CODES } from "better-auth";
7
+ import { AUTH_ERRORS_ES } from "./errorCopy.es";
8
+
9
+ /**
10
+ * The words Better Auth's own refusals are said in.
11
+ *
12
+ * ## Why this is server-side, when every other translation in the kit is not
13
+ *
14
+ * `docs/I18N.md` holds that the server never localizes an error: a `PithyError`'s `message` stays
15
+ * English permanently, because it is at once the operator's diagnostic and the fallback for a client
16
+ * that cannot translate, and a translating client renders `t.maybe(code, params) ?? message`.
17
+ *
18
+ * These are not `PithyError`s. Better Auth owns these routes and answers them in its own flat
19
+ * `{ message, code }` before anything of ours sees the failure — better-call renders an endpoint's
20
+ * `APIError` into a Response inside `instance.handler` (#449). So the choice is not *where* to
21
+ * translate them but *whether*, and leaving them was leaving the last English sentences on an otherwise
22
+ * translated screen — met by the most ordinary mistake there is, mistyping a one-time code.
23
+ *
24
+ * Translating them at the server also reaches a caller the client seam cannot: a mobile app holding a
25
+ * bearer token, or a second-party integration, gets the reader's language without shipping
26
+ * `@pithy-sh/i18n`. And nothing is lost to the operator, because the plugin keeps the English on
27
+ * `originalMessage` rather than replacing it.
28
+ *
29
+ * ## Where the words come from
30
+ *
31
+ * `@better-auth/i18n` (MIT) ships 22 languages of its own, maintained upstream. This layers over them
32
+ * rather than restating them: copying a locale here would fork it on the day it landed, and the kit's
33
+ * own rule for catalogs is that they ship in the package and are never copied. {@link AUTH_ERRORS_ES}
34
+ * carries only what its `es` is missing.
35
+ *
36
+ * **The layering is per key, never per locale**, for the same reason `composeMessages` merges that way:
37
+ * a locale object replacing another is a fork wearing the shape of an override.
38
+ */
39
+
40
+ /** Every locale this kit writes Better Auth's refusals in. English is the source and is not listed. */
41
+ const KIT_OVERRIDES: Record<string, Record<string, string>> = { es: AUTH_ERRORS_ES };
42
+
43
+ /**
44
+ * Codes that stay English on purpose, and why.
45
+ *
46
+ * **A declared list rather than a silent remainder.** The gate in `./errorCopy.test.ts` requires every
47
+ * code the composed plugin set can raise to be either translated or named here, so a code Better Auth
48
+ * adds in a later release fails the build instead of quietly reaching somebody in English. That is the
49
+ * same shape the migration-order table uses, and for the same reason: the property is only true as a
50
+ * set.
51
+ *
52
+ * Every entry is a fault an **adopter** caused in their own configuration or their own request, not
53
+ * anything a reader can act on. Translating those makes them harder to search for and no easier to fix.
54
+ */
55
+ export const ENGLISH_ON_PURPOSE: Record<string, string> = {
56
+ ASYNC_VALIDATION_NOT_SUPPORTED: "A field validator returned a promise where the adapter takes none.",
57
+ BODY_MUST_BE_AN_OBJECT: "The caller sent something that is not a JSON object.",
58
+ CALLBACK_URL_REQUIRED: "The adopter's own call omitted the URL it wants a reader returned to.",
59
+ CHANGE_EMAIL_DISABLED: "The adopter switched the feature off in their own config.",
60
+ FAILED_TO_CREATE_VERIFICATION: "A write this Worker owns failed; the operator reads our logs, not a reader.",
61
+ FIELD_NOT_ALLOWED: "The caller sent a field the adopter's own schema does not declare.",
62
+ ID_TOKEN_NOT_SUPPORTED: "The configured provider does not do id-token sign-in.",
63
+ INVALID_CALLBACK_URL: "The adopter's configured callback is not a URL this Worker trusts.",
64
+ INVALID_ERROR_CALLBACK_URL: "As above, for the error return.",
65
+ INVALID_NEW_USER_CALLBACK_URL: "As above, for the first-sign-in return.",
66
+ INVALID_ORIGIN: "The CSRF origin gate. An operator reads this while checking `trustedOrigins`.",
67
+ INVALID_REDIRECT_URL: "A redirect target outside the configured set.",
68
+ METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED: "A caller used the wrong verb on the session route.",
69
+ MISSING_OR_NULL_ORIGIN: "The other half of the origin gate, and the same audience.",
70
+ };
71
+
72
+ /** Every error code the given plugin set can raise, plus Better Auth's own base vocabulary. */
73
+ export function composedErrorCodes(plugins: readonly BetterAuthPlugin[]): string[] {
74
+ const codes = new Set(Object.keys(BASE_ERROR_CODES as Record<string, unknown>));
75
+ for (const plugin of plugins) {
76
+ const own = (plugin as { $ERROR_CODES?: Record<string, unknown> }).$ERROR_CODES;
77
+ for (const code of Object.keys(own ?? {})) codes.add(code);
78
+ }
79
+ return [...codes].sort();
80
+ }
81
+
82
+ /**
83
+ * The dictionary the plugin is configured with: what it ships, with this kit's own words over the top.
84
+ *
85
+ * `en` is included and is the plugin's own, so a reader whose negotiated locale is English gets exactly
86
+ * the sentences Better Auth already wrote — this changes nothing for a project that serves one language.
87
+ */
88
+ export function authErrorTranslations(): Record<string, Record<string, string>> {
89
+ const bundled = locales as unknown as Record<string, Record<string, string>>;
90
+ const merged: Record<string, Record<string, string>> = {};
91
+ for (const [locale, dictionary] of Object.entries(bundled)) {
92
+ merged[locale] = { ...dictionary, ...(KIT_OVERRIDES[locale] ?? {}) };
93
+ }
94
+ // A locale this kit writes that the plugin does not ship at all still has to reach the reader.
95
+ for (const [locale, dictionary] of Object.entries(KIT_OVERRIDES)) {
96
+ if (!merged[locale]) merged[locale] = { ...dictionary };
97
+ }
98
+ return merged;
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add auth` wires into `pithy.config.ts`. Deliberately
6
+ * narrow: the capability factory, the `requireAuth()` gate other capabilities stack, and the data /
7
+ * secret types an app or tool needs. Every other module is imported by deep path
8
+ * (`@pithy-sh/auth/src/...`) — this is the documented contract, not a barrel over the package.
9
+ */
10
+
11
+ export { AuthAuditActions } from "./audit/actions";
12
+ export { type AuthCapability, AuthConfig, type AuthConfigInput, auth, isAuthCapability } from "./capability";
13
+ export { type Device, DevicePlatform } from "./data/device";
14
+ export { type AuthDatabase, authDatabase } from "./data/tables";
15
+ export { DEVICE_HEADERS, type DeviceMeta } from "./device/registry";
16
+ export { requireAuth } from "./http/middleware";
17
+ export {
18
+ AppleOAuthCredentials,
19
+ AUTH_APPLE_CREDENTIALS,
20
+ AUTH_GOOGLE_CREDENTIALS,
21
+ AUTH_SESSION_SECRET,
22
+ authSecretsRegistry,
23
+ GoogleOAuthCredentials,
24
+ } from "./instance/secrets";
@@ -0,0 +1,309 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
5
+ import { type BetterAuthPlugin, betterAuth } from "better-auth";
6
+ import { APIError, createAuthMiddleware } from "better-auth/api";
7
+ import { emitAfterRequest, emitProviderUnavailable } from "../audit/emit";
8
+ import { KIT_SESSION_FIELDS, KIT_USER_FIELDS } from "../data/kitFields";
9
+ import type { AuthDatabase } from "../data/tables";
10
+ import { parseDeviceMeta, registerDevice } from "../device/registry";
11
+ import { kitPlugins } from "./plugins";
12
+ import { providerUnavailable, type ResolvedProviders, unavailableProviderFor } from "./providers";
13
+
14
+ /**
15
+ * Build the `socialProviders` block from whichever provider credentials were resolved. Exported so the
16
+ * per-provider branch matrix is unit-testable without constructing a Better Auth instance — it reads
17
+ * only the resolved credentials on `deps`, never the database.
18
+ *
19
+ * **Only a `ready` provider is registered.** A `disabled` one never was; an `unresolvable` one is the
20
+ * change #381 made, and it is what keeps magic link and OTP working while one credential is unreadable.
21
+ * The narrowing is the type's doing rather than this function's discipline — `deps.google.credentials`
22
+ * does not exist until `state === "ready"` has been established.
23
+ */
24
+ export function socialProviders(deps: AuthInstanceDeps): Record<string, unknown> | undefined {
25
+ const providers: Record<string, unknown> = {};
26
+ if (deps.google.state === "ready") {
27
+ providers.google = {
28
+ clientId: deps.google.credentials.clientId,
29
+ clientSecret: deps.google.credentials.clientSecret,
30
+ accessType: "offline",
31
+ prompt: "select_account consent",
32
+ };
33
+ }
34
+ if (deps.apple.state === "ready") {
35
+ const apple = deps.apple.credentials;
36
+ providers.apple = {
37
+ clientId: apple.clientId,
38
+ clientSecret: apple.clientSecret,
39
+ ...(apple.appBundleIdentifier ? { appBundleIdentifier: apple.appBundleIdentifier } : {}),
40
+ };
41
+ }
42
+ if (deps.facebook.state === "ready") {
43
+ // Assert Facebook's email as verified. Facebook confirms a user's email before it will return it,
44
+ // and Better Auth validates the access token against this app before trusting the `/me` profile —
45
+ // so the email is genuinely the authenticated user's, verified by Facebook (the same trust Google
46
+ // and Apple get). Better Auth otherwise defaults Facebook's `emailVerified` to `false` (its OAuth
47
+ // response carries no `email_verified` claim, and the Graph API exposes no such field), which would
48
+ // wrongly route every Facebook sign-in through verify-to-link. `mapProfileToUser` overrides only
49
+ // Facebook's own email; Facebook stays out of `trustedProviders`.
50
+ providers.facebook = {
51
+ clientId: deps.facebook.credentials.clientId,
52
+ clientSecret: deps.facebook.credentials.clientSecret,
53
+ scope: ["email"],
54
+ mapProfileToUser: () => ({ emailVerified: true }),
55
+ };
56
+ }
57
+ if (deps.github.state === "ready") {
58
+ // `user:email` (Better Auth's default GitHub scope, requested explicitly here) lets the provider
59
+ // read the primary email's verified flag from the GitHub emails API — the signal account-linking
60
+ // gates on. GitHub is not a trusted provider, so an unverified GitHub email never auto-links.
61
+ providers.github = {
62
+ clientId: deps.github.credentials.clientId,
63
+ clientSecret: deps.github.credentials.clientSecret,
64
+ scope: ["user:email"],
65
+ };
66
+ }
67
+ return Object.keys(providers).length > 0 ? providers : undefined;
68
+ }
69
+
70
+ /**
71
+ * The seeding guard: a new account may be created only for an email a provider has **verified**.
72
+ *
73
+ * Passwordless sign-up (magic link / OTP) always creates `emailVerified: true`; a social sign-up
74
+ * carries the provider's own flag. Refusing an unverified-email create closes the rival-account
75
+ * seeding vector — otherwise an untrusted provider (e.g. a GitHub account holding an *unverified*
76
+ * email at someone else's address) could mint a row that a later magic-link login by the true owner
77
+ * would inherit. Returns `true` when the create must be refused. Account *linking* into an existing
78
+ * user is handled separately by Better Auth's `trustedProviders` gate; this only guards creation.
79
+ */
80
+ export function isUnverifiedSignup(user: { emailVerified?: boolean | null }): boolean {
81
+ return user.emailVerified !== true;
82
+ }
83
+
84
+ /**
85
+ * What the instance hands to Pithy's email seam to deliver. The route never sends inline — the hook
86
+ * enqueues an `@pithy-sh/email` job (`magicLink`/`otp` template) which a Workflow delivers.
87
+ */
88
+ export type AuthEmailMessage =
89
+ | { to: string; template: "magicLink"; token: string; url: string }
90
+ | { to: string; template: "otp"; code: string };
91
+
92
+ /** The email-delivery seam: enqueue (never send inline). Injected so the instance stays I/O-agnostic. */
93
+ export type SendAuthEmail = (message: AuthEmailMessage) => Promise<void>;
94
+
95
+ /**
96
+ * Everything the Better-Auth instance needs, resolved per invocation from config + request env.
97
+ *
98
+ * Generic in the adopter's plugin tuple so the composed instance's type — and therefore its `$Infer`
99
+ * surface — reflects what was actually composed rather than only the kit's own.
100
+ */
101
+ export interface AuthInstanceDeps<Plugins extends readonly BetterAuthPlugin[] = readonly BetterAuthPlugin[]>
102
+ extends ResolvedProviders {
103
+ /** The shared Kysely over the `pithy_auth_*` tables (carries `CamelCasePlugin`). */
104
+ db: AuthDatabase;
105
+ /** The Better-Auth signing/encryption secret, sourced from `@pithy-sh/secrets`. */
106
+ secret: string;
107
+ /** The public base URL of this environment's auth worker (no trailing slash). */
108
+ baseURL: string;
109
+ /** The mount path; must equal the Hono route the handler is mounted under. */
110
+ basePath: string;
111
+ /** Web origins and mobile deep-link schemes allowed as OAuth/redirect targets and for CSRF origin checks. */
112
+ trustedOrigins: string[];
113
+ // The four social providers arrive from `ResolvedProviders` — each one `disabled`, `ready` with its
114
+ // credentials, or `unresolvable`. They are the one part of this interface that is deliberately not a
115
+ // precondition: `db` and `secret` above fail the whole instance, and a provider does not (#381).
116
+ /** Deliver a magic link or OTP. Enqueues an email job; never sends inline. */
117
+ sendEmail: SendAuthEmail;
118
+ /**
119
+ * The catalog locale this request negotiated, or `null` when nothing did.
120
+ *
121
+ * Threaded through to the translator plugin, which is what puts Better Auth's own refusals in the
122
+ * reader's language (#452). An instance is built per request, so this is that request's locale.
123
+ */
124
+ locale?: string | null;
125
+ /** Session lifetime in seconds. */
126
+ sessionExpiresIn: number;
127
+ /** How often (seconds) an active session's expiry slides forward. */
128
+ sessionUpdateAge: number;
129
+ /** Magic-link / OTP token lifetime in seconds. */
130
+ verificationExpiresIn: number;
131
+ /** OTP length (digits). */
132
+ otpLength: number;
133
+ /** When true, sign-in never provisions a new user (existing accounts only). */
134
+ disableSignUp: boolean;
135
+ /** Audit seam — emits `auth/*` events. A no-op when the audit capability is absent. */
136
+ emit: AuditEmit;
137
+ /**
138
+ * The adopter's additional Better Auth plugins, from `auth({ plugins: [...] })`. Composed **after**
139
+ * the kit's own, never in place of one — `assertAdditivePlugins` has already refused a list that
140
+ * names one of them.
141
+ */
142
+ plugins: Plugins;
143
+ }
144
+
145
+ /**
146
+ * The concrete return type of `makeAuth` — the Better-Auth instance with Pithy's plugin set, and the
147
+ * adopter's on top of it.
148
+ *
149
+ * Parameterized in the plugin tuple so an adopter can name the instance their own composition produces:
150
+ * `AuthInstance<[ReturnType<typeof organization>]>`. That is the type
151
+ * `inferAdditionalFields<…>()` needs on the client, and the reason the plugin tuple is threaded through
152
+ * `makeAuth` rather than widened to `BetterAuthPlugin[]` at the door.
153
+ */
154
+ export type AuthInstance<Plugins extends readonly BetterAuthPlugin[] = readonly BetterAuthPlugin[]> = ReturnType<
155
+ typeof makeAuth<Plugins>
156
+ >;
157
+
158
+ /**
159
+ * Build the Better-Auth instance for one request.
160
+ *
161
+ * Passwordless only — `emailAndPassword` is never enabled. The Kysely adapter wraps our shared
162
+ * `CamelCasePlugin` instance, so Better Auth's camelCase model names + fields map to the snake_case
163
+ * `pithy_auth_*` columns the migration created. Dates are ISO-8601 text on SQLite; ids are WebCrypto
164
+ * UUIDs; rate limiting is durable (D1-backed) since memory limiting is per-isolate on Workers.
165
+ */
166
+ export function makeAuth<const Plugins extends readonly BetterAuthPlugin[]>(deps: AuthInstanceDeps<Plugins>) {
167
+ return betterAuth({
168
+ appName: "Pithy",
169
+ baseURL: deps.baseURL,
170
+ basePath: deps.basePath,
171
+ secret: deps.secret,
172
+ telemetry: { enabled: false },
173
+ trustedOrigins: deps.trustedOrigins,
174
+ database: { db: deps.db, type: "sqlite", transaction: false },
175
+ advanced: {
176
+ // WebCrypto UUID ids for every model — anti-enumeration, Workers-safe.
177
+ database: { generateId: () => crypto.randomUUID() },
178
+ },
179
+ // Better Auth's errors bubble out of `handler` so the Hono boundary maps them to PithyError.
180
+ //
181
+ // **Load-bearing, and measured rather than assumed (#385).** The `before` hook below throws a
182
+ // `PithyError`, which better-call's router does not recognize as its own `APIError` — so without
183
+ // this it takes the default branch: `console.error("# SERVER_ERROR: ", error)`, which prints the
184
+ // whole payload including the `action` naming `auth-github-credentials`, and answers a bodyless
185
+ // 500. Removing it reddens two cases in `http/providerResolution.workers.test.ts` (503 becomes
186
+ // 500, and the caller's message disappears) and puts a secret name in the log. A handler could not
187
+ // replace it: `onAPIError.onError`'s return value is ignored, so the only way it reaches the same
188
+ // outcome is by throwing, which is what this is.
189
+ onAPIError: { throw: true },
190
+ databaseHooks: {
191
+ user: {
192
+ create: {
193
+ // Seeding guard: refuse to create a user whose email a provider has not verified. Every
194
+ // passwordless sign-up creates emailVerified=true and every trusted/verified social sign-up
195
+ // carries a verified flag, so only an untrusted+unverified social sign-up is blocked — the
196
+ // vector where a provider could seed a rival row at an address the caller doesn't own.
197
+ before: async (user) => {
198
+ if (isUnverifiedSignup(user)) {
199
+ throw new APIError("FORBIDDEN", {
200
+ code: "EMAIL_NOT_VERIFIED",
201
+ message:
202
+ "This email isn't verified by the provider. Sign in with a magic link to verify it, then connect the provider.",
203
+ });
204
+ }
205
+ },
206
+ },
207
+ },
208
+ session: {
209
+ create: {
210
+ // Bind the session to its device (registering the device first, so the linkage is valid).
211
+ // Only fires when a request carried device headers — an internal rotation passes deviceId
212
+ // via override instead, so this hook leaves it untouched.
213
+ before: async (session, ctx) => {
214
+ const meta = ctx?.headers ? parseDeviceMeta(ctx.headers) : undefined;
215
+ if (!meta) return;
216
+ await registerDevice(deps.db, meta, {
217
+ userId: session.userId,
218
+ lastIp: session.ipAddress ?? null,
219
+ now: new Date(),
220
+ });
221
+ return { data: { ...session, deviceId: meta.id } };
222
+ },
223
+ },
224
+ },
225
+ },
226
+ hooks: {
227
+ /**
228
+ * Refuse a provider this deployment enables and could not resolve, before Better Auth answers it
229
+ * with the 404 it gives a provider nobody configured (#381).
230
+ *
231
+ * **This is what makes degrading not the same as degrading quietly.** The instance was built
232
+ * without the provider, so `socialProviders` does not hold it, so `sign-in/social` would throw
233
+ * `PROVIDER_NOT_FOUND` — a 404 that tells somebody who signs in with GitHub every day that
234
+ * GitHub was never set up. It is the same answer for a fault and for a choice, and the two are
235
+ * not the same fact. This hook answers 503 with its own code instead, and records the attempt.
236
+ *
237
+ * **A `before` hook rather than a Hono route, and that is forced rather than preferred.** The
238
+ * provider is in the request *body*, and the body is Better Auth's to read: a Hono handler ahead
239
+ * of the catch-all would have to consume the stream that the catch-all then hands to
240
+ * `instance.handler(c.req.raw)`. Here the body is already parsed against the endpoint's own
241
+ * schema, and this reads one field out of it.
242
+ */
243
+ before: createAuthMiddleware(async (ctx) => {
244
+ const provider = unavailableProviderFor(ctx.path ?? "", ctx.body, deps);
245
+ if (!provider) return;
246
+ // Recorded before the throw, so the trail holds the attempt whether or not anything logs the
247
+ // refusal. `emitProviderUnavailable` swallows its own failure by contract.
248
+ await emitProviderUnavailable(deps.emit, { provider, headers: ctx.headers });
249
+ throw providerUnavailable(provider);
250
+ }),
251
+ // Emit audit events for every completed auth request: sign-in (+device) from the new session,
252
+ // plus the send/sign-out/token/OAuth events by path. Endpoint-scoped, so a rotation never emits.
253
+ after: createAuthMiddleware(async (ctx) => {
254
+ const newSession = ctx.context.newSession;
255
+ await emitAfterRequest(deps.emit, {
256
+ path: ctx.path ?? "",
257
+ headers: ctx.headers,
258
+ newSession: newSession
259
+ ? {
260
+ userId: newSession.user.id,
261
+ sessionId: newSession.session.id,
262
+ deviceId: (newSession.session as { deviceId?: string | null }).deviceId ?? null,
263
+ }
264
+ : null,
265
+ currentUserId: ctx.context.session?.user?.id ?? null,
266
+ });
267
+ }),
268
+ },
269
+ rateLimit: {
270
+ // Memory limiting is per-isolate (useless on Workers); back it with the durable D1 table.
271
+ enabled: true,
272
+ storage: "database",
273
+ modelName: "pithyAuthRateLimit",
274
+ },
275
+ user: {
276
+ modelName: "pithyAuthUsers",
277
+ // One declaration, shared with the schema baseline in `../migrations/pluginTables.ts`. See
278
+ // `../data/kitFields.ts` for why a column missing here is invisible to Better Auth, and why the
279
+ // two used to be written out twice.
280
+ additionalFields: KIT_USER_FIELDS,
281
+ },
282
+ session: {
283
+ modelName: "pithyAuthSessions",
284
+ expiresIn: deps.sessionExpiresIn,
285
+ updateAge: deps.sessionUpdateAge,
286
+ // Server-set session fields clients never supply: the bound device, and the refresh-token family
287
+ // (carried across rotations via createSession override, like deviceId — see `token/rotation.ts`).
288
+ additionalFields: KIT_SESSION_FIELDS,
289
+ },
290
+ account: {
291
+ modelName: "pithyAuthAccounts",
292
+ accountLinking: {
293
+ enabled: true,
294
+ // Link a social sign-in to an existing magic-link user when the verified emails match.
295
+ trustedProviders: ["google", "apple"],
296
+ },
297
+ },
298
+ verification: { modelName: "pithyAuthVerifications" },
299
+ ...((): { socialProviders?: Record<string, unknown> } => {
300
+ const providers = socialProviders(deps);
301
+ return providers ? { socialProviders: providers } : {};
302
+ })(),
303
+ // The kit's four first, the adopter's after. Order is the contract: Better Auth merges plugin
304
+ // endpoints by id and a later registration wins, so composing the adopter's list first would let
305
+ // it quietly redefine the sign-in this product promises. `assertAdditivePlugins` has already
306
+ // refused a list that names one of the four; this order is what makes that refusal the only way in.
307
+ plugins: [...kitPlugins(deps), ...deps.plugins],
308
+ });
309
+ }