@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,210 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { JsonDate, SQLiteBoolean } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { Locale } from "@pithy-sh/core/src/i18n/locale";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The Better-Auth-managed tables, one Zod object per table.
|
|
10
|
+
*
|
|
11
|
+
* Better Auth owns the reads and writes to these via its Kysely adapter (which wraps our shared
|
|
12
|
+
* Kysely + `CamelCasePlugin`, so it issues camelCase identifiers that map to the snake_case
|
|
13
|
+
* `pithy_auth_*` columns our migration creates). These schemas are the table definitions of record:
|
|
14
|
+
* they back the migration, document the model, and decode any direct read Pithy does. Columns are
|
|
15
|
+
* column-exact to Better Auth 1.6.19's generated SQLite schema. Dates are ISO-8601 text
|
|
16
|
+
* (`BetterAuthDate`), booleans are `0|1` (`SQLiteBoolean`) — matching Better Auth's on-disk
|
|
17
|
+
* representation, not Pithy's ms-epoch house default. `z.input` is the SQLite row; `z.output` the
|
|
18
|
+
* app shape.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Date codec for Better-Auth columns. On SQLite/D1 Better Auth's adapter serializes `date` fields with
|
|
23
|
+
* `Date#toISOString()` — ISO-8601 **text**, not the ms-epoch number Pithy's house `SQLiteDate` assumes.
|
|
24
|
+
* That on-disk shape is exactly core's `JsonDate` (Date ↔ ISO-8601 string), so this is an alias, named
|
|
25
|
+
* for its use so the right codec is obvious at each Better-Auth column (`SQLiteDate` would mis-decode).
|
|
26
|
+
*/
|
|
27
|
+
const BetterAuthDate = JsonDate;
|
|
28
|
+
|
|
29
|
+
/** `pithy_auth_users` — one end user. Text/UUID id (anti-enumeration); email is unique. */
|
|
30
|
+
export const User = z
|
|
31
|
+
.object({
|
|
32
|
+
id: z.string().describe("Primary key. A random/UUID text id (not auto-increment) so users cannot be enumerated."),
|
|
33
|
+
name: z.string().describe("The user's display name, supplied by the provider profile or at sign-up."),
|
|
34
|
+
email: z
|
|
35
|
+
.string()
|
|
36
|
+
.describe("The user's email address. Unique across all users; the identity key for passwordless sign-in."),
|
|
37
|
+
emailVerified: SQLiteBoolean.describe(
|
|
38
|
+
"Whether the email has been verified. Stored as `0|1`; magic-link/OTP sign-in sets it true.",
|
|
39
|
+
),
|
|
40
|
+
image: z.string().nullable().describe("URL of the user's avatar from a social profile, or null."),
|
|
41
|
+
locale: Locale.nullable().describe(
|
|
42
|
+
"The reader's chosen language as a BCP-47 tag, or null when they have never chosen. Null is not the default locale: it means negotiate from `Accept-Language`, so an unchosen reader follows their device. A stored tag outranks the header.",
|
|
43
|
+
),
|
|
44
|
+
createdAt: BetterAuthDate.describe(
|
|
45
|
+
"When the user record was created. ISO-8601 text in SQLite; a `Date` in app code.",
|
|
46
|
+
),
|
|
47
|
+
updatedAt: BetterAuthDate.describe(
|
|
48
|
+
"When the user record was last updated. ISO-8601 text in SQLite; a `Date` in app code.",
|
|
49
|
+
),
|
|
50
|
+
})
|
|
51
|
+
.describe("A user in `pithy_auth_users` — the canonical identity every session and account hangs off.");
|
|
52
|
+
export type User = z.output<typeof User>;
|
|
53
|
+
|
|
54
|
+
/** `pithy_auth_sessions` — one active session (the long-lived, bearer/cookie credential). */
|
|
55
|
+
export const Session = z
|
|
56
|
+
.object({
|
|
57
|
+
id: z.string().describe("Primary key. A random/UUID text id."),
|
|
58
|
+
expiresAt: BetterAuthDate.describe(
|
|
59
|
+
"When the session expires. Slides forward on use within `updateAge`. ISO-8601 text in SQLite.",
|
|
60
|
+
),
|
|
61
|
+
token: z
|
|
62
|
+
.string()
|
|
63
|
+
.describe(
|
|
64
|
+
"The opaque session token. Unique; presented as the cookie value or `Authorization: Bearer`. The refresh credential.",
|
|
65
|
+
),
|
|
66
|
+
createdAt: BetterAuthDate.describe("When the session was created. ISO-8601 text in SQLite."),
|
|
67
|
+
updatedAt: BetterAuthDate.describe("When the session was last refreshed. ISO-8601 text in SQLite."),
|
|
68
|
+
ipAddress: z
|
|
69
|
+
.string()
|
|
70
|
+
.nullable()
|
|
71
|
+
.describe(
|
|
72
|
+
"The client IP at sign-in, for the device registry and `new sign-in from…` security signals. Nullable.",
|
|
73
|
+
),
|
|
74
|
+
userAgent: z.string().nullable().describe("The client user-agent at sign-in. Nullable."),
|
|
75
|
+
userId: z.string().describe("The owning user's id. Foreign key to `pithy_auth_users(id)`, ON DELETE CASCADE."),
|
|
76
|
+
deviceId: z
|
|
77
|
+
.string()
|
|
78
|
+
.nullable()
|
|
79
|
+
.describe(
|
|
80
|
+
"The id of the `pithy_auth_devices` row this session is bound to (per-device sessions), or null for a device-less web login.",
|
|
81
|
+
),
|
|
82
|
+
familyId: z
|
|
83
|
+
.string()
|
|
84
|
+
.nullable()
|
|
85
|
+
.describe(
|
|
86
|
+
"The refresh-token family this session belongs to, carried across rotations so a replayed refresh token can revoke the whole chain. Server-set; null for a session created before rotation or one never rotated.",
|
|
87
|
+
),
|
|
88
|
+
})
|
|
89
|
+
.describe("A session in `pithy_auth_sessions` — a signed-in credential, optionally bound to a device.");
|
|
90
|
+
export type Session = z.output<typeof Session>;
|
|
91
|
+
|
|
92
|
+
/** `pithy_auth_accounts` — a credential/identity link for a user (one row per social provider). */
|
|
93
|
+
export const Account = z
|
|
94
|
+
.object({
|
|
95
|
+
id: z.string().describe("Primary key. A random/UUID text id."),
|
|
96
|
+
issuer: z
|
|
97
|
+
.string()
|
|
98
|
+
.describe(
|
|
99
|
+
"Who asserted this identity, as the identity provider names itself: an OIDC issuer URL (`https://accounts.google.com`), `local:oauth:<id>` for an OAuth provider that publishes none, or `local:credential` for a password. With `accountId` it is the account's identity and carries a unique index — a provider slug is a name this project chose, so two providers pointed at one directory could assert the same `accountId` and land on two rows.",
|
|
100
|
+
),
|
|
101
|
+
accountId: z.string().describe("The provider's stable id for this user (e.g. the Google `sub` claim)."),
|
|
102
|
+
providerId: z
|
|
103
|
+
.string()
|
|
104
|
+
.describe("The provider slug, e.g. `google`. Identifies which OAuth/credential provider this row links."),
|
|
105
|
+
userId: z.string().describe("The owning user's id. Foreign key to `pithy_auth_users(id)`, ON DELETE CASCADE."),
|
|
106
|
+
accessToken: z
|
|
107
|
+
.string()
|
|
108
|
+
.nullable()
|
|
109
|
+
.describe("The provider OAuth access token, if granted. Never returned to clients. Nullable."),
|
|
110
|
+
refreshToken: z
|
|
111
|
+
.string()
|
|
112
|
+
.nullable()
|
|
113
|
+
.describe(
|
|
114
|
+
"The provider OAuth refresh token, if granted (`accessType: offline`). Never returned to clients. Nullable.",
|
|
115
|
+
),
|
|
116
|
+
idToken: z
|
|
117
|
+
.string()
|
|
118
|
+
.nullable()
|
|
119
|
+
.describe("The provider OIDC id token from the last sign-in. Never returned to clients. Nullable."),
|
|
120
|
+
accessTokenExpiresAt: BetterAuthDate.nullable().describe(
|
|
121
|
+
"When the provider access token expires. ISO-8601 text in SQLite. Nullable.",
|
|
122
|
+
),
|
|
123
|
+
refreshTokenExpiresAt: BetterAuthDate.nullable().describe(
|
|
124
|
+
"When the provider refresh token expires. ISO-8601 text in SQLite. Nullable.",
|
|
125
|
+
),
|
|
126
|
+
scope: z.string().nullable().describe("The OAuth scopes granted, space-delimited. Nullable."),
|
|
127
|
+
password: z
|
|
128
|
+
.string()
|
|
129
|
+
.nullable()
|
|
130
|
+
.describe("Unused — Pithy is passwordless-only, so this Better-Auth column is always null."),
|
|
131
|
+
createdAt: BetterAuthDate.describe("When the account link was created. ISO-8601 text in SQLite."),
|
|
132
|
+
updatedAt: BetterAuthDate.describe("When the account link was last updated. ISO-8601 text in SQLite."),
|
|
133
|
+
})
|
|
134
|
+
.describe("An account link in `pithy_auth_accounts` — a user's connection to a social/OAuth provider.");
|
|
135
|
+
export type Account = z.output<typeof Account>;
|
|
136
|
+
|
|
137
|
+
/** `pithy_auth_verifications` — short-lived tokens: magic-link, OTP, and OAuth PKCE/state. */
|
|
138
|
+
export const Verification = z
|
|
139
|
+
.object({
|
|
140
|
+
id: z.string().describe("Primary key. A random/UUID text id."),
|
|
141
|
+
identifier: z
|
|
142
|
+
.string()
|
|
143
|
+
.describe(
|
|
144
|
+
"What the value verifies — a namespaced email for OTP/magic-link, or the OAuth `state` for PKCE. Indexed.",
|
|
145
|
+
),
|
|
146
|
+
value: z
|
|
147
|
+
.string()
|
|
148
|
+
.describe("The token, code, or serialized PKCE/state payload. Consumed atomically on first verify (single-use)."),
|
|
149
|
+
expiresAt: BetterAuthDate.describe(
|
|
150
|
+
"When the verification expires (5 min for magic-link/OTP, ~10 min for OAuth state). ISO-8601 text in SQLite.",
|
|
151
|
+
),
|
|
152
|
+
createdAt: BetterAuthDate.describe("When the verification was created. ISO-8601 text in SQLite."),
|
|
153
|
+
updatedAt: BetterAuthDate.describe("When the verification was last updated. ISO-8601 text in SQLite."),
|
|
154
|
+
})
|
|
155
|
+
.describe(
|
|
156
|
+
"A verification record in `pithy_auth_verifications` — the store behind magic-link, OTP, and OAuth PKCE state.",
|
|
157
|
+
);
|
|
158
|
+
export type Verification = z.output<typeof Verification>;
|
|
159
|
+
|
|
160
|
+
/** `pithy_auth_jwks` — the JWKS keypairs that sign and verify short-lived JWT access tokens. */
|
|
161
|
+
export const Jwks = z
|
|
162
|
+
.object({
|
|
163
|
+
id: z.string().describe("Primary key. A random/UUID text id, used as the JWT `kid`."),
|
|
164
|
+
publicKey: z
|
|
165
|
+
.string()
|
|
166
|
+
.describe("The public key (JWK), published at `/jwks` so resource servers verify access tokens locally."),
|
|
167
|
+
privateKey: z
|
|
168
|
+
.string()
|
|
169
|
+
.describe(
|
|
170
|
+
"The private signing key, encrypted at rest with the Better-Auth secret (sourced from `@pithy-sh/secrets`). Never published.",
|
|
171
|
+
),
|
|
172
|
+
createdAt: BetterAuthDate.describe("When the keypair was created. ISO-8601 text in SQLite."),
|
|
173
|
+
expiresAt: BetterAuthDate.nullable().describe(
|
|
174
|
+
"When the keypair is retired. Retired keys stay published through their grace window so in-flight tokens still verify. Nullable.",
|
|
175
|
+
),
|
|
176
|
+
alg: z
|
|
177
|
+
.string()
|
|
178
|
+
.nullable()
|
|
179
|
+
.describe(
|
|
180
|
+
"The signing algorithm this keypair is for (`EdDSA`, `RS256`), published in the key's JWKS entry so a verifier picks the right one without guessing. Nullable because Better Auth declares it optional and a key minted before 1.7 has none — the plugin falls back to `EdDSA`, which is what those keys are.",
|
|
181
|
+
),
|
|
182
|
+
crv: z
|
|
183
|
+
.string()
|
|
184
|
+
.nullable()
|
|
185
|
+
.describe(
|
|
186
|
+
"The elliptic curve for an EC or OKP key (`Ed25519`, `P-256`), and null for RSA, which has no curve. Published beside `alg` for the same reason.",
|
|
187
|
+
),
|
|
188
|
+
})
|
|
189
|
+
.describe(
|
|
190
|
+
"A signing keypair in `pithy_auth_jwks` — rotation keeps prior public keys published so issued tokens keep verifying.",
|
|
191
|
+
);
|
|
192
|
+
export type Jwks = z.output<typeof Jwks>;
|
|
193
|
+
|
|
194
|
+
/** `pithy_auth_rate_limit` — Better Auth's database-backed rate-limit counters (per key, durable across isolates). */
|
|
195
|
+
export const RateLimit = z
|
|
196
|
+
.object({
|
|
197
|
+
id: z.string().describe("Primary key. A random/UUID text id."),
|
|
198
|
+
key: z.string().describe("The rate-limit bucket key (path + client IP). Unique."),
|
|
199
|
+
count: z.number().int().describe("The number of requests counted in the current window."),
|
|
200
|
+
lastRequest: z
|
|
201
|
+
.number()
|
|
202
|
+
.int()
|
|
203
|
+
.describe(
|
|
204
|
+
"The ms-epoch timestamp of the most recent request in the window. A raw number, written by Better Auth's limiter.",
|
|
205
|
+
),
|
|
206
|
+
})
|
|
207
|
+
.describe(
|
|
208
|
+
"A rate-limit counter in `pithy_auth_rate_limit` — durable per-isolate-safe throttling for the auth send/sign-in routes.",
|
|
209
|
+
);
|
|
210
|
+
export type RateLimit = z.output<typeof RateLimit>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `pithy_auth_devices` — the device registry, a Pithy-specific table with no Better-Auth equivalent.
|
|
9
|
+
*
|
|
10
|
+
* One row per user-device, keyed on a client-generated stable id so the same physical device maps to
|
|
11
|
+
* one row across re-logins. Sessions link back via `pithy_auth_sessions.device_id`. This table is
|
|
12
|
+
* written by Pithy code (the `session.create` hook and the device routes) through our shared Kysely,
|
|
13
|
+
* so it keeps Pithy's house ms-epoch `SQLiteDate` — unlike the Better-Auth tables.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The platform a device runs. `web` covers any browser; mobile carries richer metadata. */
|
|
17
|
+
export const DevicePlatform = z
|
|
18
|
+
.enum(["ios", "android", "web"])
|
|
19
|
+
.describe("The device platform: `ios`, `android`, or `web`.");
|
|
20
|
+
export type DevicePlatform = z.output<typeof DevicePlatform>;
|
|
21
|
+
|
|
22
|
+
/** One registered device. */
|
|
23
|
+
export const Device = z
|
|
24
|
+
.object({
|
|
25
|
+
id: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.max(128)
|
|
29
|
+
.describe(
|
|
30
|
+
"Primary key. A client-generated stable id (UUID) so one physical device maps to one row across re-logins. Bounded because it arrives verbatim from the `x-pithy-device-id` header and becomes both a stored key and the tiebreak inside the device listing's keyset cursor — an unbounded id is an unbounded write, and it would also let one device push that cursor past the length the same route accepts on the way back in, making the registry unpageable past that row. Same reasoning as the control-plane `jti` bound.",
|
|
31
|
+
),
|
|
32
|
+
userId: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe("The owning user's id. Foreign key to `pithy_auth_users(id)`, ON DELETE CASCADE. Indexed."),
|
|
35
|
+
platform: DevicePlatform.describe("The device platform, captured from login metadata."),
|
|
36
|
+
name: z
|
|
37
|
+
.string()
|
|
38
|
+
.nullable()
|
|
39
|
+
.describe("A human label for the device (OS device name), shown in device-management UI. Nullable."),
|
|
40
|
+
model: z.string().nullable().describe("The hardware model, for device-management UI and support. Nullable."),
|
|
41
|
+
osVersion: z.string().nullable().describe("The device OS version at last sign-in. Nullable."),
|
|
42
|
+
appVersion: z.string().nullable().describe("The client app version at last sign-in. Nullable."),
|
|
43
|
+
pushToken: z
|
|
44
|
+
.string()
|
|
45
|
+
.nullable()
|
|
46
|
+
.describe("The APNs/FCM push token, stored for the future push capability and updatable. Nullable."),
|
|
47
|
+
lastIp: z
|
|
48
|
+
.string()
|
|
49
|
+
.nullable()
|
|
50
|
+
.describe("The client IP at the most recent sign-in, for `new sign-in from…` security signals. Nullable."),
|
|
51
|
+
lastSeenAt: SQLiteDate.describe(
|
|
52
|
+
"When this device was last seen (most recent sign-in). Ms-epoch in SQLite; a `Date` in app code.",
|
|
53
|
+
),
|
|
54
|
+
createdAt: SQLiteDate.describe("When this device was first registered. Ms-epoch in SQLite; a `Date` in app code."),
|
|
55
|
+
})
|
|
56
|
+
.describe("A registered device in `pithy_auth_devices` — per-device sessions, security signals, and push routing.");
|
|
57
|
+
export type Device = z.output<typeof Device>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Locale } from "@pithy-sh/core/src/i18n/locale";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The columns Pithy adds to Better Auth's own tables, declared **once**.
|
|
8
|
+
*
|
|
9
|
+
* Better Auth owns every read and write to `pithy_auth_users` and `pithy_auth_sessions` through its
|
|
10
|
+
* Kysely adapter, and it writes only the fields its options declare. So a column that exists in the
|
|
11
|
+
* migration and in the `User` Zod object and *not* here is null forever and absent from every user
|
|
12
|
+
* object the instance returns — a migration and a schema field that silently did nothing.
|
|
13
|
+
*
|
|
14
|
+
* ## Why one module rather than two agreeing declarations
|
|
15
|
+
*
|
|
16
|
+
* These have to appear in two places: `makeAuth`'s live options (`../instance/auth.ts`) and the
|
|
17
|
+
* schema baseline `pluginSchemaDelta` subtracts each adopter plugin's schema from
|
|
18
|
+
* (`../migrations/pluginTables.ts`). Both were written out by hand, with a comment in each saying it
|
|
19
|
+
* "must match" the other and a test claiming to hold them together. The test did not: it compared the
|
|
20
|
+
* baseline against the `User` schema and never imported `makeAuth` at all, so reverting the live
|
|
21
|
+
* declaration left the whole suite green while Better Auth silently stopped writing the column (#441).
|
|
22
|
+
*
|
|
23
|
+
* `docs/CONVENTIONS.md` is explicit that removing an invariant beats watching it. There is one object
|
|
24
|
+
* now, imported by both, and the two cannot disagree because there is no second thing to disagree
|
|
25
|
+
* with. That is also why the consequence is worth restating rather than diluting: leave a kit column
|
|
26
|
+
* out of the baseline and `pluginSchemaDelta` reports it as something an adopter's plugin brought, so
|
|
27
|
+
* a plugin that also declares a user `locale` emits `ALTER TABLE … ADD COLUMN locale` against a table
|
|
28
|
+
* that already has one — a duplicate-column failure part-way through a migration D1 cannot roll back,
|
|
29
|
+
* because it has no transactional DDL.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The extra `pithy_auth_users` columns.
|
|
34
|
+
*
|
|
35
|
+
* **`locale` is `input: true`, deliberately, and that is the whole point of the column.** Every other
|
|
36
|
+
* extra field the kit declares is server-set — {@link KIT_SESSION_FIELDS} is `input: false` because a
|
|
37
|
+
* client that could name its own device or token family could name somebody else's. A locale is the
|
|
38
|
+
* opposite: it is the reader's own preference, and the flow it exists for is a signed-in reader
|
|
39
|
+
* switching language and having the choice follow them to their next device. Refusing client input
|
|
40
|
+
* would leave an admin route as the only way to store a preference, which is not a thing a reader has.
|
|
41
|
+
*
|
|
42
|
+
* **What makes that safe is the validator, not the type.** `type: "string"` alone would let a caller
|
|
43
|
+
* write any string into a column every read passes through `User.parse` — so one `updateUser` with a
|
|
44
|
+
* megabyte of junk would poison that row, and the admin listing that reads a page of rows would then
|
|
45
|
+
* throw for every operator rather than only for the author. Better Auth runs `validator.input` on the
|
|
46
|
+
* supplied value and answers 400 on a failure (`parseInputData`), so the same schema that guards the
|
|
47
|
+
* read guards the write, one hop earlier.
|
|
48
|
+
*
|
|
49
|
+
* **And it is `Locale.nullable()`, not `Locale`.** The column is nullable, the `User` field is
|
|
50
|
+
* nullable, `AdminUserView` is nullable, and every one of those says the same thing: null is "this
|
|
51
|
+
* reader has not chosen", which is what makes the server fall back to `Accept-Language`. A validator
|
|
52
|
+
* that accepted a tag but refused `null` would let a reader pick a language and never take it back —
|
|
53
|
+
* `updateUser({ locale: null })` answering 400 for the one state the schema calls ordinary.
|
|
54
|
+
*/
|
|
55
|
+
export const KIT_USER_FIELDS = {
|
|
56
|
+
locale: { type: "string", required: false, input: true, validator: { input: Locale.nullable() } },
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The extra `pithy_auth_sessions` columns — the bound device, and the refresh-token family carried
|
|
61
|
+
* across rotations.
|
|
62
|
+
*
|
|
63
|
+
* `input: false` on both: these are server-set facts about a session, and a client able to name its own
|
|
64
|
+
* device id or token family could name somebody else's.
|
|
65
|
+
*/
|
|
66
|
+
export const KIT_SESSION_FIELDS = {
|
|
67
|
+
deviceId: { type: "string", required: false, input: false },
|
|
68
|
+
familyId: { type: "string", required: false, input: false },
|
|
69
|
+
} as const;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `pithy_auth_rotated_tokens` — the reuse-detection ledger, a Pithy-specific table with no
|
|
9
|
+
* Better-Auth equivalent.
|
|
10
|
+
*
|
|
11
|
+
* One row per refresh token that has been consumed by a rotation. On `/auth/token/rotate` the
|
|
12
|
+
* presented session token is deleted and recorded here; presenting it again (after it no longer
|
|
13
|
+
* resolves to a live session) is a replayed refresh credential — the canonical compromise signal for
|
|
14
|
+
* rotated refresh tokens (OAuth refresh rotation, RFC 6819 §5.2.2.3). The row carries the `familyId`
|
|
15
|
+
* so the whole token family can be revoked on reuse. Written by Pithy code through our shared Kysely,
|
|
16
|
+
* so it keeps Pithy's house ms-epoch `SQLiteDate` — unlike the Better-Auth tables.
|
|
17
|
+
*/
|
|
18
|
+
export const RotatedToken = z
|
|
19
|
+
.object({
|
|
20
|
+
token: z
|
|
21
|
+
.string()
|
|
22
|
+
.describe(
|
|
23
|
+
"Primary key. The consumed session token, recorded when a rotation revoked it. Presenting it again is reuse.",
|
|
24
|
+
),
|
|
25
|
+
familyId: z
|
|
26
|
+
.string()
|
|
27
|
+
.describe(
|
|
28
|
+
"The refresh-token family this token belonged to. The unit revoked when reuse is detected — every session sharing it is signed out. Indexed.",
|
|
29
|
+
),
|
|
30
|
+
userId: z
|
|
31
|
+
.string()
|
|
32
|
+
.describe("The owning user's id, for attributing the reuse audit event to the compromised account."),
|
|
33
|
+
rotatedAt: SQLiteDate.describe(
|
|
34
|
+
"When the token was consumed by a rotation. Ms-epoch in SQLite; a `Date` in app code. Drives retention pruning.",
|
|
35
|
+
),
|
|
36
|
+
})
|
|
37
|
+
.describe(
|
|
38
|
+
"A consumed refresh token in `pithy_auth_rotated_tokens` — the ledger that powers refresh-token reuse detection and family revocation.",
|
|
39
|
+
);
|
|
40
|
+
export type RotatedToken = z.output<typeof RotatedToken>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
|
+
import type { Kysely } from "kysely";
|
|
7
|
+
import { Account, Jwks, RateLimit, Session, User, Verification } from "./betterAuth";
|
|
8
|
+
import { Device } from "./device";
|
|
9
|
+
import { RotatedToken } from "./rotatedToken";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The auth capability's table map: camelCase keys, so `CamelCasePlugin` emits the snake_case
|
|
13
|
+
* `pithy_auth_*` SQL. One source of truth shared by the capability wiring, the Better-Auth instance
|
|
14
|
+
* (whose Kysely adapter wraps this same Kysely), the device registry, and the migration.
|
|
15
|
+
*
|
|
16
|
+
* The first six tables are Better-Auth-managed; `pithyAuthDevices` and `pithyAuthRotatedTokens` are
|
|
17
|
+
* ours. Setting Better Auth's `modelName` to these exact camelCase keys lets the one `CamelCasePlugin`
|
|
18
|
+
* own the camelCase ↔ snake_case boundary uniformly for both Better Auth's queries and our migration DDL.
|
|
19
|
+
*/
|
|
20
|
+
export const authTables = {
|
|
21
|
+
pithyAuthUsers: User,
|
|
22
|
+
pithyAuthSessions: Session,
|
|
23
|
+
pithyAuthAccounts: Account,
|
|
24
|
+
pithyAuthVerifications: Verification,
|
|
25
|
+
pithyAuthJwks: Jwks,
|
|
26
|
+
pithyAuthRateLimit: RateLimit,
|
|
27
|
+
pithyAuthDevices: Device,
|
|
28
|
+
pithyAuthRotatedTokens: RotatedToken,
|
|
29
|
+
};
|
|
30
|
+
export type AuthTables = typeof authTables;
|
|
31
|
+
|
|
32
|
+
/** The typed Kysely view over the auth tables — shared by the Better-Auth adapter and the device registry. */
|
|
33
|
+
export type AuthDatabase = Kysely<DatabaseSchema<AuthTables>>;
|
|
34
|
+
|
|
35
|
+
/** Build the auth Kysely over a D1 binding. Carries `CamelCasePlugin`, so query code stays camelCase. */
|
|
36
|
+
export function authDatabase(d1: D1Database): AuthDatabase {
|
|
37
|
+
return createDatabase(d1, authTables);
|
|
38
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Device, DevicePlatform } from "../data/device";
|
|
5
|
+
import type { AuthDatabase } from "../data/tables";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The device registry: parse optional device metadata off a sign-in request, upsert the
|
|
9
|
+
* `pithy_auth_devices` row, and resolve a device's sessions for revocation. All writes go through our
|
|
10
|
+
* shared Kysely + codecs (ms-epoch dates) — distinct from the Better-Auth-managed tables.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Request headers a client may send at sign-in to register/identify its device. */
|
|
14
|
+
export const DEVICE_HEADERS = {
|
|
15
|
+
id: "x-pithy-device-id",
|
|
16
|
+
platform: "x-pithy-platform",
|
|
17
|
+
name: "x-pithy-device-name",
|
|
18
|
+
model: "x-pithy-device-model",
|
|
19
|
+
osVersion: "x-pithy-os-version",
|
|
20
|
+
appVersion: "x-pithy-app-version",
|
|
21
|
+
pushToken: "x-pithy-push-token",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Device metadata captured at sign-in. `id` is required; everything else is best-effort. `platform` is
|
|
26
|
+
* `null` when the header was absent — distinct from a supplied value — so a sparse re-login never
|
|
27
|
+
* overwrites a known platform with a default.
|
|
28
|
+
*/
|
|
29
|
+
export interface DeviceMeta {
|
|
30
|
+
id: string;
|
|
31
|
+
platform: DevicePlatform | null;
|
|
32
|
+
name: string | null;
|
|
33
|
+
model: string | null;
|
|
34
|
+
osVersion: string | null;
|
|
35
|
+
appVersion: string | null;
|
|
36
|
+
pushToken: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function header(headers: Headers, name: string): string | null {
|
|
40
|
+
const value = headers.get(name)?.trim();
|
|
41
|
+
return value ? value : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse device metadata from request headers. Returns `undefined` when no `x-pithy-device-id` is
|
|
46
|
+
* present — a device-less login (a plain web session). `platform` is `null` when its header is absent
|
|
47
|
+
* or unrecognized; the default `web` is applied only on first registration, never on a sparse update.
|
|
48
|
+
*/
|
|
49
|
+
export function parseDeviceMeta(headers: Headers): DeviceMeta | undefined {
|
|
50
|
+
const id = header(headers, DEVICE_HEADERS.id);
|
|
51
|
+
if (!id) return undefined;
|
|
52
|
+
const platform = DevicePlatform.safeParse(header(headers, DEVICE_HEADERS.platform));
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
platform: platform.success ? platform.data : null,
|
|
56
|
+
name: header(headers, DEVICE_HEADERS.name),
|
|
57
|
+
model: header(headers, DEVICE_HEADERS.model),
|
|
58
|
+
osVersion: header(headers, DEVICE_HEADERS.osVersion),
|
|
59
|
+
appVersion: header(headers, DEVICE_HEADERS.appVersion),
|
|
60
|
+
pushToken: header(headers, DEVICE_HEADERS.pushToken),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Register-or-update a device for a user. Idempotent on the composite `(userId, id)` key: a re-login
|
|
66
|
+
* from the same device refreshes `lastSeenAt`/`lastIp` and any newly-supplied metadata, but never
|
|
67
|
+
* overwrites existing metadata with a sparse login and never resets `createdAt`.
|
|
68
|
+
*/
|
|
69
|
+
export async function registerDevice(
|
|
70
|
+
db: AuthDatabase,
|
|
71
|
+
meta: DeviceMeta,
|
|
72
|
+
context: { userId: string; lastIp: string | null; now: Date },
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const record = Device.encode({
|
|
75
|
+
id: meta.id,
|
|
76
|
+
userId: context.userId,
|
|
77
|
+
// `web` is the default only at first registration; an update preserves the stored platform unless
|
|
78
|
+
// the client supplied a fresh one (handled in `refreshed` below).
|
|
79
|
+
platform: meta.platform ?? "web",
|
|
80
|
+
name: meta.name,
|
|
81
|
+
model: meta.model,
|
|
82
|
+
osVersion: meta.osVersion,
|
|
83
|
+
appVersion: meta.appVersion,
|
|
84
|
+
pushToken: meta.pushToken,
|
|
85
|
+
lastIp: context.lastIp,
|
|
86
|
+
lastSeenAt: context.now,
|
|
87
|
+
createdAt: context.now,
|
|
88
|
+
});
|
|
89
|
+
// Only refresh fields the client actually supplied this time, so a metadata-light re-login keeps
|
|
90
|
+
// earlier richer detail.
|
|
91
|
+
const refreshed: Record<string, unknown> = {
|
|
92
|
+
lastSeenAt: record.lastSeenAt,
|
|
93
|
+
lastIp: record.lastIp,
|
|
94
|
+
};
|
|
95
|
+
if (meta.platform !== null) refreshed.platform = record.platform;
|
|
96
|
+
if (meta.name !== null) refreshed.name = record.name;
|
|
97
|
+
if (meta.model !== null) refreshed.model = record.model;
|
|
98
|
+
if (meta.osVersion !== null) refreshed.osVersion = record.osVersion;
|
|
99
|
+
if (meta.appVersion !== null) refreshed.appVersion = record.appVersion;
|
|
100
|
+
if (meta.pushToken !== null) refreshed.pushToken = record.pushToken;
|
|
101
|
+
|
|
102
|
+
await db
|
|
103
|
+
.insertInto("pithyAuthDevices")
|
|
104
|
+
.values(record)
|
|
105
|
+
.onConflict((oc) => oc.columns(["userId", "id"]).doUpdateSet(refreshed))
|
|
106
|
+
.execute();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** List a user's registered devices, most-recently-seen first. */
|
|
110
|
+
export async function listDevices(db: AuthDatabase, userId: string): Promise<Device[]> {
|
|
111
|
+
const rows = await db
|
|
112
|
+
.selectFrom("pithyAuthDevices")
|
|
113
|
+
.selectAll()
|
|
114
|
+
.where("userId", "=", userId)
|
|
115
|
+
.orderBy("lastSeenAt", "desc")
|
|
116
|
+
.execute();
|
|
117
|
+
return rows.map((row) => Device.parse(row));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Resolve the session tokens bound to one of a user's devices — the set a device-revoke must sign out. */
|
|
121
|
+
export async function deviceSessionTokens(db: AuthDatabase, userId: string, deviceId: string): Promise<string[]> {
|
|
122
|
+
const rows = await db
|
|
123
|
+
.selectFrom("pithyAuthSessions")
|
|
124
|
+
.select("token")
|
|
125
|
+
.where("userId", "=", userId)
|
|
126
|
+
.where("deviceId", "=", deviceId)
|
|
127
|
+
.execute();
|
|
128
|
+
return rows.map((row) => row.token);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Delete a user's device row (after its sessions are revoked). Returns true if a row was removed. */
|
|
132
|
+
export async function deleteDevice(db: AuthDatabase, userId: string, deviceId: string): Promise<boolean> {
|
|
133
|
+
const result = await db
|
|
134
|
+
.deleteFrom("pithyAuthDevices")
|
|
135
|
+
.where("userId", "=", userId)
|
|
136
|
+
.where("id", "=", deviceId)
|
|
137
|
+
.executeTakeFirst();
|
|
138
|
+
return (result.numDeletedRows ?? 0n) > 0n;
|
|
139
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { EmailCapability } from "@pithy-sh/email/src/capability";
|
|
5
|
+
import type { AuthEmailMessage, SendAuthEmail } from "../instance/auth";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The email-delivery seam. Auth never assembles the email infrastructure — it calls the `enqueue` the
|
|
9
|
+
* email capability exposes (already bound to the request env by the caller), passing only the
|
|
10
|
+
* high-level input. The email capability owns the `DB`/`EMAIL_SENDER` bindings, the from-identity, and
|
|
11
|
+
* the theme. Delivery is the email Workflow's job; auth only enqueues.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** An env-bound enqueue — the email capability's `enqueue`, partially applied with the request env. */
|
|
15
|
+
export type EnqueueEmail = (input: Parameters<EmailCapability["enqueue"]>[1]) => Promise<unknown>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The language to write to this person in, resolved per message.
|
|
19
|
+
*
|
|
20
|
+
* A thunk over the address rather than a value, because the answer is a database read and most of
|
|
21
|
+
* what it is asked about is a sign-in that may not have an account yet. See {@link makeSendAuthEmail}.
|
|
22
|
+
*/
|
|
23
|
+
export type ResolveRecipientLocale = (email: string) => Promise<string | null>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the `sendEmail` the auth instance calls from its magic-link / OTP hooks.
|
|
27
|
+
*
|
|
28
|
+
* ## The locale
|
|
29
|
+
*
|
|
30
|
+
* A sign-in email is the first thing a project sends and the one it cannot afford to send in a
|
|
31
|
+
* language the reader does not have, because there is no password to fall back to: somebody who cannot
|
|
32
|
+
* find the button in an unfamiliar alphabet cannot get in at all. So `localeFor` is asked per message
|
|
33
|
+
* and the answer rides onto the job, where the send Workflow reads it back hours later.
|
|
34
|
+
*
|
|
35
|
+
* It resolves in two steps and the order matters. **A stored `pithy_auth_users.locale` wins**, because
|
|
36
|
+
* a person who has chosen a language has said something durable about themselves, and the device they
|
|
37
|
+
* happen to be signing in from tonight has not. Where there is no row — a first-time sign-up, which is
|
|
38
|
+
* exactly when a magic link matters most — the resolver falls back to the language this request
|
|
39
|
+
* negotiated, which is the only thing anyone knows about the reader yet. Null means neither answered,
|
|
40
|
+
* and null renders the kit's English rather than asserting English was chosen.
|
|
41
|
+
*
|
|
42
|
+
* Optional so that a composition without it (and every existing test harness) behaves as it did.
|
|
43
|
+
*/
|
|
44
|
+
export function makeSendAuthEmail(
|
|
45
|
+
enqueue: EnqueueEmail,
|
|
46
|
+
expiresMinutes: number,
|
|
47
|
+
localeFor?: ResolveRecipientLocale,
|
|
48
|
+
): SendAuthEmail {
|
|
49
|
+
return async (message: AuthEmailMessage): Promise<void> => {
|
|
50
|
+
const locale = (await localeFor?.(message.to)) ?? undefined;
|
|
51
|
+
if (message.template === "magicLink") {
|
|
52
|
+
await enqueue({
|
|
53
|
+
to: message.to,
|
|
54
|
+
template: "magicLink",
|
|
55
|
+
payload: { url: message.url, expiresMinutes },
|
|
56
|
+
...(locale ? { locale } : {}),
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await enqueue({
|
|
61
|
+
to: message.to,
|
|
62
|
+
template: "otp",
|
|
63
|
+
payload: { code: message.code, expiresMinutes },
|
|
64
|
+
...(locale ? { locale } : {}),
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
}
|