@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,108 @@
1
+ {
2
+ "name": "auth",
3
+ "package": "@pithy-sh/auth",
4
+ "requiredBindings": [{ "type": "d1", "name": "DB" }, { "type": "ratelimit", "name": "AUTH_RATE_LIMITER" }],
5
+ "peerCapabilities": ["secrets", "email"],
6
+ "optionalCapabilities": ["turnstile", "audit"],
7
+ "migrationNamespace": "auth",
8
+ "devSecrets": [{ "name": "auth-session-secret", "devValue": "random" }],
9
+ "secrets": [
10
+ {
11
+ "name": "auth-session-secret",
12
+ "origin": {
13
+ "kind": "minted",
14
+ "recipe": {
15
+ "kind": "random",
16
+ "bytes": 32,
17
+ "encoding": "base64url"
18
+ }
19
+ },
20
+ "rotation": {
21
+ "kind": "local"
22
+ }
23
+ },
24
+ {
25
+ "name": "auth-google-credentials",
26
+ "origin": {
27
+ "kind": "obtained",
28
+ "issuer": "google",
29
+ "documentation": "https://console.cloud.google.com/apis/credentials"
30
+ },
31
+ "rotation": {
32
+ "kind": "manual",
33
+ "issuer": "google",
34
+ "documentation": "https://console.cloud.google.com/apis/credentials"
35
+ }
36
+ },
37
+ {
38
+ "name": "auth-apple-credentials",
39
+ "origin": {
40
+ "kind": "obtained",
41
+ "issuer": "apple",
42
+ "documentation": "https://developer.apple.com/account/resources/authkeys/list"
43
+ },
44
+ "rotation": {
45
+ "kind": "manual",
46
+ "issuer": "apple",
47
+ "documentation": "https://developer.apple.com/account/resources/authkeys/list"
48
+ }
49
+ },
50
+ {
51
+ "name": "auth-facebook-credentials",
52
+ "origin": {
53
+ "kind": "obtained",
54
+ "issuer": "facebook",
55
+ "documentation": "https://developers.facebook.com/apps/"
56
+ },
57
+ "rotation": {
58
+ "kind": "manual",
59
+ "issuer": "facebook",
60
+ "documentation": "https://developers.facebook.com/apps/"
61
+ }
62
+ },
63
+ {
64
+ "name": "auth-github-credentials",
65
+ "origin": {
66
+ "kind": "obtained",
67
+ "issuer": "github",
68
+ "documentation": "https://github.com/settings/developers"
69
+ },
70
+ "rotation": {
71
+ "kind": "manual",
72
+ "issuer": "github",
73
+ "documentation": "https://github.com/settings/developers"
74
+ }
75
+ }
76
+ ],
77
+ "whenToEnable": "Sign-in for your app — passwordless, mobile and web both first-class. Magic link, email OTP, and social sign-in with Google, Apple, Facebook, and GitHub. A successful sign-in mints a long-lived refresh session and short-lived JWT access tokens that verify locally with no database hit. It fills core's identity seams, so every other capability just calls `requireAuth()`. This is the keystone capability; add it first.",
78
+ "scaffold": [
79
+ "Create the session signing secret: `pithy secrets create auth-session-secret`.",
80
+ "Add the `AUTH_RATE_LIMITER` Workers Rate Limiting binding to each wrangler.jsonc env — the coarse per-IP edge guard on the auth routes. For example: `\"ratelimits\": [{ \"name\": \"AUTH_RATE_LIMITER\", \"namespace_id\": \"1001\", \"simple\": { \"limit\": 60, \"period\": 60 } }]`. This is tier 1; Better Auth's per-action limiter (tier 2) is built in.",
81
+ "Set `baseURL` to the origin this Worker is deployed at, and list every web origin and mobile deep-link scheme in `trustedOrigins`. Dev needs neither: it resolves its own base URL and trusts its own origin.",
82
+ "More of Better Auth (optional): pass any additional Better Auth plugin through `plugins: [...]` — `organization()`, `passkey()`, `twoFactor()`, `apiKey()`, `admin()`. They are added to the four this capability composes (bearer, jwt, magic-link, email-otp), which are fixed and cannot be replaced; a config naming one of them is refused by name. `pithy migrate` creates whatever tables a plugin declares, and `pithy doctor` names every plugin composed. Add the matching client plugin to `createAuthClient` for its typed client surface. See the package's README.",
83
+ "Turnstile (optional): add the `turnstile` capability and the magic-link and OTP send routes are bot-gated automatically — no wiring.",
84
+ "Google (optional): set `google: { enabled: true }`, then `pithy secrets create auth-google-credentials` with the typed JSON `{ clientId, clientSecret }`. See the package's docs/google-oauth.md for the Console steps and redirect URIs.",
85
+ "Apple (optional): set `apple: { enabled: true }`, then `pithy secrets create auth-apple-credentials` with the typed JSON `{ clientId, clientSecret, appBundleIdentifier? }`. See the package's docs/apple-signin.md.",
86
+ "Facebook (optional): set `facebook: { enabled: true }`, then `pithy secrets create auth-facebook-credentials` with the typed JSON `{ clientId, clientSecret }`. See the package's docs/facebook-oauth.md.",
87
+ "GitHub (optional): set `github: { enabled: true }`, then `pithy secrets create auth-github-credentials` with the typed JSON `{ clientId, clientSecret }`. See the package's docs/github-oauth.md.",
88
+ "Looking a user up and signing them out from a dashboard is a `control-plane` surface, not an admin user, so the routes under /auth/admin are reached with a signed management credential rather than a session. Add `controlplane()` to this Worker and run `pithy dashboard connect`, then grant the connection only what its tooling needs: `auth:users:read` to find people, `auth:devices:read` to walk the device registry, `auth:sessions:revoke` to kill one stolen session, `auth:users:logout` to sign somebody out everywhere, `auth:devices:revoke` for a phone reported lost. Five scopes and not one admin flag — a support tool that looks people up must not also be able to sign the customer base out, and an incident-response tool that kills a session has no business reading every address in the user table. With the seam not composed every /auth/admin route answers `controlplane/not_connected`, and no app session opens any of them whatever it carries. There is deliberately no impersonation: signing in as a user produces a credential indistinguishable from theirs, so it is excluded until it has its own design and security review."
89
+ ],
90
+ "configOptions": [
91
+ {
92
+ "key": "basePath",
93
+ "default": "/auth",
94
+ "describe": "The path the auth handler mounts under. Must match the OAuth redirect URIs you register. Defaults to `/auth`."
95
+ },
96
+ {
97
+ "key": "baseURL",
98
+ "default": "https://api.example.com",
99
+ "constant": "publicOrigin",
100
+ "describe": "The public origin of this Worker where it is deployed, no trailing slash. OAuth callbacks, JWKS and magic-link URLs are built from it, so it must be reachable. A `dev` composition ignores it and serves on `http://<the host the request arrived at>` — local dev has no TLS and its port is assigned per run, so it is the one address nobody can write down. Set `trustedOrigins` and the social providers (`google`, `apple`, `facebook`, `github`) by hand — see the scaffold steps below."
101
+ },
102
+ {
103
+ "key": "disableSignUp",
104
+ "default": false,
105
+ "describe": "When true, sign-in never provisions a new user — existing accounts only. Unknown emails get no email (anti-enumeration)."
106
+ }
107
+ ]
108
+ }
@@ -0,0 +1,357 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { boundParameterBudget, MAX_BOUND_PARAMETERS } from "@pithy-sh/core/src/data/boundParameters";
5
+ import { decodeCursor, pageLimit, toPage } from "@pithy-sh/core/src/data/cursor";
6
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { type SqlBool, sql } from "kysely";
8
+ import { Session, User } from "../data/betterAuth";
9
+ import { Device, type DevicePlatform } from "../data/device";
10
+ import type { AuthDatabase } from "../data/tables";
11
+
12
+ /**
13
+ * The reads a management dashboard's user panes resolve to, and the lookups its revocations need.
14
+ *
15
+ * Deliberately **not** under `src/http/`: nothing here knows about a request, a scope, or a caller, so
16
+ * the projections that decide what a management client may see stay in one place (`http/adminRoutes.ts`)
17
+ * and these stay directly testable against a real D1. Everything takes the shared {@link AuthDatabase},
18
+ * so the Better-Auth-managed tables and Pithy's own device registry are read through the one Kysely
19
+ * with `CamelCasePlugin` — query code never types a `pithy_auth_` name.
20
+ *
21
+ * ## Two date representations, and getting it wrong is silent
22
+ *
23
+ * The Better-Auth tables store dates as **ISO-8601 text** (`BetterAuthDate`); `pithy_auth_devices`
24
+ * stores them as Pithy's house **ms-epoch numbers** (`SQLiteDate`). A cursor carries the sort column's
25
+ * *stored* value, so the users cursor holds a string and the devices cursor holds a number. Handing
26
+ * SQLite the other one does not throw — it compares across storage classes and quietly returns the
27
+ * wrong page — so each cursor is built from the parsed row and converted back to the column's own
28
+ * representation rather than copied from whatever the row object happened to hold.
29
+ *
30
+ * ## Everything is bounded, including the sub-lists
31
+ *
32
+ * A device id is client-generated (`x-pithy-device-id`), so a single user can mint as many device rows
33
+ * as they like, and sessions accumulate per sign-in. Reading "this user's devices" unbounded would let
34
+ * any end user decide how much work an admin pane does. Every list here takes a limit and over-fetches
35
+ * by one to report whether there is more.
36
+ */
37
+
38
+ /** One page of rows plus where the next one starts. `nextCursor` is null at the end of the list. */
39
+ export interface AdminPage<T> {
40
+ /** The rows in this page, already decoded through their table schema. */
41
+ items: T[];
42
+ /** The opaque cursor the next request should send, or null when this was the last page. */
43
+ nextCursor: string | null;
44
+ }
45
+
46
+ /** A bounded sub-list inside a single-user view, and whether the bound cut it short. */
47
+ export interface AdminSubList<T> {
48
+ /** The rows, most recent first, at most the requested limit. */
49
+ items: T[];
50
+ /** True when more rows exist than the limit allowed — the pane should say so rather than imply a total. */
51
+ truncated: boolean;
52
+ }
53
+
54
+ /**
55
+ * The separator joining `(userId, id)` into the device cursor's single tiebreak.
56
+ *
57
+ * `pithy_auth_devices` has a **composite** primary key, because `id` is a client-generated device id
58
+ * that is unique only per user. A cursor tiebreaking on `id` alone would therefore not name a unique
59
+ * row, and two users who happened to mint the same device id would straddle a page boundary — one of
60
+ * them skipped. A NUL byte is the separator because a user id is a Better-Auth UUID and cannot contain
61
+ * one, so splitting on the first occurrence recovers the pair exactly however the device id is written.
62
+ */
63
+ const DEVICE_KEY_SEPARATOR = "\u0000";
64
+
65
+ function deviceKey(userId: string, id: string): string {
66
+ return `${userId}${DEVICE_KEY_SEPARATOR}${id}`;
67
+ }
68
+
69
+ function splitDeviceKey(key: string): { userId: string; id: string } | undefined {
70
+ const at = key.indexOf(DEVICE_KEY_SEPARATOR);
71
+ if (at < 0) return undefined;
72
+ return { userId: key.slice(0, at), id: key.slice(at + 1) };
73
+ }
74
+
75
+ /**
76
+ * Escape a caller's search term for a `LIKE` pattern.
77
+ *
78
+ * Without this a term containing `%` or `_` silently becomes a wildcard, so a support agent searching
79
+ * for the very common `first_last@example.com` matches addresses that are not the one they typed and
80
+ * acts on the wrong person. It is a correctness fix rather than an authorization one — a caller holding
81
+ * `auth:users:read` may already list every user — which is why the bound on the term's length lives on
82
+ * the request schema instead.
83
+ */
84
+ function likePattern(term: string): string {
85
+ return `%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
86
+ }
87
+
88
+ /** What a user listing may filter and page by. */
89
+ export interface ListUsersOptions {
90
+ /** Free text matched against email and display name. Absent lists everyone, newest first. */
91
+ search?: string;
92
+ /** The previous page's `nextCursor`. A malformed one is a first page, never an error. */
93
+ cursor?: string;
94
+ /** How many rows to return, clamped into `[1, MAX_PAGE_SIZE]` by `pageLimit`. */
95
+ limit?: number;
96
+ }
97
+
98
+ /**
99
+ * Users, newest first, optionally filtered by a free-text term over email and display name.
100
+ *
101
+ * Keyset-paginated on `(createdAt, id)` — never an offset. The users table is written to constantly
102
+ * while somebody is paging through it, and under `OFFSET` a sign-up at the head pushes a row from page
103
+ * one onto page two, so a client paging through sees it twice and misses another by the same amount.
104
+ *
105
+ * The search is a substring `LIKE` over two columns and no index can serve it; the request schema bounds
106
+ * the term and `pageLimit` bounds the page, which is what keeps the scan a bounded one.
107
+ */
108
+ export async function listUsers(db: AuthDatabase, options: ListUsersOptions = {}): Promise<AdminPage<User>> {
109
+ const limit = pageLimit(options.limit);
110
+ let query = db
111
+ .selectFrom("pithyAuthUsers")
112
+ .selectAll()
113
+ .orderBy("createdAt", "desc")
114
+ .orderBy("id", "desc")
115
+ .limit(limit + 1);
116
+
117
+ const term = options.search?.trim();
118
+ if (term) {
119
+ const pattern = likePattern(term);
120
+ // Raw SQL for this one predicate, because SQLite's `ESCAPE` clause is part of the `LIKE` operator
121
+ // rather than of its right-hand operand, so there is no expression-builder form of it. Both column
122
+ // names are already their physical (single-word) names, so `CamelCasePlugin` has nothing to do here.
123
+ query = query.where(sql<SqlBool>`(email like ${pattern} escape '\\' or name like ${pattern} escape '\\')`);
124
+ }
125
+
126
+ const cursor = decodeCursor(options.cursor);
127
+ if (cursor) {
128
+ // ISO-8601 text, because that is what the Better-Auth adapter wrote. Descending lexicographic order
129
+ // over a fixed-width ISO-8601 string is descending chronological order, which is why this works.
130
+ const at = String(cursor.sort);
131
+ const id = String(cursor.id);
132
+ query = query.where((eb) =>
133
+ eb.or([eb("createdAt", "<", at), eb.and([eb("createdAt", "=", at), eb("id", "<", id)])]),
134
+ );
135
+ }
136
+
137
+ const rows = await query.execute();
138
+ return toPage(
139
+ rows.map((row) => User.parse(row)),
140
+ limit,
141
+ (user) => ({ sort: user.createdAt.toISOString(), id: user.id }),
142
+ );
143
+ }
144
+
145
+ /** One user, or null when no row carries that id. The 404 is the caller's decision, not this one's. */
146
+ export async function getUser(db: AuthDatabase, userId: string): Promise<User | null> {
147
+ const row = await db.selectFrom("pithyAuthUsers").selectAll().where("id", "=", userId).executeTakeFirst();
148
+ return row ? User.parse(row) : null;
149
+ }
150
+
151
+ /**
152
+ * What the bulk lookup binds besides the ids themselves: nothing. Named anyway, because it is the number
153
+ * {@link MAX_USER_LOOKUP} is derived against — adding a `where` here is then an edit to both, next to
154
+ * each other, rather than a cap nobody re-derived.
155
+ */
156
+ export const USER_LOOKUP_FIXED_PARAMETERS = 0;
157
+
158
+ /**
159
+ * The most ids one {@link getUsers} call may name.
160
+ *
161
+ * Two numbers meet at 100 and both matter. D1 accepts `MAX_BOUND_PARAMETERS` in one statement and this
162
+ * one spends `USER_LOOKUP_FIXED_PARAMETERS` before the first id, so the ceiling is the budget. And a
163
+ * caller's ids come from a page of *their* rows, which `pageLimit` clamps at `MAX_PAGE_SIZE` — a cap
164
+ * below that would refuse to resolve the largest page the kit itself hands out, which is the first thing
165
+ * an adopter would hit.
166
+ */
167
+ export const MAX_USER_LOOKUP = 100;
168
+
169
+ /**
170
+ * The users behind a list of ids, in one query, keyed by id.
171
+ *
172
+ * The read an adopter with its own membership or team table needs: it holds ids and has to render names
173
+ * beside them. Without this the only by-id read is {@link getUser}, so a twelve-person roster is twelve
174
+ * queries — and the alternatives are worse, since querying `pithy_auth_users` directly is a second
175
+ * definition of this capability's schema and paging {@link listUsers} to find twelve people scans a
176
+ * table that grows without bound.
177
+ *
178
+ * **A `Map`, not an array.** The caller already has the order — it is holding the id list — and what it
179
+ * lacks is the lookup. An array makes every caller rebuild this index, and an array in *input order*
180
+ * would quietly imply that a missing user leaves a hole in it.
181
+ *
182
+ * **A missing id is an absence, not an error.** A membership can outlive the user row it names, and the
183
+ * pane has to render that gap rather than fail the screen. Ids collapse to their distinct set first: two
184
+ * memberships naming the same person are one lookup, and duplicates cost the statement nothing.
185
+ *
186
+ * **Bounded, and a refusal rather than a truncation** — the same rule the sub-lists here follow, for the
187
+ * same reason. Quietly answering for 100 of somebody's 140 members would be a wrong roster presented as
188
+ * a right one, so past the cap this throws and names it. An empty list issues no query at all.
189
+ *
190
+ * Rows go through the `User` codec, exactly as {@link getUser}'s do, which is both the date conversion
191
+ * and the projection: the schema names the columns an adopter gets, so nothing this table later gains
192
+ * arrives here unannounced.
193
+ */
194
+ export async function getUsers(db: AuthDatabase, userIds: readonly string[]): Promise<Map<string, User>> {
195
+ const ids = [...new Set(userIds)];
196
+ if (ids.length === 0) return new Map();
197
+
198
+ // `Math.min` is a no-op today and a tripwire tomorrow: it is what turns a `where` added above into a
199
+ // refusal that names the cap, instead of a statement `createDatabase` rejects at bind time.
200
+ const cap = Math.min(MAX_USER_LOOKUP, boundParameterBudget(USER_LOOKUP_FIXED_PARAMETERS));
201
+ if (ids.length > cap) {
202
+ throw new ValidationError({
203
+ message: `Look up at most ${MAX_USER_LOOKUP} users at a time.`,
204
+ action: `Resolve the ids in pages of ${MAX_USER_LOOKUP}.`,
205
+ detail: `getUsers was given ${ids.length} distinct ids, over the cap of ${MAX_USER_LOOKUP}; D1 accepts ${MAX_BOUND_PARAMETERS} bound parameters and this statement spends ${USER_LOOKUP_FIXED_PARAMETERS} of them before the first id.`,
206
+ });
207
+ }
208
+
209
+ const rows = await db.selectFrom("pithyAuthUsers").selectAll().where("id", "in", ids).execute();
210
+ const users = new Map<string, User>();
211
+ for (const row of rows) {
212
+ const user = User.parse(row);
213
+ users.set(user.id, user);
214
+ }
215
+ return users;
216
+ }
217
+
218
+ /**
219
+ * The provider slugs a user can sign in with — `["google"]`, `["google", "apple"]`, or empty for a
220
+ * passwordless-only account.
221
+ *
222
+ * **Selects `providerId` and nothing else, and that is the security property rather than an
223
+ * optimization.** `pithy_auth_accounts` also holds the provider's `accessToken`, `refreshToken`, and
224
+ * `idToken` — live credentials against a third party, on the user's behalf. Those must never reach a
225
+ * management client, and the strongest way to guarantee it is for them never to be loaded: a projection
226
+ * cannot leak a column that was not selected, however the view function is later edited.
227
+ */
228
+ export async function userProviders(db: AuthDatabase, userId: string): Promise<string[]> {
229
+ const rows = await db
230
+ .selectFrom("pithyAuthAccounts")
231
+ .select("providerId")
232
+ .where("userId", "=", userId)
233
+ .orderBy("providerId", "asc")
234
+ .execute();
235
+ return [...new Set(rows.map((row) => row.providerId))];
236
+ }
237
+
238
+ /** A user's live sessions, newest first, bounded. */
239
+ export async function listUserSessions(
240
+ db: AuthDatabase,
241
+ userId: string,
242
+ limit: number,
243
+ ): Promise<AdminSubList<Session>> {
244
+ const rows = await db
245
+ .selectFrom("pithyAuthSessions")
246
+ .selectAll()
247
+ .where("userId", "=", userId)
248
+ .orderBy("createdAt", "desc")
249
+ .orderBy("id", "desc")
250
+ .limit(limit + 1)
251
+ .execute();
252
+ return {
253
+ items: rows.slice(0, limit).map((row) => Session.parse(row)),
254
+ truncated: rows.length > limit,
255
+ };
256
+ }
257
+
258
+ /** A user's registered devices, most-recently-seen first, bounded. */
259
+ export async function listUserDevices(db: AuthDatabase, userId: string, limit: number): Promise<AdminSubList<Device>> {
260
+ const rows = await db
261
+ .selectFrom("pithyAuthDevices")
262
+ .selectAll()
263
+ .where("userId", "=", userId)
264
+ .orderBy("lastSeenAt", "desc")
265
+ .orderBy("id", "desc")
266
+ .limit(limit + 1)
267
+ .execute();
268
+ return {
269
+ items: rows.slice(0, limit).map((row) => Device.parse(row)),
270
+ truncated: rows.length > limit,
271
+ };
272
+ }
273
+
274
+ /** What a device-registry listing may filter and page by. */
275
+ export interface ListDeviceRegistryOptions {
276
+ /** Narrow to one user's devices. Absent reads the whole fleet, most-recently-seen first. */
277
+ userId?: string;
278
+ /** Narrow to one platform. Typed as the enum, so a filter the column cannot hold is a compile error. */
279
+ platform?: DevicePlatform;
280
+ /** The previous page's `nextCursor`. A malformed one is a first page, never an error. */
281
+ cursor?: string;
282
+ /** How many rows to return, clamped into `[1, MAX_PAGE_SIZE]` by `pageLimit`. */
283
+ limit?: number;
284
+ }
285
+
286
+ /**
287
+ * The device registry across users, most-recently-seen first.
288
+ *
289
+ * Keyset-paginated on `(lastSeenAt, userId, id)`. `lastSeenAt` is a **ms-epoch number** here, not the
290
+ * ISO-8601 text the Better-Auth tables use — this is Pithy's own table, on Pithy's house codec.
291
+ */
292
+ export async function listDeviceRegistry(
293
+ db: AuthDatabase,
294
+ options: ListDeviceRegistryOptions = {},
295
+ ): Promise<AdminPage<Device>> {
296
+ const limit = pageLimit(options.limit);
297
+ let query = db
298
+ .selectFrom("pithyAuthDevices")
299
+ .selectAll()
300
+ .orderBy("lastSeenAt", "desc")
301
+ .orderBy("userId", "desc")
302
+ .orderBy("id", "desc")
303
+ .limit(limit + 1);
304
+
305
+ if (options.userId) query = query.where("userId", "=", options.userId);
306
+ if (options.platform) query = query.where("platform", "=", options.platform);
307
+
308
+ const cursor = decodeCursor(options.cursor);
309
+ const key = cursor ? splitDeviceKey(String(cursor.id)) : undefined;
310
+ const at = cursor ? Number(cursor.sort) : Number.NaN;
311
+ // A cursor whose sort value is not a number, or whose tiebreak is not a `(userId, id)` pair, is a
312
+ // cursor from somewhere else. Same rule as a malformed one: it reads as a first page.
313
+ if (key && Number.isFinite(at)) {
314
+ query = query.where((eb) =>
315
+ eb.or([
316
+ eb("lastSeenAt", "<", at),
317
+ eb.and([
318
+ eb("lastSeenAt", "=", at),
319
+ eb.or([eb("userId", "<", key.userId), eb.and([eb("userId", "=", key.userId), eb("id", "<", key.id)])]),
320
+ ]),
321
+ ]),
322
+ );
323
+ }
324
+
325
+ const rows = await query.execute();
326
+ return toPage(
327
+ rows.map((row) => Device.parse(row)),
328
+ limit,
329
+ (device) => ({ sort: device.lastSeenAt.getTime(), id: deviceKey(device.userId, device.id) }),
330
+ );
331
+ }
332
+
333
+ /**
334
+ * The session a management client named, or null.
335
+ *
336
+ * Returns the session's **token** because that is what Better Auth's `internalAdapter.deleteSession`
337
+ * keys on — a revocation must go through it so the adapter's own bookkeeping stays consistent, exactly
338
+ * as the user-facing device revoke does. The token is a live credential and stops here: it is looked up
339
+ * and handed to the delete, and no projection in `http/adminRoutes.ts` ever carries it out to a caller.
340
+ */
341
+ export async function findSessionById(
342
+ db: AuthDatabase,
343
+ sessionId: string,
344
+ ): Promise<{ id: string; token: string; userId: string } | null> {
345
+ const row = await db
346
+ .selectFrom("pithyAuthSessions")
347
+ .select(["id", "token", "userId"])
348
+ .where("id", "=", sessionId)
349
+ .executeTakeFirst();
350
+ return row ?? null;
351
+ }
352
+
353
+ /** Every live session token a user holds — the set a sign-out-everywhere has to revoke. */
354
+ export async function userSessionTokens(db: AuthDatabase, userId: string): Promise<string[]> {
355
+ const rows = await db.selectFrom("pithyAuthSessions").select("token").where("userId", "=", userId).execute();
356
+ return rows.map((row) => row.token);
357
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The audit action codes `@pithy-sh/auth` emits, as `domain/reason` strings under the `auth` domain.
6
+ *
7
+ * Auth emits through core's `emit` seam (`c.var.emit`), so it never imports `@pithy-sh/audit` — audit
8
+ * is an optional capability (principle 4: depend on core seams, not other capabilities). When audit is
9
+ * absent the seam is a no-op; when present these land in `pithy_audit_events`. Every code matches core's
10
+ * `AuditAction` pattern; `actions.test.ts` asserts it.
11
+ */
12
+ export const AuthAuditActions = {
13
+ /** A sign-in completed (magic link, OTP, or OAuth). Outcome `denied` for a blocked attempt. */
14
+ signin: "auth/signin",
15
+ /** A sign-out / session revocation completed. */
16
+ signout: "auth/signout",
17
+ /** A session was exchanged for a fresh access token, rotating the refresh credential. */
18
+ tokenRefresh: "auth/token_refresh",
19
+ /** A consumed (already-rotated) refresh token was replayed — reuse detected, the family revoked. Outcome `denied`. */
20
+ tokenReuseDetected: "auth/token_reuse_detected",
21
+ /** A magic link was requested and enqueued for delivery. */
22
+ magicLinkSent: "auth/magic_link_sent",
23
+ /** An email OTP was requested and enqueued for delivery. */
24
+ otpSent: "auth/otp_sent",
25
+ /** A social account (Google) was linked to a user. */
26
+ oauthLinked: "auth/oauth_linked",
27
+ /** A device was registered or updated from sign-in metadata. */
28
+ deviceRegistered: "auth/device_registered",
29
+ /** A device was revoked (its session(s) signed out). */
30
+ deviceRevoked: "auth/device_revoked",
31
+ /**
32
+ * Somebody tried to sign in with a provider this deployment enables and cannot serve — its credential
33
+ * would not resolve, so the instance was built without it (#381). Outcome `denied`, severity
34
+ * `warning`.
35
+ *
36
+ * **This is the operator's channel, and it is why degrading is not the same as degrading silently.**
37
+ * The refusal the caller gets names the provider, but a caller is not who fixes this; nobody at the
38
+ * adopter learns a sign-in method is down from a browser somebody else is holding. The trail carries
39
+ * one row per attempt, naming the provider, so the question "since when, and how many people has it
40
+ * cost" is answerable from the same place every other auth question is.
41
+ */
42
+ providerUnavailable: "auth/provider_unavailable",
43
+
44
+ /**
45
+ * The admin actions, emitted only from the control-plane surface (`http/adminRoutes.ts`) and always
46
+ * with `actorType: "control-plane"` — a management client is not a user of the adopter's app, so its
47
+ * actions must be answerable separately from their users'.
48
+ *
49
+ * **The reads are audited too, and that is not padding.** Listing the user table hands a management
50
+ * client every customer's email address; reading one user hands over where they signed in from and on
51
+ * what. If only the writes were recorded, the trail would show a compromised dashboard credential
52
+ * revoking one session and say nothing at all about the customer list it walked on the way there —
53
+ * and the exfiltration is the larger incident.
54
+ */
55
+
56
+ /** The user table was listed or searched from the management surface. A read of other people's data. */
57
+ adminUsersListed: "auth/admin_users_listed",
58
+ /** One user was read from the management surface, with their sessions and devices. */
59
+ adminUserRead: "auth/admin_user_read",
60
+ /** The device registry was walked from the management surface. */
61
+ adminDevicesListed: "auth/admin_devices_listed",
62
+ /** One named session was revoked from the management surface. */
63
+ adminSessionRevoked: "auth/admin_session_revoked",
64
+ /** Every session a user held was revoked from the management surface — signed out everywhere. */
65
+ adminUserSessionsRevoked: "auth/admin_user_sessions_revoked",
66
+ /** One of a user's devices was signed out and its registration dropped, from the management surface. */
67
+ adminDeviceRevoked: "auth/admin_device_revoked",
68
+ } as const;
69
+
70
+ /** One of the auth audit action codes. */
71
+ export type AuthAuditAction = (typeof AuthAuditActions)[keyof typeof AuthAuditActions];