@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.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/docs/apple-signin.md +139 -0
- package/docs/facebook-oauth.md +92 -0
- package/docs/github-oauth.md +99 -0
- package/docs/google-oauth.md +118 -0
- package/package.json +58 -0
- package/pithy.manifest.json +108 -0
- package/src/admin/users.ts +357 -0
- package/src/audit/actions.ts +71 -0
- package/src/audit/emit.ts +223 -0
- package/src/capability.ts +300 -0
- package/src/client/api.ts +501 -0
- package/src/client/projection.ts +55 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/data/betterAuth.ts +210 -0
- package/src/data/device.ts +57 -0
- package/src/data/kitFields.ts +69 -0
- package/src/data/rotatedToken.ts +40 -0
- package/src/data/tables.ts +38 -0
- package/src/device/registry.ts +139 -0
- package/src/email/send.ts +67 -0
- package/src/http/adminRoutes.ts +368 -0
- package/src/http/baseUrl.ts +109 -0
- package/src/http/csrf.ts +98 -0
- package/src/http/devLoginRoute.ts +159 -0
- package/src/http/errors.ts +70 -0
- package/src/http/guards.ts +158 -0
- package/src/http/middleware.ts +67 -0
- package/src/http/rateLimit.ts +36 -0
- package/src/http/resolve.ts +152 -0
- package/src/http/responses.ts +199 -0
- package/src/http/routes.ts +325 -0
- package/src/http/schemas.ts +118 -0
- package/src/http/views.ts +93 -0
- package/src/i18n/errorCopy.es.ts +35 -0
- package/src/i18n/errorCopy.ts +99 -0
- package/src/index.ts +24 -0
- package/src/instance/auth.ts +309 -0
- package/src/instance/plugins.ts +172 -0
- package/src/instance/providers.ts +185 -0
- package/src/instance/secrets.ts +197 -0
- package/src/migrations/0001_init.ts +229 -0
- package/src/migrations/pluginTables.ts +334 -0
- package/src/seeds/devSession.ts +286 -0
- package/src/seeds/example.ts +48 -0
- package/src/test-utils/liveApp.ts +338 -0
- package/src/token/rotation.ts +104 -0
- 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
|
+
}
|