@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,286 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
5
+ import { fromZodError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { MAX_SEED_ORDER } from "@pithy-sh/core/src/seed/compose";
7
+ import { DEV_LOGIN_FILE, DEV_LOGIN_PATH, DevLogin } from "@pithy-sh/core/src/seed/devLogin";
8
+ import {
9
+ d1SeedGroup,
10
+ defineSeed,
11
+ type SeedPreparation,
12
+ type SeedPrepareContext,
13
+ type SeedSet,
14
+ } from "@pithy-sh/core/src/seed/seed";
15
+ import { z } from "zod";
16
+ import { Session } from "../data/betterAuth";
17
+ import { DEV_PROTOCOL, sessionCookieName } from "../http/baseUrl";
18
+ import { AUTH_SESSION_SECRET } from "../instance/secrets";
19
+
20
+ /**
21
+ * The dev-login seed set: a real, signed-in session for one seeded user, so local development does not
22
+ * begin with a magic-link round trip.
23
+ *
24
+ * Sign-in is passwordless by design, which is right in production and a tax in development — and more
25
+ * than a tax for anything automated, which cannot read a mailbox at all. The interesting part is *where*
26
+ * the work happens: the session is minted during seed, so the database holds a genuine row and the browser
27
+ * is handed a genuine cookie. Nothing about the request path is relaxed to make it work.
28
+ *
29
+ * Four guard rails, because what this writes is a live credential:
30
+ *
31
+ * - `environments: ["dev"]` — it can never be composed into staging or production.
32
+ * - No `~/.config/pithy/<project>/dev.json`, no session. The default stays "there is no way in but a magic link";
33
+ * opting in is a per-machine file outside the repo, so two developers on one checkout can differ.
34
+ * - The login file is transient, written under the gitignored `logs/` ({@link DEV_LOGIN_PATH}). A seeded
35
+ * cookie must never be committable.
36
+ * - The named user must be one this run actually creates. A session for a user nobody seeded is a dangling
37
+ * row, so `dev.json` is checked against `context.seeded` — the run's own inventory — and a miss fails
38
+ * saying who *was* seeded.
39
+ *
40
+ * Not an example set. It seeds no users of its own; it signs in as whoever the run creates — auth's example
41
+ * cast when the project enables `includeExamples`, the app's own users otherwise, both when both. An adopter
42
+ * should not have to turn on a fictional cast to get a dev login, and the cast is no more seeded than before:
43
+ * it still arrives only through `authExampleSeed`, which is still `example: true`.
44
+ */
45
+
46
+ /**
47
+ * Where this set sorts: last, at the ceiling. It depends on every set that can create a user — auth's own
48
+ * example set, and an adopter's app set, which sorts high by convention — so it sorts after all of them.
49
+ * Ties break on the namespaced key, but nothing rests on that: what this set needs to *know* comes from the
50
+ * composed plan, not from what has already been written, and the session row carries no foreign key.
51
+ */
52
+ export const AUTH_DEV_SESSION_SEED_ORDER = MAX_SEED_ORDER;
53
+
54
+ /** The composed registry coordinates of the users table this set reads and signs in as. */
55
+ const USERS_DATABASE = "app";
56
+ const USERS_TABLE = "pithyAuthUsers";
57
+
58
+ /**
59
+ * The cookie Better Auth reads the session from, in the one environment this set runs in.
60
+ *
61
+ * It used to be a literal beside a comment asserting that a `dev` base URL is not HTTPS. Nothing made
62
+ * that true: `baseURL` was one string for every environment, so an adopter whose production origin was
63
+ * HTTPS — every adopter — seeded this name while the running instance looked for `__Secure-` (#244).
64
+ * The session was there, the cookie was there, and `get-session` returned `null` with nothing logged.
65
+ *
66
+ * Now it is computed from the same two facts the composition computes its own name from: a `dev`
67
+ * composition serves over {@link DEV_PROTOCOL}, and {@link sessionCookieName} is the prefix rule. The
68
+ * host and port never enter it, which is what lets a seed name a cookie for a port not yet assigned.
69
+ * Still locked to the running Better Auth version by a test that reads the name off a live instance.
70
+ */
71
+ export const DEV_SESSION_COOKIE_NAME = sessionCookieName(DEV_PROTOCOL);
72
+
73
+ /**
74
+ * The prefix every seeded dev session's id and token carry.
75
+ *
76
+ * It is what makes a dev session **findable without being told**, which is what the dev-login route
77
+ * needs: the route reads the session out of D1 and has no artifact to consult (a Worker has no
78
+ * filesystem). And it is what makes one identifiable at all — a `pithy_auth_sessions` row minted by a
79
+ * seed is otherwise indistinguishable from one a real sign-in created.
80
+ */
81
+ export const DEV_SESSION_TOKEN_PREFIX = "dev-session-";
82
+
83
+ /** How long a seeded session lives. Long, because reseeding to restore a dev login is the friction this removes. */
84
+ const DEV_SESSION_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
85
+
86
+ /** Bytes of the secret digest kept as the token's fingerprint — enough to separate secrets, short enough to read. */
87
+ const FINGERPRINT_BYTES = 4;
88
+
89
+ /** The developer's machine-local preferences for this project, read from `~/.config/pithy/<project>/dev.json`. */
90
+ export const DevPreferences = z
91
+ .object({
92
+ user: z
93
+ .string()
94
+ .describe(
95
+ "The email of the seeded user to sign in as. Must be a user this seed run creates, or the seed fails rather than signing in as nobody.",
96
+ ),
97
+ })
98
+ .describe(
99
+ "A developer's machine-local dev preferences, from the Pithy config directory (`~/.config/pithy/<project>/dev.json`, or `%APPDATA%\\pithy\\<project>\\dev.json` on Windows) — outside the repo, so opting in needs no commit.",
100
+ );
101
+ export type DevPreferences = z.output<typeof DevPreferences>;
102
+
103
+ /**
104
+ * A user this run seeds, read back out of the composed plan.
105
+ *
106
+ * Narrow on purpose: the rows come from a set this capability does not own — an adopter's, most of the time —
107
+ * so they cross a trust boundary and are validated, but only for the two fields a session needs. A row with
108
+ * more in it is fine; a row without these is not a user this set can sign in as.
109
+ */
110
+ export const SeededUser = z
111
+ .object({
112
+ id: z.string().min(1).describe("The user id the session's `userId` points at."),
113
+ email: z.string().min(1).describe("The email `dev.json` names, and the login artifact records."),
114
+ })
115
+ .describe("A seeded `pithy_auth_users` row, narrowed to what minting a dev session for it requires.");
116
+ export type SeededUser = z.output<typeof SeededUser>;
117
+
118
+ /** What {@link mintDevSession} produces: the row to write, and the artifact the browser is handed. */
119
+ export interface MintedDevSession {
120
+ /** The `pithy_auth_sessions` row, in app shape — re-encoded and validated by the seed writer. */
121
+ session: Session;
122
+ /** The dev-login artifact, written to `logs/dev-login.json` once the row lands. */
123
+ login: DevLogin;
124
+ }
125
+
126
+ /**
127
+ * Sign a value the way better-call does, so Better Auth accepts the cookie as one it signed itself:
128
+ * HMAC-SHA-256 over the value with the auth secret, base64, appended after a dot, then URI-encoded.
129
+ *
130
+ * Mirrored rather than imported: `signCookieValue` is internal to `better-call/dist/crypto`, not part of
131
+ * anything Better Auth re-exports, so importing it would bind us to a private path. Verified against
132
+ * better-call 1.3.6 — the version Better Auth 1.6.19 resolves — and pinned by a round-trip test that makes
133
+ * a real instance accept the result, which is the only check that actually matters.
134
+ */
135
+ export async function signCookieValue(value: string, secret: string): Promise<string> {
136
+ const key = await crypto.subtle.importKey(
137
+ "raw",
138
+ new TextEncoder().encode(secret),
139
+ { name: "HMAC", hash: "SHA-256" },
140
+ false,
141
+ ["sign"],
142
+ );
143
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
144
+ const base64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
145
+ return encodeURIComponent(`${value}.${base64}`);
146
+ }
147
+
148
+ /**
149
+ * A short, stable fingerprint of the auth secret.
150
+ *
151
+ * Putting it in the session token makes the token deterministic across reseeds — the same cookie keeps
152
+ * working in every worktree once each is seeded, which is the whole point — while rotating the secret
153
+ * changes the token *and* invalidates every cookie signed with the old one, for free. A truncated digest,
154
+ * never the secret: the token is written to a file and read back by tooling.
155
+ */
156
+ export async function secretFingerprint(secret: string): Promise<string> {
157
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
158
+ return Array.from(new Uint8Array(digest).slice(0, FINGERPRINT_BYTES))
159
+ .map((byte) => byte.toString(16).padStart(2, "0"))
160
+ .join("");
161
+ }
162
+
163
+ /** Inputs to {@link mintDevSession}. `now` is a seam so the determinism tests do not depend on the clock. */
164
+ export interface MintDevSessionInput {
165
+ /** The seeded user to sign in as. */
166
+ user: SeededUser;
167
+ /** The Better Auth signing secret for this environment — never logged, never stored, never in an error. */
168
+ secret: string;
169
+ /** The moment the session is minted. Defaults to now. */
170
+ now?: Date;
171
+ }
172
+
173
+ /**
174
+ * Mint one deterministic dev session: the `pithy_auth_sessions` row and the signed cookie for it.
175
+ *
176
+ * The row id carries the fingerprint too, not just the token. Seed writes are `INSERT OR IGNORE`, so an
177
+ * id that ignored a rotation would keep the stale token alive under a row the next run refuses to replace.
178
+ */
179
+ export async function mintDevSession(input: MintDevSessionInput): Promise<MintedDevSession> {
180
+ const now = input.now ?? new Date();
181
+ const expiresAt = new Date(now.getTime() + DEV_SESSION_LIFETIME_MS);
182
+ const fingerprint = await secretFingerprint(input.secret);
183
+ const token = `${DEV_SESSION_TOKEN_PREFIX}${input.user.id}-${fingerprint}`;
184
+
185
+ const session: Session = {
186
+ id: token,
187
+ token,
188
+ userId: input.user.id,
189
+ expiresAt,
190
+ createdAt: now,
191
+ updatedAt: now,
192
+ ipAddress: "127.0.0.1",
193
+ userAgent: "pithy seed (dev login)",
194
+ deviceId: null,
195
+ familyId: null,
196
+ };
197
+
198
+ return {
199
+ session,
200
+ login: {
201
+ email: input.user.email,
202
+ userId: input.user.id,
203
+ cookieName: DEV_SESSION_COOKIE_NAME,
204
+ cookieValue: await signCookieValue(token, input.secret),
205
+ expiresAt,
206
+ },
207
+ };
208
+ }
209
+
210
+ /**
211
+ * The users this run creates, whichever set contributes them.
212
+ *
213
+ * A row that does not parse is skipped rather than fatal: it belongs to some other set, which owns its own
214
+ * validation, and failing the dev login over someone else's fixture would be the wrong place to find out.
215
+ */
216
+ function seededUsers(seeded: SeedPrepareContext["seeded"]): SeededUser[] {
217
+ return seeded(USERS_DATABASE, USERS_TABLE).flatMap((row) => {
218
+ const parsed = SeededUser.safeParse(row);
219
+ return parsed.success ? [parsed.data] : [];
220
+ });
221
+ }
222
+
223
+ /** What to do about it — the actionable half of every failure here, and never a guess. */
224
+ function nameOneOf(users: readonly SeededUser[]): string {
225
+ if (users.length === 0) {
226
+ return "This run seeds no users at all. Add a user fixture to your app's seed set, or turn on seed.includeExamples for the example cast.";
227
+ }
228
+ return `Name one of the users this run seeds instead: ${users.map((user) => user.email).join(", ")}.`;
229
+ }
230
+
231
+ /** Resolve the preference file into the user to sign in as, or fail saying who this run does seed. */
232
+ function requireUser(preferences: unknown, users: readonly SeededUser[]): SeededUser {
233
+ const parsed = DevPreferences.safeParse(preferences);
234
+ if (!parsed.success) {
235
+ throw fromZodError(parsed.error, {
236
+ message: "The dev.json preference file does not name a user.",
237
+ action: `Set { "user": "<email>" } in it. ${nameOneOf(users)}`,
238
+ });
239
+ }
240
+ // Both sides normalized: `dev.json` is hand-typed, and refusing to sign in over the capital in
241
+ // `Ada@example.com` would be a puzzle rather than an error.
242
+ const wanted = normalizeAddress(parsed.data.user);
243
+ const user = users.find((candidate) => normalizeAddress(candidate.email) === wanted);
244
+ if (!user) {
245
+ throw new ValidationError({
246
+ message: `dev.json asks to sign in as ${parsed.data.user}, which this seed run does not create.`,
247
+ action: nameOneOf(users),
248
+ });
249
+ }
250
+ return user;
251
+ }
252
+
253
+ /**
254
+ * The dev-login seed set. Composed by the auth capability; runs only in `dev`, only for a user this same run
255
+ * creates, and only when the developer has opted in with a `dev.json`.
256
+ */
257
+ export const authDevSessionSeed: SeedSet = defineSeed({
258
+ name: "dev-session",
259
+ order: AUTH_DEV_SESSION_SEED_ORDER,
260
+ environments: ["dev"],
261
+ prepare: async (context): Promise<SeedPreparation> => {
262
+ // No dev.json, no session. This is the default, and it is the one that keeps "there is no way in but
263
+ // a magic link" true for everyone who never asked for anything else.
264
+ if (context.preferences === undefined || context.preferences === null) return {};
265
+
266
+ const user = requireUser(context.preferences, seededUsers(context.seeded));
267
+ const secret = await context.secret(AUTH_SESSION_SECRET);
268
+ if (!secret) {
269
+ throw new ValidationError({
270
+ message: "Cannot mint a dev session without this environment's auth secret.",
271
+ // Never `.dev.vars` (#176). Every `d1` secret left that file in #153, and telling an adopter to
272
+ // put one back there is telling them to undo it — the value would be inert, and the seed would
273
+ // fail again with the same sentence. The path is deliberately unnamed rather than guessed: this
274
+ // set runs inside a Worker with no filesystem and no config directory to resolve, and `pithy
275
+ // doctor` prints the resolved path on every run precisely so a message like this does not have to.
276
+ action: `Add ${AUTH_SESSION_SECRET} to this project's dev secrets file — pithy doctor prints its path — then seed again.`,
277
+ });
278
+ }
279
+
280
+ const minted = await mintDevSession({ user, secret });
281
+ return {
282
+ d1: [d1SeedGroup("app", "pithyAuthSessions", Session, [minted.session])],
283
+ artifacts: [{ file: DEV_LOGIN_FILE, contents: `${JSON.stringify(DevLogin.encode(minted.login), null, 2)}\n` }],
284
+ };
285
+ },
286
+ });
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { EXAMPLE_IDENTITIES } from "@pithy-sh/core/src/seed/exampleIdentities";
5
+ import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
6
+ import { User } from "../data/betterAuth";
7
+
8
+ /**
9
+ * Where the example set sorts among the whole project's seed registry. Auth seeds the identities
10
+ * first — before the leaderboard/ledger/multiplayer example sets, which reference these same users
11
+ * by id — so `order` encodes that dependency, exactly like the migration registry.
12
+ */
13
+ const AUTH_EXAMPLE_SEED_ORDER = 100;
14
+
15
+ /**
16
+ * The canonical demo cast ({@link EXAMPLE_IDENTITIES}) as `pithy_auth_users` rows — the shared,
17
+ * passwordless test identities every other capability's example seed references by `userId`. No
18
+ * `Account`/`Session`/password rows: these are the users, not a signed-in session, and Pithy is
19
+ * passwordless-only regardless. `emailVerified` is true so a seeded identity behaves exactly like a
20
+ * user who has completed magic-link/OTP verification. Composed in only when the project turns on
21
+ * `seed.includeExamples` (`pithy.config.ts`), and only for `dev` and `staging` — an example fixture
22
+ * never targets production, regardless of that setting.
23
+ */
24
+ export const authExampleSeed: SeedSet = defineSeed({
25
+ name: "example",
26
+ order: AUTH_EXAMPLE_SEED_ORDER,
27
+ environments: ["dev", "staging"],
28
+ example: true,
29
+ d1: [
30
+ d1SeedGroup(
31
+ "app",
32
+ "pithyAuthUsers",
33
+ User,
34
+ EXAMPLE_IDENTITIES.map((identity) => ({
35
+ id: identity.id,
36
+ name: identity.name,
37
+ email: identity.email,
38
+ emailVerified: true,
39
+ image: null,
40
+ // The example cast has never picked a language, which is the state most real users are in:
41
+ // negotiate from `Accept-Language` until they do.
42
+ locale: null,
43
+ createdAt: new Date(),
44
+ updatedAt: new Date(),
45
+ })),
46
+ ),
47
+ ],
48
+ });
@@ -0,0 +1,338 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
5
+ import type { AddressInfo } from "node:net";
6
+ import type { D1Database } from "@cloudflare/workers-types";
7
+ import type { AuditEventInput } from "@pithy-sh/core/src/audit/auditEvent";
8
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
9
+ import { pithyErrorHandler } from "@pithy-sh/core/src/error/http";
10
+ import { createMigrationRegistry } from "@pithy-sh/core/src/migrations/registry";
11
+ import { runMigrations } from "@pithy-sh/core/src/migrations/runner";
12
+ import { email } from "@pithy-sh/email/src/capability";
13
+ import { email_0001_init } from "@pithy-sh/email/src/migrations/0001_init";
14
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
15
+ import { configureSharedSecrets, resetSharedSecrets } from "@pithy-sh/secrets/src/sharedSecretsStore";
16
+ import { devEncryptionKeys } from "@pithy-sh/secrets/src/test-utils/devEncryptionKeys";
17
+ import { type SecretFixture, seedSecrets } from "@pithy-sh/secrets/src/test-utils/secretFixtures";
18
+ import { turnstileSecretsRegistry } from "@pithy-sh/turnstile/src/secret/registry";
19
+ import { Hono } from "hono";
20
+ import { Miniflare } from "miniflare";
21
+ import { AuthConfig, type AuthWiring } from "../capability";
22
+ import { publishSameOrigin } from "../http/csrf";
23
+ import { createSessionMiddleware } from "../http/middleware";
24
+ import { createRateLimitMiddleware } from "../http/rateLimit";
25
+ import { createAuthRoutes } from "../http/routes";
26
+ import { authSecretsRegistry } from "../instance/secrets";
27
+ import { AUTH_MIGRATION_ORDER, auth_0001_init } from "../migrations/0001_init";
28
+
29
+ /**
30
+ * A real auth Worker on a real port, for the live suites (#84).
31
+ *
32
+ * The live E2E questions this package has are about an **origin**: which `redirect_uri` Better Auth
33
+ * hands Google, whether a token posted from a browser survives the humanity gate, whether a callback
34
+ * arriving with a forged `state` sets a cookie. None of those are answerable inside the workers pool,
35
+ * which has no listening socket and therefore no port a redirect URI could name; and none are
36
+ * answerable against a hand-built Better Auth instance either, because the thing under test is the
37
+ * route stack the capability composes — the turnstile gate on two paths and not a third, the CSRF
38
+ * publication, the rate limiter — rather than the instance underneath it.
39
+ *
40
+ * So this boots the real stack in Node:
41
+ *
42
+ * - **Miniflare supplies D1**, from Node rather than from inside a worker. Two databases, as a deployed
43
+ * worker has: `DB` for the `pithy_auth_*` and `pithy_email_*` tables, `SECRETS` for the encrypted
44
+ * rows every secret is read from (#153). No plaintext binding stands in for either.
45
+ * - **The migrations are the real ones**, run through the real registry and runner.
46
+ * - **`node:http` gives the port.** Vitest's `fetch` reaches the app the way a browser would, so an
47
+ * `Origin` header is a real one and a `Set-Cookie` is a real one.
48
+ *
49
+ * It reaches Cloudflare for nothing. What is live in a suite built on this is the third party the suite
50
+ * names — Google's token endpoint, Turnstile's siteverify — and nothing else.
51
+ */
52
+
53
+ /** The registry these suites resolve through: auth's slice plus turnstile's, since one route stacks the gate. */
54
+ export const LIVE_REGISTRY = { ...authSecretsRegistry, ...turnstileSecretsRegistry };
55
+
56
+ /** Unconfigure the module-scoped shared secrets store. For the end of a file, never for one app's close. */
57
+ export function resetLiveSecrets(): void {
58
+ resetSharedSecrets();
59
+ }
60
+
61
+ /** Tables the app database holds once both capabilities' migrations have run. */
62
+ const TABLES = [
63
+ "pithy_auth_accounts",
64
+ "pithy_auth_devices",
65
+ "pithy_auth_jwks",
66
+ "pithy_auth_rate_limit",
67
+ "pithy_auth_rotated_tokens",
68
+ "pithy_auth_sessions",
69
+ "pithy_auth_users",
70
+ "pithy_auth_verifications",
71
+ "pithy_email_jobs",
72
+ "pithy_email_events",
73
+ ];
74
+
75
+ /**
76
+ * The session secret these suites sign with.
77
+ *
78
+ * A fixed throwaway rather than a random one, so a failure is reproducible; it authenticates nothing
79
+ * anywhere, and it is not a value the fixture report or an error message ever carries.
80
+ */
81
+ const SESSION_SECRET = "live-e2e-secret-please-rotate-000000";
82
+
83
+ /** An email job the app wrote: which template, and to whom. The body is never read — it carries a token. */
84
+ export interface EnqueuedEmail {
85
+ /** The job's template — `magic-link`, `otp`, … as `pithy_email_jobs` recorded it. */
86
+ template: string;
87
+ /** The recipient address. */
88
+ to: string;
89
+ }
90
+
91
+ /** What a suite may change about the app it boots. Everything else is the capability's own default. */
92
+ export interface LiveAppOptions {
93
+ /**
94
+ * The mount path, **always stated**.
95
+ *
96
+ * `AuthConfig` defaults it to `/auth`, and that default is precisely what a live suite must not lean
97
+ * on: every `redirect_uri` registered with an OAuth provider embeds this path, so moving it silently
98
+ * invalidates all of them at once, and a suite that took the default could not tell that it had moved.
99
+ * Required here, so the pin is in the suite where its reader can see it.
100
+ */
101
+ basePath: string;
102
+ /** Whether Google is enabled. When true, `google` credentials must be supplied. */
103
+ google?: { clientId: string; clientSecret: string } | undefined;
104
+ /** When set, the turnstile gate is composed at this widget mode, exactly as `auth.compose` stacks it. */
105
+ turnstile?: { mode: "visible" | "invisible"; secretKey: string } | undefined;
106
+ /**
107
+ * The `ENVIRONMENT` var this Worker is stamped with, as `pithy init` writes into every wrangler
108
+ * stanza. `dev` by default, because that is what these suites are: a developer's machine, running the
109
+ * stack the way `pithy dev` does.
110
+ *
111
+ * It is an option because one thing now reads it — the turnstile gate refuses a Cloudflare test key
112
+ * outside dev and staging (#374) — so a suite has to be able to say it is prod to prove that.
113
+ */
114
+ environment?: string;
115
+ }
116
+
117
+ /** A booted app: where it listens, what it was pinned to, and what it recorded. */
118
+ export interface LiveApp {
119
+ /** The origin the app is actually serving on — `http://localhost:<port>`, port assigned by the OS. */
120
+ origin: string;
121
+ /** The mount path the suite pinned. Every asserted URL is composed from this, never from a literal. */
122
+ basePath: string;
123
+ /** The OAuth callback URL for a provider, composed from this run's origin and pinned base path. */
124
+ callbackUrl(provider: string): string;
125
+ /** Every audit event the app emitted, in order. */
126
+ events: AuditEventInput[];
127
+ /** Read the email jobs the app enqueued — the evidence a gated route did or did not reach its handler. */
128
+ enqueued(): Promise<EnqueuedEmail[]>;
129
+ /**
130
+ * The one-time code this app last mailed to an address, read out of the job payload.
131
+ *
132
+ * **What it is for is a signed-in browser.** A suite about a route that needs a session has to get
133
+ * one the way a reader does — ask for a code, then post it back — and the code exists nowhere a
134
+ * browser can see it. Reading the enqueued job is the harness standing in for the mailbox, and it is
135
+ * the only stand-in available: no Workflow runs here, so nothing is ever delivered or redacted.
136
+ *
137
+ * `null` when nothing was mailed to that address, which is a suite asserting on a send that never
138
+ * happened rather than a code that was blank.
139
+ */
140
+ mailedOtp(to: string): Promise<string | null>;
141
+ /** Stop the server and dispose the runtime. Always from a `finally` or an `afterAll`. */
142
+ close(): Promise<void>;
143
+ }
144
+
145
+ /**
146
+ * Boot the auth Worker on an ephemeral port.
147
+ *
148
+ * The caller owns the teardown: `close()` stops the listener and disposes Miniflare, and a suite that
149
+ * forgets leaves a workerd process behind.
150
+ */
151
+ export async function startLiveApp(options: LiveAppOptions): Promise<LiveApp> {
152
+ const miniflare = new Miniflare({
153
+ modules: true,
154
+ script: "export default {};",
155
+ d1Databases: { DB: "DB", SECRETS: "SECRETS" },
156
+ });
157
+ const db = (await miniflare.getD1Database("DB")) as unknown as D1Database;
158
+ const secretsDb = (await miniflare.getD1Database("SECRETS")) as unknown as D1Database;
159
+
160
+ const env: Record<string, unknown> = {
161
+ DB: db,
162
+ SECRETS: secretsDb,
163
+ SECRETS_ENCRYPTION_KEYS: devEncryptionKeys(),
164
+ // What a Worker knows about itself (`core/src/worker/identity`), stamped as the scaffold stamps it.
165
+ ENVIRONMENT: options.environment ?? "dev",
166
+ // The tier-1 edge limiter's binding. Always allows: these suites are about the gate below it, and a
167
+ // limiter that denied would make a failure read as a bug in the thing under test.
168
+ AUTH_RATE_LIMITER: { limit: async () => ({ success: true }) } satisfies RateLimit,
169
+ };
170
+
171
+ await migrate(db);
172
+
173
+ const fixture: SecretFixture<typeof LIVE_REGISTRY> = { "auth-session-secret": SESSION_SECRET };
174
+ if (options.google) fixture["auth-google-credentials"] = options.google;
175
+ if (options.turnstile) {
176
+ fixture["turnstile-secret-keys"] = { [options.turnstile.mode]: { key: options.turnstile.secretKey } };
177
+ }
178
+ configureSharedSecrets({ registry: LIVE_REGISTRY });
179
+ await seedSecrets(env as unknown as SecretsStoreEnv, LIVE_REGISTRY, fixture);
180
+
181
+ const events: AuditEventInput[] = [];
182
+ const server = createServer();
183
+ const origin = await listen(server);
184
+
185
+ const emailCapability = email({ fromAddress: "no@reply.test", fromName: "Live E2E", baseUrl: origin });
186
+ const wiring: AuthWiring = {
187
+ config: AuthConfig.parse({
188
+ baseURL: origin,
189
+ basePath: options.basePath,
190
+ trustedOrigins: [origin],
191
+ google: { enabled: Boolean(options.google) },
192
+ }),
193
+ enqueueEmail: emailCapability.enqueue,
194
+ turnstile: options.turnstile ? { mode: options.turnstile.mode } : undefined,
195
+ };
196
+
197
+ const app = buildApp(wiring, events);
198
+ server.on("request", (request, response) => {
199
+ void serve(app, env, origin, request, response);
200
+ });
201
+
202
+ return {
203
+ origin,
204
+ basePath: options.basePath,
205
+ callbackUrl: (provider) => `${origin}${options.basePath}/callback/${provider}`,
206
+ events,
207
+ enqueued: async () => {
208
+ const rows = await db.prepare("select template, to_address from pithy_email_jobs order by rowid").all();
209
+ return (rows.results as { template: string; to_address: string }[]).map((row) => ({
210
+ template: row.template,
211
+ to: row.to_address,
212
+ }));
213
+ },
214
+ mailedOtp: async (to) => {
215
+ const row = await db
216
+ .prepare(
217
+ "select payload from pithy_email_jobs where to_address = ? and template = 'otp' order by rowid desc limit 1",
218
+ )
219
+ .bind(to)
220
+ .first<{ payload: string }>();
221
+ if (!row) return null;
222
+ const parsed: unknown = JSON.parse(row.payload);
223
+ const code = typeof parsed === "object" && parsed !== null ? (parsed as { code?: unknown }).code : undefined;
224
+ return typeof code === "string" ? code : null;
225
+ },
226
+ close: async () => {
227
+ // Deliberately **not** `resetSharedSecrets()`. That configuration is module-scoped, as it is in a
228
+ // worker isolate, so a suite running two apps at once — one pinned at `/auth`, one at `/identity`
229
+ // — would have the second's teardown unconfigure the first, and every later request to it would
230
+ // answer 500. The configuration holds a registry and no values; each app resolves its own secrets
231
+ // from its own `SECRETS` D1 through its own env, so sharing it costs nothing. `resetLiveSecrets`
232
+ // is there for a suite that wants the module clean at the end.
233
+ await new Promise<void>((resolve) => server.close(() => resolve()));
234
+ await miniflare.dispose();
235
+ },
236
+ };
237
+ }
238
+
239
+ /** Run auth's and email's real migrations against a fresh database. */
240
+ async function migrate(db: D1Database): Promise<void> {
241
+ for (const table of [...TABLES, "pithy_migrations", "pithy_migrations_lock"]) {
242
+ await db.prepare(`drop table if exists ${table}`).run();
243
+ }
244
+ const provider = createMigrationRegistry([
245
+ { database: "app", namespace: "auth", order: AUTH_MIGRATION_ORDER, migrations: { "0001_init": auth_0001_init } },
246
+ { database: "app", namespace: "email", order: 200, migrations: { "0001_init": email_0001_init } },
247
+ ]).app;
248
+ if (!provider) throw new Error('expected a migration provider for database "app"');
249
+ await runMigrations(db, provider);
250
+ }
251
+
252
+ /**
253
+ * The capability's middleware order, mirrored: same-origin publication, the tier-1 limiter, session
254
+ * resolution, then the routes.
255
+ *
256
+ * Mirrored rather than obtained from `auth().compose()` because composing the capability needs a
257
+ * `pithy.config.ts`, a project on disk and a CLI to resolve it. The order here is the order
258
+ * `capability.ts` registers, and `routeContract.test.ts` is what holds that claim honest.
259
+ */
260
+ function buildApp(wiring: AuthWiring, events: AuditEventInput[]): Hono<PithyHonoEnv> {
261
+ const app = new Hono<PithyHonoEnv>();
262
+ app.onError(pithyErrorHandler);
263
+ app.use("*", async (c, next) => {
264
+ if (c.get("emit") === undefined) {
265
+ c.set("emit", async (event) => {
266
+ events.push(event);
267
+ });
268
+ }
269
+ if (c.get("auth") === undefined) c.set("auth", null);
270
+ await next();
271
+ });
272
+ publishSameOrigin(wiring)(app);
273
+ app.use(`${wiring.config.basePath}/*`, createRateLimitMiddleware(wiring.config.rateLimiterBinding));
274
+ createSessionMiddleware(wiring)(app);
275
+ createAuthRoutes(wiring)(app);
276
+ return app;
277
+ }
278
+
279
+ /** Listen on an OS-assigned port and answer with the origin that names it. */
280
+ function listen(server: Server): Promise<string> {
281
+ return new Promise((resolve, reject) => {
282
+ server.once("error", reject);
283
+ server.listen(0, "127.0.0.1", () => {
284
+ const address = server.address() as AddressInfo;
285
+ resolve(`http://localhost:${address.port}`);
286
+ });
287
+ });
288
+ }
289
+
290
+ /** Hand one Node request to the Hono app and write back what it answered. */
291
+ async function serve(
292
+ app: Hono<PithyHonoEnv>,
293
+ env: Record<string, unknown>,
294
+ origin: string,
295
+ request: IncomingMessage,
296
+ response: ServerResponse,
297
+ ): Promise<void> {
298
+ try {
299
+ const answer = await app.fetch(await toRequest(request, origin), env);
300
+ // `getSetCookie()` and not `headers.forEach`: a response carrying two cookies has two headers of
301
+ // the same name, and folding them into one comma-joined value is how a session cookie arrives
302
+ // unparseable at a client that would otherwise have accepted it.
303
+ const headers: [string, string][] = [];
304
+ for (const [key, value] of answer.headers) if (key.toLowerCase() !== "set-cookie") headers.push([key, value]);
305
+ for (const cookie of answer.headers.getSetCookie()) headers.push(["set-cookie", cookie]);
306
+ response.writeHead(answer.status, headers);
307
+ response.end(answer.body ? Buffer.from(await answer.arrayBuffer()) : undefined);
308
+ } catch (cause) {
309
+ // A throw here is a defect in the harness, never a result. Answering 500 silently would let a suite
310
+ // read a harness crash as the app denying a request, which is the exact confusion these tests exist
311
+ // to remove — so the reason goes on the wire.
312
+ response.writeHead(500, { "content-type": "text/plain" });
313
+ response.end(`live harness failed: ${cause instanceof Error ? cause.message : String(cause)}`);
314
+ }
315
+ }
316
+
317
+ /** A Node request as a Web `Request`, body and all. */
318
+ async function toRequest(request: IncomingMessage, origin: string): Promise<Request> {
319
+ const method = request.method ?? "GET";
320
+ const headers = new Headers();
321
+ for (const [key, value] of Object.entries(request.headers)) {
322
+ if (value === undefined) continue;
323
+ for (const one of Array.isArray(value) ? value : [value]) headers.append(key, one);
324
+ }
325
+ const hasBody = method !== "GET" && method !== "HEAD";
326
+ const body = hasBody ? await readBody(request) : undefined;
327
+ return new Request(new URL(request.url ?? "/", origin), { method, headers, body });
328
+ }
329
+
330
+ /** Collect a request body. */
331
+ function readBody(request: IncomingMessage): Promise<Buffer> {
332
+ return new Promise((resolve, reject) => {
333
+ const chunks: Buffer[] = [];
334
+ request.on("data", (chunk: Buffer) => chunks.push(chunk));
335
+ request.on("end", () => resolve(Buffer.concat(chunks)));
336
+ request.on("error", reject);
337
+ });
338
+ }