@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,229 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Create the auth tables in the app database, all prefixed `pithy_auth_` so they never clash with an
9
+ * adopter's own tables. Six are Better-Auth-managed (`users`, `sessions`, `accounts`,
10
+ * `verifications`, `jwks`, `rate_limit`); `devices` (the device registry) and `rotated_tokens` (the
11
+ * refresh-token reuse-detection ledger) are Pithy's own.
12
+ *
13
+ * Identifiers are declared in **camelCase**: the runner installs `CamelCasePlugin`, which snake-cases
14
+ * every identifier in the emitted DDL (CLAUDE.md §Data layer) — and the same plugin lets Better
15
+ * Auth's camelCase queries land on these snake_case columns. Better-Auth `date` columns are **text**
16
+ * (it stores ISO-8601 strings on SQLite; ISO sorts chronologically); Pithy's own device timestamps
17
+ * are **integer** ms-epoch. Foreign keys are omitted to match the repo convention (D1 does not enforce
18
+ * them without a per-connection PRAGMA); linkage is by indexed id columns.
19
+ *
20
+ * `down` is the tested inverse — indexes then tables, in reverse creation order (D1 has no
21
+ * transactional DDL, so order matters).
22
+ *
23
+ * **Amended in place on 2026-08-23** for `locale` on `pithy_auth_users` (pithy-sh/pithy#441), and the
24
+ * condition was checked rather than assumed, because CONTRIBUTING.md asks a later reader to check it:
25
+ *
26
+ * - `@pithy-sh/auth` is at `0.0.0` and `https://registry.npmjs.org/@pithy-sh/auth` is a 404. Nothing
27
+ * has been released, so no `0300_auth_0001_init` has run anywhere a chain would be replayed against.
28
+ * - `packages/cli/src/migrations/oneMigration.test.ts` gates that same condition repo-wide and is green,
29
+ * which is what keeps this an amendment rather than a `0002`.
30
+ *
31
+ * **The moment either stops being true this file is history, and the chain is append-only.** A version
32
+ * cut and the next column is a `0002`. Nothing about a tidy `0001` tells a reader which side of that
33
+ * line they are on — re-run the two checks, do not infer them.
34
+ */
35
+ export const auth_0001_init: Migration = {
36
+ up: async (db: Kysely<unknown>): Promise<void> => {
37
+ await db.schema
38
+ .createTable("pithyAuthUsers")
39
+ .addColumn("id", "text", (c) => c.primaryKey())
40
+ .addColumn("name", "text", (c) => c.notNull())
41
+ .addColumn("email", "text", (c) => c.notNull().unique())
42
+ .addColumn("emailVerified", "integer", (c) => c.notNull().defaultTo(0))
43
+ .addColumn("image", "text")
44
+ // The reader's chosen language, as a BCP-47 tag, or null for "has not chosen" — which is not the
45
+ // same as the default locale, and the distinction is the whole point of the column. A null means
46
+ // negotiate from `Accept-Language` on every request, so a reader who has never picked follows the
47
+ // device they are holding; a stored tag means they picked, and it outranks the header everywhere.
48
+ //
49
+ // **Nullable, and it could not be otherwise.** Better Auth owns the inserts into this table and
50
+ // supplies no value for a user who never chose one, so the column has to accept its absence. It is
51
+ // written through `user.additionalFields` in `instance/auth.ts` — without that declaration the
52
+ // adapter would never read or write it and this column would be null forever.
53
+ .addColumn("locale", "text")
54
+ .addColumn("createdAt", "text", (c) => c.notNull())
55
+ .addColumn("updatedAt", "text", (c) => c.notNull())
56
+ .execute();
57
+
58
+ await db.schema
59
+ .createTable("pithyAuthSessions")
60
+ .addColumn("id", "text", (c) => c.primaryKey())
61
+ .addColumn("expiresAt", "text", (c) => c.notNull())
62
+ .addColumn("token", "text", (c) => c.notNull().unique())
63
+ .addColumn("createdAt", "text", (c) => c.notNull())
64
+ .addColumn("updatedAt", "text", (c) => c.notNull())
65
+ .addColumn("ipAddress", "text")
66
+ .addColumn("userAgent", "text")
67
+ .addColumn("userId", "text", (c) => c.notNull())
68
+ .addColumn("deviceId", "text")
69
+ // The refresh-token family this session belongs to, carried across rotations so a replayed
70
+ // refresh token can revoke the whole chain. Server-set; null until the session is first rotated.
71
+ .addColumn("familyId", "text")
72
+ .execute();
73
+
74
+ await db.schema
75
+ .createTable("pithyAuthAccounts")
76
+ .addColumn("id", "text", (c) => c.primaryKey())
77
+ // `issuer` is 1.7's account identity, and it is `required: true` in Better Auth's own model
78
+ // (#451). An account is the pair `(issuer, accountId)` now rather than `(providerId, accountId)`,
79
+ // because a provider id is ours to name and an issuer is the identity provider's own — two
80
+ // providers configured against one directory could otherwise mint the same `accountId` and land
81
+ // on different rows. `local:credential` for a password, the discovery URL for an OIDC provider,
82
+ // `local:oauth:<id>` for an OAuth provider that publishes none.
83
+ .addColumn("issuer", "text", (c) => c.notNull())
84
+ .addColumn("accountId", "text", (c) => c.notNull())
85
+ .addColumn("providerId", "text", (c) => c.notNull())
86
+ .addColumn("userId", "text", (c) => c.notNull())
87
+ .addColumn("accessToken", "text")
88
+ .addColumn("refreshToken", "text")
89
+ .addColumn("idToken", "text")
90
+ .addColumn("accessTokenExpiresAt", "text")
91
+ .addColumn("refreshTokenExpiresAt", "text")
92
+ .addColumn("scope", "text")
93
+ .addColumn("password", "text")
94
+ .addColumn("createdAt", "text", (c) => c.notNull())
95
+ .addColumn("updatedAt", "text", (c) => c.notNull())
96
+ .execute();
97
+
98
+ await db.schema
99
+ .createTable("pithyAuthVerifications")
100
+ .addColumn("id", "text", (c) => c.primaryKey())
101
+ .addColumn("identifier", "text", (c) => c.notNull())
102
+ .addColumn("value", "text", (c) => c.notNull())
103
+ .addColumn("expiresAt", "text", (c) => c.notNull())
104
+ .addColumn("createdAt", "text", (c) => c.notNull())
105
+ .addColumn("updatedAt", "text", (c) => c.notNull())
106
+ .execute();
107
+
108
+ await db.schema
109
+ .createTable("pithyAuthJwks")
110
+ .addColumn("id", "text", (c) => c.primaryKey())
111
+ .addColumn("publicKey", "text", (c) => c.notNull())
112
+ .addColumn("privateKey", "text", (c) => c.notNull())
113
+ .addColumn("createdAt", "text", (c) => c.notNull())
114
+ .addColumn("expiresAt", "text")
115
+ // `alg` and `crv` arrived with better-auth 1.7's jwt plugin (#451). Both are `required: false`
116
+ // in its own model, so both are nullable here — and a key written by 1.6 has neither, which is
117
+ // the state the plugin already reads through its `?? "EdDSA"` fallback.
118
+ .addColumn("alg", "text")
119
+ .addColumn("crv", "text")
120
+ .execute();
121
+
122
+ await db.schema
123
+ .createTable("pithyAuthRateLimit")
124
+ .addColumn("id", "text", (c) => c.primaryKey())
125
+ .addColumn("key", "text", (c) => c.notNull().unique())
126
+ .addColumn("count", "integer", (c) => c.notNull())
127
+ .addColumn("lastRequest", "integer", (c) => c.notNull())
128
+ .execute();
129
+
130
+ // Composite PK `(userId, id)`: `id` is a client-generated device id, unique *per user*. This
131
+ // isolates devices across users — a client passing another user's device id can never touch their
132
+ // row — and gives the userId-prefix index for free (so no separate index needed).
133
+ await db.schema
134
+ .createTable("pithyAuthDevices")
135
+ .addColumn("id", "text", (c) => c.notNull())
136
+ .addColumn("userId", "text", (c) => c.notNull())
137
+ .addColumn("platform", "text", (c) => c.notNull())
138
+ .addColumn("name", "text")
139
+ .addColumn("model", "text")
140
+ .addColumn("osVersion", "text")
141
+ .addColumn("appVersion", "text")
142
+ .addColumn("pushToken", "text")
143
+ .addColumn("lastIp", "text")
144
+ .addColumn("lastSeenAt", "integer", (c) => c.notNull())
145
+ .addColumn("createdAt", "integer", (c) => c.notNull())
146
+ .addPrimaryKeyConstraint("pithyAuthDevicesPk", ["userId", "id"])
147
+ .execute();
148
+
149
+ // The reuse-detection ledger: one row per refresh token consumed by a rotation. Presenting a
150
+ // recorded token again (after it no longer resolves to a live session) is a replayed refresh
151
+ // credential; its `familyId` drives family revocation (RFC 6819 §5.2.2.3). Ms-epoch `rotatedAt`.
152
+ await db.schema
153
+ .createTable("pithyAuthRotatedTokens")
154
+ .addColumn("token", "text", (c) => c.primaryKey())
155
+ .addColumn("familyId", "text", (c) => c.notNull())
156
+ .addColumn("userId", "text", (c) => c.notNull())
157
+ .addColumn("rotatedAt", "integer", (c) => c.notNull())
158
+ .execute();
159
+
160
+ await db.schema.createIndex("pithyAuthSessionsUserIdIdx").on("pithyAuthSessions").column("userId").execute();
161
+ await db.schema.createIndex("pithyAuthSessionsDeviceIdIdx").on("pithyAuthSessions").column("deviceId").execute();
162
+ await db.schema.createIndex("pithyAuthAccountsUserIdIdx").on("pithyAuthAccounts").column("userId").execute();
163
+ // The identity 1.7 looks an account up by, and unique because the pair *is* the identity: two rows
164
+ // sharing it are two records of one account at one provider, which is the account-confusion state
165
+ // Better Auth's own 1.7 upgrade guide has adopters audit for before adding this (#451). Declared
166
+ // here rather than backfilled, because nothing is published and no row predates it.
167
+ await db.schema
168
+ .createIndex("pithyAuthAccountsIssuerAccountIdIdx")
169
+ .on("pithyAuthAccounts")
170
+ .columns(["issuer", "accountId"])
171
+ .unique()
172
+ .execute();
173
+ await db.schema
174
+ .createIndex("pithyAuthVerificationsIdentifierIdx")
175
+ .on("pithyAuthVerifications")
176
+ .column("identifier")
177
+ .execute();
178
+ await db.schema
179
+ .createIndex("pithyAuthRotatedTokensFamilyIdIdx")
180
+ .on("pithyAuthRotatedTokens")
181
+ .column("familyId")
182
+ .execute();
183
+ // Supports the retention prune (`delete where rotated_at < cutoff`) as an index range delete.
184
+ await db.schema
185
+ .createIndex("pithyAuthRotatedTokensRotatedAtIdx")
186
+ .on("pithyAuthRotatedTokens")
187
+ .column("rotatedAt")
188
+ .execute();
189
+
190
+ // The two keyset cursors the control-plane admin listings page on. Composite and in sort order, so
191
+ // each page is an index range scan rather than a scan of every user or device the project has.
192
+ // The trailing id is the tiebreak: without it two rows sharing a timestamp straddle a page boundary
193
+ // and one of them is skipped or returned twice.
194
+ await db.schema
195
+ .createIndex("pithyAuthUsersCreatedAtIdx")
196
+ .on("pithyAuthUsers")
197
+ .columns(["createdAt", "id"])
198
+ .execute();
199
+ await db.schema
200
+ .createIndex("pithyAuthDevicesLastSeenAtIdx")
201
+ .on("pithyAuthDevices")
202
+ .columns(["lastSeenAt", "userId", "id"])
203
+ .execute();
204
+ },
205
+
206
+ down: async (db: Kysely<unknown>): Promise<void> => {
207
+ await db.schema.dropIndex("pithyAuthDevicesLastSeenAtIdx").execute();
208
+ await db.schema.dropIndex("pithyAuthUsersCreatedAtIdx").execute();
209
+ await db.schema.dropIndex("pithyAuthRotatedTokensRotatedAtIdx").execute();
210
+ await db.schema.dropIndex("pithyAuthRotatedTokensFamilyIdIdx").execute();
211
+ await db.schema.dropIndex("pithyAuthVerificationsIdentifierIdx").execute();
212
+ await db.schema.dropIndex("pithyAuthAccountsIssuerAccountIdIdx").execute();
213
+ await db.schema.dropIndex("pithyAuthAccountsUserIdIdx").execute();
214
+ await db.schema.dropIndex("pithyAuthSessionsDeviceIdIdx").execute();
215
+ await db.schema.dropIndex("pithyAuthSessionsUserIdIdx").execute();
216
+
217
+ await db.schema.dropTable("pithyAuthRotatedTokens").execute();
218
+ await db.schema.dropTable("pithyAuthDevices").execute();
219
+ await db.schema.dropTable("pithyAuthRateLimit").execute();
220
+ await db.schema.dropTable("pithyAuthJwks").execute();
221
+ await db.schema.dropTable("pithyAuthVerifications").execute();
222
+ await db.schema.dropTable("pithyAuthAccounts").execute();
223
+ await db.schema.dropTable("pithyAuthSessions").execute();
224
+ await db.schema.dropTable("pithyAuthUsers").execute();
225
+ },
226
+ };
227
+
228
+ /** The migration order for the auth namespace within the app database (library-before-app; auth is a wedge cap). */
229
+ export const AUTH_MIGRATION_ORDER = 300;
@@ -0,0 +1,334 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { BetterAuthOptions, BetterAuthPlugin } from "better-auth";
6
+ import { getSchema } from "better-auth/db";
7
+ import type { Kysely } from "kysely";
8
+ import type { Migration } from "kysely/migration";
9
+ import { KIT_SESSION_FIELDS, KIT_USER_FIELDS } from "../data/kitFields";
10
+ import { authTables } from "../data/tables";
11
+ import { kitPlugins } from "../instance/plugins";
12
+
13
+ /**
14
+ * Deriving an adopter's Better Auth plugin tables, and creating them through `pithy migrate`.
15
+ *
16
+ * The kit's migration model is per-capability sets composed by `pithy migrate`, and until now there was
17
+ * no path for tables an **adopter** introduced through a capability's plugin. This is that path, and it
18
+ * works because the plugin list is in `pithy.config.ts` — the same file the CLI already imports to
19
+ * collect capabilities. The auth capability reads its own config, asks Better Auth what schema those
20
+ * plugins imply, and contributes one ordinary Kysely migration per plugin. Nothing new was added to the
21
+ * migration model; the capability simply declares more of it.
22
+ *
23
+ * **The diff is against a baseline, not against nothing.** `getSchema` answers with the *whole* schema
24
+ * for a set of options, so the same call is made twice — once with only the kit's own plugins, once
25
+ * with the kit's own plus one adopter plugin — and the difference is what that plugin brought. That is
26
+ * what keeps the derived migration from re-creating `pithy_auth_users` on every project.
27
+ *
28
+ * **A plugin brings two kinds of change and both matter.** `organization` adds three tables *and* an
29
+ * `activeOrganizationId` column to the session table the kit already owns; a create-table-only reading
30
+ * ships a schema where `setActive` fails on the first call. Both halves are derived here.
31
+ */
32
+
33
+ /**
34
+ * The Better Auth options that shape the schema, and only those. Model names, the session's extra
35
+ * fields, and the rate-limit table are all read by `getSchema`, so the baseline it computes has to
36
+ * carry the same ones `makeAuth` passes or the diff would report the kit's own tables as new.
37
+ *
38
+ * Kept beside `makeAuth` rather than inside it because the migration is built at `auth(config)` time,
39
+ * where the request-scoped deps (database, secret, base URL) do not exist yet — and none of them
40
+ * changes a column.
41
+ */
42
+ export function authSchemaOptions(plugins: readonly BetterAuthPlugin[]): BetterAuthOptions {
43
+ return {
44
+ // The kit's own extra columns, from the one module that declares them. The baseline is what each
45
+ // plugin's schema is *subtracted from*, so a kit column missing here is reported as something the
46
+ // plugin brought — see `../data/kitFields.ts` for what that costs on a database with no
47
+ // transactional DDL.
48
+ user: { modelName: "pithyAuthUsers", additionalFields: KIT_USER_FIELDS },
49
+ session: { modelName: "pithyAuthSessions", additionalFields: KIT_SESSION_FIELDS },
50
+ account: { modelName: "pithyAuthAccounts" },
51
+ verification: { modelName: "pithyAuthVerifications" },
52
+ rateLimit: { enabled: true, storage: "database", modelName: "pithyAuthRateLimit" },
53
+ plugins: [
54
+ // The kit's own four, from the one definition the live instance also composes. The callbacks are
55
+ // never invoked here — a schema is a shape, and `getSchema` reads `plugin.schema`, not behavior.
56
+ ...kitPlugins({
57
+ verificationExpiresIn: 300,
58
+ otpLength: 6,
59
+ disableSignUp: false,
60
+ sendEmail: async () => undefined,
61
+ }),
62
+ ...plugins,
63
+ ],
64
+ };
65
+ }
66
+
67
+ /** SQLite storage for one Better Auth field type — the same two the kit's own tables use. */
68
+ export type PluginColumnType = "text" | "integer";
69
+
70
+ /** One column of a table a plugin introduced. */
71
+ export interface PluginTableColumn {
72
+ /** The camelCase column name; `CamelCasePlugin` snake-cases it in the emitted DDL. */
73
+ name: string;
74
+ /** Its SQLite storage. */
75
+ type: PluginColumnType;
76
+ /** Whether the column is `NOT NULL`. */
77
+ notNull: boolean;
78
+ /** Whether this is the table's `id` primary key. */
79
+ primaryKey: boolean;
80
+ /** Whether the column carries a `UNIQUE` constraint. */
81
+ unique: boolean;
82
+ /** Whether the plugin asked for an index on it. */
83
+ index: boolean;
84
+ }
85
+
86
+ /** A whole table a plugin introduced. */
87
+ export interface PluginTable {
88
+ /** The camelCase model name; `CamelCasePlugin` snake-cases it. */
89
+ name: string;
90
+ /** Its columns, `id` first. */
91
+ columns: PluginTableColumn[];
92
+ }
93
+
94
+ /** A column a plugin added to a table that already existed — the kit's own, or an earlier plugin's. */
95
+ export interface PluginColumn {
96
+ /** The table the column is added to. */
97
+ table: string;
98
+ /** The camelCase column name. */
99
+ name: string;
100
+ /** Its SQLite storage. */
101
+ type: PluginColumnType;
102
+ /** Always `false` — see {@link pluginSchemaDelta}. */
103
+ notNull: boolean;
104
+ /** Whether the plugin asked for an index on it. */
105
+ index: boolean;
106
+ }
107
+
108
+ /** Everything one plugin adds to the schema: whole tables, and columns on tables that already exist. */
109
+ export interface PluginSchemaDelta {
110
+ tables: PluginTable[];
111
+ columns: PluginColumn[];
112
+ }
113
+
114
+ /** The field-attribute subset `getSchema` reports that a column plan is built from. */
115
+ interface SchemaField {
116
+ type: unknown;
117
+ required?: boolean;
118
+ unique?: boolean;
119
+ index?: boolean;
120
+ }
121
+
122
+ /**
123
+ * Map a Better Auth field type to SQLite storage.
124
+ *
125
+ * `date` is **text**, not SQLite's `date` affinity: Better Auth stores ISO-8601 strings on SQLite, ISO
126
+ * sorts chronologically, and `0001_init` already declares every Better-Auth date column that way. A
127
+ * list type (`string[]`, `number[]`) and a literal-union type are both JSON/text on SQLite, which is
128
+ * what Better Auth's own generator does. Anything else is a `boolean`/`number`/`string` — the two
129
+ * numeric-ish ones are `integer`, everything else `text`.
130
+ */
131
+ function columnType(type: unknown): PluginColumnType {
132
+ return type === "boolean" || type === "number" ? "integer" : "text";
133
+ }
134
+
135
+ /** The kit's own tables — the six Better Auth manages plus `devices` and `rotated_tokens`. */
136
+ const KIT_TABLE_NAMES: readonly string[] = Object.keys(authTables);
137
+
138
+ /**
139
+ * What one plugin adds to the auth schema, as a plan a migration can be built from.
140
+ *
141
+ * **Every added column is nullable, deliberately.** SQLite refuses `ALTER TABLE … ADD COLUMN … NOT NULL`
142
+ * without a constant default, and the table it is being added to is a live one that already has rows —
143
+ * so a plugin's `required` on a *new* column of an *existing* table cannot be honored by any migration,
144
+ * whatever it claims. Better Auth writes the value on every insert it makes, so the constraint is
145
+ * enforced where the plugin enforces it; the column simply does not also declare it. A column of a table
146
+ * the plugin creates itself is `NOT NULL` normally — there are no rows to contradict it.
147
+ */
148
+ export function pluginSchemaDelta(plugin: BetterAuthPlugin): PluginSchemaDelta {
149
+ const baseline = getSchema(authSchemaOptions([]));
150
+ const extended = getSchema(authSchemaOptions([plugin]));
151
+
152
+ const tables: PluginTable[] = [];
153
+ const columns: PluginColumn[] = [];
154
+
155
+ for (const [model, spec] of Object.entries(extended)) {
156
+ const before = baseline[model];
157
+ const fields = Object.entries(spec.fields as Record<string, SchemaField>);
158
+
159
+ if (!before) {
160
+ tables.push({
161
+ name: model,
162
+ columns: [
163
+ { name: "id", type: "text", notNull: true, primaryKey: true, unique: false, index: false },
164
+ ...fields.map(([name, field]) => ({
165
+ name,
166
+ type: columnType(field.type),
167
+ notNull: field.required !== false,
168
+ primaryKey: false,
169
+ unique: field.unique === true,
170
+ index: field.index === true,
171
+ })),
172
+ ],
173
+ });
174
+ continue;
175
+ }
176
+
177
+ for (const [name, field] of fields) {
178
+ if (before.fields[name]) continue;
179
+ columns.push({
180
+ table: model,
181
+ name,
182
+ type: columnType(field.type),
183
+ notNull: false,
184
+ index: field.index === true || field.unique === true,
185
+ });
186
+ }
187
+ }
188
+
189
+ return { tables, columns };
190
+ }
191
+
192
+ /** The index name for a column, in the camelCase the `0001_init` indexes are declared in. */
193
+ function indexName(table: string, column: string): string {
194
+ return `${table}${column.charAt(0).toUpperCase()}${column.slice(1)}Idx`;
195
+ }
196
+
197
+ /**
198
+ * Build the Kysely migration for one plugin's delta. `down` is the exact inverse, and it runs the
199
+ * indexes off first: SQLite refuses to drop an indexed column.
200
+ */
201
+ export function pluginMigration(delta: PluginSchemaDelta): Migration {
202
+ return {
203
+ up: async (db: Kysely<unknown>): Promise<void> => {
204
+ for (const table of delta.tables) {
205
+ let builder = db.schema.createTable(table.name);
206
+ for (const column of table.columns) {
207
+ builder = builder.addColumn(column.name, column.type, (c) => {
208
+ let built = column.primaryKey ? c.primaryKey() : c;
209
+ if (column.notNull && !column.primaryKey) built = built.notNull();
210
+ if (column.unique) built = built.unique();
211
+ return built;
212
+ });
213
+ }
214
+ await builder.execute();
215
+ }
216
+ for (const column of delta.columns) {
217
+ await db.schema
218
+ .alterTable(column.table)
219
+ .addColumn(column.name, column.type, (c) => c)
220
+ .execute();
221
+ }
222
+ // Indexes last, in one pass over both halves — an index is only ever an addition, so nothing
223
+ // reads it before the DDL that created its column has run.
224
+ for (const table of delta.tables) {
225
+ for (const column of table.columns) {
226
+ if (!column.index) continue;
227
+ await db.schema.createIndex(indexName(table.name, column.name)).on(table.name).column(column.name).execute();
228
+ }
229
+ }
230
+ for (const column of delta.columns) {
231
+ if (!column.index) continue;
232
+ await db.schema
233
+ .createIndex(indexName(column.table, column.name))
234
+ .on(column.table)
235
+ .column(column.name)
236
+ .execute();
237
+ }
238
+ },
239
+
240
+ down: async (db: Kysely<unknown>): Promise<void> => {
241
+ for (const column of delta.columns) {
242
+ if (column.index) await db.schema.dropIndex(indexName(column.table, column.name)).execute();
243
+ }
244
+ for (const column of delta.columns) {
245
+ await db.schema.alterTable(column.table).dropColumn(column.name).execute();
246
+ }
247
+ // The tables go last and in reverse, so a plugin that created several drops them newest first.
248
+ for (const table of [...delta.tables].reverse()) {
249
+ await db.schema.dropTable(table.name).execute();
250
+ }
251
+ },
252
+ };
253
+ }
254
+
255
+ /**
256
+ * The migration key for a plugin, e.g. `two-factor` → `0002_plugin_two_factor`.
257
+ *
258
+ * `0002` for every plugin, and that is not laziness. The four-digit lead is what
259
+ * `createMigrationRegistry` demands of a local key; within the auth namespace these all sort after
260
+ * `0001_init` and among themselves by plugin id, and **the order between two plugins is arbitrary by
261
+ * construction** — no plugin's tables reference another's, since each is derived against the same
262
+ * baseline. Numbering them `0002`, `0003`, … in config order would make the key of an already-applied
263
+ * migration change the moment a plugin was inserted before it, which is the one thing a ledger cannot
264
+ * survive.
265
+ */
266
+ export function pluginMigrationKey(id: string): string {
267
+ return `0002_plugin_${id
268
+ .toLowerCase()
269
+ .replace(/[^a-z0-9]+/g, "_")
270
+ .replace(/^_+|_+$/g, "")}`;
271
+ }
272
+
273
+ /** What an adopter's plugin list contributes to the auth capability, derived once. */
274
+ export interface AuthPluginPlan {
275
+ /** One migration per plugin that declares a schema, keyed by {@link pluginMigrationKey}. */
276
+ migrations: Record<string, Migration>;
277
+ /** One entry per plugin, whatever it declared — what `pithy doctor` reports. */
278
+ extensions: { id: string; tables: string[] }[];
279
+ }
280
+
281
+ /**
282
+ * Derive everything the auth capability needs from an adopter's plugin list, in one pass.
283
+ *
284
+ * Migrations: one per plugin that has a schema, keyed by {@link pluginMigrationKey}. A plugin that adds
285
+ * no tables and no columns gets no migration and no ledger row — a plugin that is only endpoints has
286
+ * nothing to create. Extensions: one per plugin regardless, because a plugin with no tables still adds
287
+ * routes and still must not be invisible.
288
+ *
289
+ * Two plugins that claim the same table, or a plugin that claims one of the kit's own, are refused
290
+ * here rather than at `pithy migrate`: the second `createTable` would fail mid-run against a database
291
+ * that has no transactional DDL, leaving the first plugin's tables half-created. Naming both plugins
292
+ * and the table is the whole remedy — one of them takes a `schema: { … modelName }` override.
293
+ */
294
+ export function authPluginPlan(plugins: readonly BetterAuthPlugin[]): AuthPluginPlan {
295
+ const migrations: Record<string, Migration> = {};
296
+ const extensions: { id: string; tables: string[] }[] = [];
297
+ const claimed = new Map<string, string>();
298
+
299
+ for (const plugin of plugins) {
300
+ const delta = pluginSchemaDelta(plugin);
301
+ extensions.push({ id: plugin.id, tables: delta.tables.map((table) => table.name) });
302
+ for (const table of delta.tables) {
303
+ if (KIT_TABLE_NAMES.includes(table.name)) {
304
+ throw new ValidationError({
305
+ message: `The Better Auth "${plugin.id}" plugin declares a table the auth capability already owns: ${table.name}.`,
306
+ action: `Give it another name through the plugin's own \`schema: { … modelName }\` option — ${table.name} is created by the auth capability's 0001_init.`,
307
+ detail: `plugin "${plugin.id}" collides with kit table "${table.name}"`,
308
+ });
309
+ }
310
+ const owner = claimed.get(table.name);
311
+ if (owner) {
312
+ throw new ValidationError({
313
+ message: `The Better Auth "${plugin.id}" and "${owner}" plugins both declare a ${table.name} table.`,
314
+ action: `Give one of them another name through its \`schema: { ${table.name}: { modelName: "…" } }\` option.`,
315
+ detail: `plugins "${owner}" and "${plugin.id}" both claim table "${table.name}"`,
316
+ });
317
+ }
318
+ claimed.set(table.name, plugin.id);
319
+ }
320
+
321
+ if (delta.tables.length === 0 && delta.columns.length === 0) continue;
322
+ const key = pluginMigrationKey(plugin.id);
323
+ if (migrations[key]) {
324
+ throw new ValidationError({
325
+ message: `Two Better Auth plugins reduce to the same migration key: ${key}.`,
326
+ action: `Two plugin ids that differ only in punctuation cannot both be composed — rename one, or compose only one of them.`,
327
+ detail: `plugin "${plugin.id}" collides on migration key "${key}"`,
328
+ });
329
+ }
330
+ migrations[key] = pluginMigration(delta);
331
+ }
332
+
333
+ return { migrations, extensions };
334
+ }