@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,501 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The browser half of `@pithy-sh/auth`: the one place that knows how a browser program makes a
6
+ * same-origin, cookie-bearing request to this Worker, and the calls made through it — the six a
7
+ * scaffolded screen makes, and {@link updateUser}, which an adopter's own screen calls.
8
+ *
9
+ * **This module lives in the package for the reason `@pithy-sh/payments`'s counterpart does, only more
10
+ * so.** `pithy ui add` writes the auth screens once and may never rewrite them: they are copied into
11
+ * the adopter's repository and become the adopter's files. Every request written into one of those
12
+ * files is frozen there at the moment `pithy init` ran, and a later fix to the base path, the cookie
13
+ * mode or the failure directions reaches none of them. Six such requests existed — `/get-session`,
14
+ * `/sign-out`, the two OTP routes, the magic link, and the social handoff — each spelling out the same
15
+ * transport by hand. They call this now, and this upgrades with a minor release (#370).
16
+ *
17
+ * **Cookie/session, same origin, and that is enforced rather than assumed.** Every request carries
18
+ * `credentials: "include"` and nothing else: no token in `localStorage` or `sessionStorage`, no
19
+ * `Authorization` header, no refresh rotation. The SPA and its Worker share an origin, so the session
20
+ * rides an httpOnly cookie JavaScript cannot read. Bearer is the mobile path — the same routes serve
21
+ * it, documented rather than scaffolded.
22
+ *
23
+ * **CSRF travels with the cookie mode, and half of it is on this side of the wire.** The server's
24
+ * `requireSameOrigin()` refuses a cookie-authenticated mutating request whose `Origin` does not match;
25
+ * the browser only attaches that header to a request it considers this page's own. So the client half
26
+ * of the rule is that the request never leaves the origin — {@link callAuth} builds its URL from a base
27
+ * path and a relative path and **refuses anything that is not a rooted same-origin path**, rather than
28
+ * handing an ambient session to whatever a mis-set `basePath` names. A caller cannot forget the check,
29
+ * because a caller no longer writes the request.
30
+ *
31
+ * **Nothing here throws.** Not a stylistic choice: this module imports nothing, so `PithyError` is not
32
+ * in reach, and a bare `throw new Error` is exactly what this kit forbids. An unreachable Worker, a
33
+ * proxy's HTML error page, a 500 — each becomes a renderable {@link AuthFailure} on an
34
+ * {@link AuthResult}, and every caller answers one. The Worker is still the security boundary; nothing
35
+ * on this side of the wire protects anything, it only decides what a screen shows.
36
+ *
37
+ * **No absolute URL literals, and no schema library.** Paths are relative, so the calls follow whatever
38
+ * origin the bundle is served from. Answers are narrowed by hand-written `is…(value: unknown): value is
39
+ * T` guards rather than Zod: this file compiles into an adopter's browser bundle, and it must not drag
40
+ * the Worker's schema graph in behind it. `sameOrigin.test.ts` holds the import list to empty, so the
41
+ * property is structural rather than a habit.
42
+ */
43
+
44
+ /** Where the auth handler mounts by default — the same default `AuthConfig.basePath` carries. */
45
+ export const AUTH_BASE_PATH = "/auth";
46
+
47
+ /**
48
+ * The slice of `fetch` this module uses, declared structurally.
49
+ *
50
+ * Not `typeof fetch`: the package compiles against `@cloudflare/workers-types`, whose `RequestInit` has
51
+ * no `credentials` — and `credentials: "include"` is the entire cookie story. Declaring the shape keeps
52
+ * one signature true in a Worker-typed program, in a browser, and in a test that injects a stub.
53
+ */
54
+ export interface AuthRequestInit {
55
+ /** The HTTP method. Absent means GET. */
56
+ method?: string;
57
+ /** Request headers. */
58
+ headers?: Record<string, string>;
59
+ /** The JSON body, already serialized. */
60
+ body?: string;
61
+ /** Cookie policy. Always `include` here — same-origin, httpOnly session cookie. */
62
+ credentials?: "include" | "same-origin" | "omit";
63
+ }
64
+
65
+ /** The slice of `Response` this module reads. */
66
+ export interface AuthResponse {
67
+ /** Whether the status was 2xx. */
68
+ ok: boolean;
69
+ /** The HTTP status. */
70
+ status: number;
71
+ /** The parsed body, or a rejection when it was not JSON. */
72
+ json(): Promise<unknown>;
73
+ }
74
+
75
+ /** A fetch this module can call. */
76
+ export type AuthFetch = (input: string, init?: AuthRequestInit) => Promise<AuthResponse>;
77
+
78
+ /**
79
+ * The browser globals this module reaches for, as an injectable seam.
80
+ *
81
+ * Reached through an object rather than `window` because the package compiles in a program with no DOM
82
+ * lib, alongside the Worker code. Injecting it is also what lets a test prove the no-browser path
83
+ * without a DOM.
84
+ */
85
+ export interface AuthGlobal {
86
+ /** The browser's fetch. */
87
+ fetch?: AuthFetch;
88
+ }
89
+
90
+ /**
91
+ * What a humanity check contributes to a gated request: the body it wants sent, and any headers.
92
+ *
93
+ * Exactly the shape `@pithy-sh/turnstile`'s scaffolded helper already returns, so a screen hands its
94
+ * check straight in. Absent means no check is composed, which is the common case.
95
+ */
96
+ export type AuthGate = (body: Record<string, unknown>) => {
97
+ /** The body to send, with whatever field the check wanted added. */
98
+ body: Record<string, unknown>;
99
+ /** The headers to send, with whatever header the check wanted added. */
100
+ headers: Record<string, string>;
101
+ };
102
+
103
+ /** What every call takes: where the routes are, and the seams a test replaces. */
104
+ export interface AuthClientOptions {
105
+ /** Where the auth routes mount. Defaults to {@link AUTH_BASE_PATH}. */
106
+ basePath?: string;
107
+ /** The fetch to use. Defaults to the one on {@link AuthClientOptions.global}. */
108
+ fetch?: AuthFetch;
109
+ /** The global object the default fetch comes off. Defaults to `globalThis`. */
110
+ global?: AuthGlobal;
111
+ /** The humanity check to satisfy, on the two routes that are gated by one. */
112
+ gate?: AuthGate;
113
+ }
114
+
115
+ /** A refusal a screen can render: the namespaced code, the public message, and what to do next. */
116
+ export interface AuthFailure {
117
+ /** The namespaced code — `auth/invalid_token`, or a `client/*` sentinel this module minted. */
118
+ code: string;
119
+ /** The public message. The server's `detail` never crosses the HTTP codec, so this is all there is. */
120
+ message: string;
121
+ /** What to do next, when the server offered one. */
122
+ action: string | null;
123
+ }
124
+
125
+ /** Either the value, or a failure to render. Never a throw. */
126
+ export type AuthResult<T> = { ok: true; value: T } | { ok: false; failure: AuthFailure };
127
+
128
+ /** The worker could not be reached at all. Offline, or a DNS failure, or no fetch in this program. */
129
+ export const AUTH_UNREACHABLE: AuthFailure = {
130
+ code: "client/unreachable",
131
+ message: "We couldn't reach the server.",
132
+ action: "Check your connection, then try again.",
133
+ };
134
+
135
+ /** The worker answered with something this client cannot read. A proxy's HTML page, or a shape change. */
136
+ export const AUTH_UNREADABLE: AuthFailure = {
137
+ code: "client/unreadable",
138
+ message: "The server answered with something we couldn't read.",
139
+ action: "Try again. If it keeps happening, the app and the backend are out of step.",
140
+ };
141
+
142
+ /**
143
+ * The request would have left this origin, so it was never sent.
144
+ *
145
+ * The only way to reach it is a `basePath` or a path that is not a rooted same-origin path — an
146
+ * absolute URL, a protocol-relative `//host`, a backslash form a URL parser reads as one. Sending it
147
+ * would attach an ambient session to a request this page is not entitled to make, which is the thing
148
+ * the cookie mode exists to keep from happening.
149
+ */
150
+ export const AUTH_CROSS_ORIGIN: AuthFailure = {
151
+ code: "client/cross_origin",
152
+ message: "That request would have left this site, so it wasn't sent.",
153
+ action: "Check the auth `basePath` in pithy.config.ts — it is a path on this worker, not a URL.",
154
+ };
155
+
156
+ /** Whether a value is a plain record — the first step of every guard below. */
157
+ function isRecord(value: unknown): value is Record<string, unknown> {
158
+ return typeof value === "object" && value !== null && !Array.isArray(value);
159
+ }
160
+
161
+ /**
162
+ * Whether a composed path stays on this origin.
163
+ *
164
+ * A rooted path and nothing that resolves elsewhere. `//host` and `/\host` are the two forms a URL
165
+ * parser reads as an authority rather than as a path, and a browser follows both — which is why the
166
+ * check is on the second character rather than on a scheme it would never find after a leading `/`.
167
+ */
168
+ function isSameOriginPath(path: string): boolean {
169
+ if (!path.startsWith("/")) return false;
170
+ const second = path.charAt(1);
171
+ return second !== "/" && second !== "\\";
172
+ }
173
+
174
+ /**
175
+ * A failure read off either shape this Worker answers with, or the generic one when the body is neither.
176
+ *
177
+ * **Two shapes, because two packages own routes here and only one of them is ours.** Everything this
178
+ * capability writes answers `{ error: { code, message, action } }` through `pithyErrorHandler`. Every
179
+ * route Better Auth owns — the one-time code, the magic link, sign-out, `get-session`, the social
180
+ * handoff, `update-user` — answers its own flat `{ message, code }`, because better-call renders an
181
+ * endpoint's `APIError` into a Response inside `instance.handler` and nothing on our side of that ever
182
+ * sees it (#449).
183
+ *
184
+ * Reading only the envelope meant every one of those refusals arrived as {@link AUTH_UNREADABLE}, so a
185
+ * reader who mistyped their one-time code was told the app was broken rather than that the code was
186
+ * wrong — in a screen `pithy ui add` copies into an adopter's repository, where it can never be fixed.
187
+ *
188
+ * **The flat shape is read rather than rewritten on the server, deliberately.** `packages/auth/README.md`
189
+ * documents `createAuthClient` from `better-auth/client` as a first-class client surface (#271), and
190
+ * `@better-fetch/fetch` builds its error as `{ ...parsedBody, status }` — so an adopter on that path
191
+ * reads `error.code === "INVALID_OTP"` today. Re-homing the body into our envelope would make that
192
+ * `undefined` for every one of them. The wire is Better Auth's contract; this side learns to read it.
193
+ *
194
+ * So `code` arrives in whichever vocabulary produced it, and the two are told apart by their shape:
195
+ * `auth/invalid_token` is ours, `INVALID_OTP` is Better Auth's. A screen matching on a code should
196
+ * expect the one belonging to the route it called.
197
+ */
198
+ function readFailure(body: unknown): AuthFailure {
199
+ if (!isRecord(body)) return AUTH_UNREADABLE;
200
+
201
+ // The kit's envelope, which every route this capability writes answers with.
202
+ if (isRecord(body.error)) {
203
+ const { code, message, action } = body.error;
204
+ if (typeof code !== "string" || typeof message !== "string") return AUTH_UNREADABLE;
205
+ return { code, message, action: typeof action === "string" ? action : null };
206
+ }
207
+
208
+ // Better Auth's own, which every route it owns answers with.
209
+ const { code, message } = body;
210
+ if (typeof code !== "string" || typeof message !== "string") return AUTH_UNREADABLE;
211
+ return { code, message, action: null };
212
+ }
213
+
214
+ /**
215
+ * One call: same-origin, cookie-carrying, never throwing. **The only producer of that request.**
216
+ *
217
+ * The body is read before the status is judged, because a refusal's body is the failure a screen
218
+ * renders. A body that will not parse is the generic failure rather than a crash — a corporate proxy's
219
+ * HTML page reaches a browser far more often than anyone expects.
220
+ *
221
+ * **Exported, and that is the point of it.** `@pithy-sh/auth` shipped no browser primitive at all, so
222
+ * six scaffolded screens each wrote the transport out by hand — and those screens are copied into an
223
+ * adopter's repository, where Pithy can never fix them. Every browser program that asks this Worker an
224
+ * auth question calls this, and `sameOrigin.test.ts` fails the build on any module that grows its own
225
+ * (#370).
226
+ *
227
+ * **Zod-free, like everything on this side of the wire.** The answer is narrowed by the hand-written
228
+ * `guard` a caller passes, never by a schema: this compiles into an adopter's browser bundle, and
229
+ * dragging the Worker's schema graph in behind it would break their build rather than ours.
230
+ */
231
+ export async function callAuth<T>(
232
+ path: string,
233
+ init: AuthRequestInit,
234
+ options: AuthClientOptions | undefined,
235
+ guard: (value: unknown) => value is T,
236
+ ): Promise<AuthResult<T>> {
237
+ const base = options?.basePath ?? AUTH_BASE_PATH;
238
+ const url = `${base}${path}`;
239
+ // Before anything else, and before any fetch: the cookie mode below is only safe because this is a
240
+ // request to this Worker. `base` is adopter config; `path` is ours.
241
+ if (!isSameOriginPath(url)) return { ok: false, failure: AUTH_CROSS_ORIGIN };
242
+
243
+ const fetcher = options?.fetch ?? (options?.global ?? (globalThis as AuthGlobal)).fetch;
244
+ if (!fetcher) return { ok: false, failure: AUTH_UNREACHABLE };
245
+
246
+ let response: AuthResponse;
247
+ try {
248
+ response = await fetcher(url, { ...init, credentials: "include" });
249
+ } catch {
250
+ return { ok: false, failure: AUTH_UNREACHABLE };
251
+ }
252
+
253
+ let body: unknown;
254
+ try {
255
+ body = await response.json();
256
+ } catch {
257
+ // Success or refusal, an unparseable body says the same thing: whatever answered was not this Worker.
258
+ return { ok: false, failure: AUTH_UNREADABLE };
259
+ }
260
+
261
+ if (!response.ok) return { ok: false, failure: readFailure(body) };
262
+ return guard(body) ? { ok: true, value: body } : { ok: false, failure: AUTH_UNREADABLE };
263
+ }
264
+
265
+ /**
266
+ * A JSON POST, with the humanity check's contribution already folded in.
267
+ *
268
+ * The gate is applied here rather than at each call site for the reason the cookie mode is: a screen
269
+ * that forgets it sends an ungated request to a gated route and gets a 403 it cannot explain.
270
+ */
271
+ function jsonPost(body: Record<string, unknown>, options: AuthClientOptions | undefined): AuthRequestInit {
272
+ const gated = options?.gate ? options.gate(body) : { body, headers: {} };
273
+ return {
274
+ method: "POST",
275
+ headers: { "content-type": "application/json", ...gated.headers },
276
+ body: JSON.stringify(gated.body),
277
+ };
278
+ }
279
+
280
+ /** The signed-in user, as far as a browser is concerned. */
281
+ export interface AuthUser {
282
+ /** The user's id. */
283
+ id: string;
284
+ /** Their email address. */
285
+ email: string;
286
+ /** Their display name, when they have one. */
287
+ name?: string;
288
+ }
289
+
290
+ /** A live session, or `null` when nobody is signed in. */
291
+ export type AuthSession = { user: AuthUser } | null;
292
+
293
+ /**
294
+ * Whether a value is a session envelope, or the explicit "nobody" the route answers with.
295
+ *
296
+ * `null` is a valid answer and a distinct one from a failure — signed out is not the same as unread —
297
+ * so it is narrowed here rather than collapsed into one by a caller.
298
+ */
299
+ function isSession(value: unknown): value is AuthSession {
300
+ if (value === null) return true;
301
+ if (!isRecord(value)) return false;
302
+ const user = value.user;
303
+ return isRecord(user) && typeof user.id === "string" && typeof user.email === "string";
304
+ }
305
+
306
+ /** Whether a value is any JSON object. The floor for a route whose success body a screen does not read. */
307
+ function isObject(value: unknown): value is Record<string, unknown> {
308
+ return isRecord(value);
309
+ }
310
+
311
+ /**
312
+ * Who is signed in, or `null`.
313
+ *
314
+ * **A failure is not a signed-out session**, and the return type is what keeps the two apart. A screen
315
+ * that renders "signed out" from an unreachable Worker offers a sign-in form to somebody who is
316
+ * already signed in, and the sign-in it starts replaces a live session with a new one. A caller that
317
+ * wants to treat them alike writes `result.ok ? result.value : null` — one line, reading as the
318
+ * decision it is.
319
+ */
320
+ export function getSession(options?: AuthClientOptions): Promise<AuthResult<AuthSession>> {
321
+ return callAuth("/get-session", {}, options, isSession);
322
+ }
323
+
324
+ /**
325
+ * End the session server-side.
326
+ *
327
+ * **The content type is not decoration, and the scaffolded screen was missing it.** The route refuses a
328
+ * POST without `application/json` with a 415 — so every app scaffolded so far has been signing nobody
329
+ * out. It went unnoticed because the screen ignored the answer and navigated to `/sign-in` regardless,
330
+ * which looks exactly like a successful sign-out until the back button restores a live session.
331
+ * `clientRoundTrip.test.ts` is what found it, and is what would find it again: a request written into a
332
+ * file Pithy may never rewrite is a request nobody ever tested against the route it names (#370).
333
+ *
334
+ * The body is an empty object rather than nothing, because a declared JSON content type with no body is
335
+ * the other half of the same 415.
336
+ */
337
+ export function signOut(options?: AuthClientOptions): Promise<AuthResult<Record<string, unknown>>> {
338
+ // Not `jsonPost`: that folds in the humanity check, and sign-out is not a gated route.
339
+ return callAuth(
340
+ "/sign-out",
341
+ { method: "POST", headers: { "content-type": "application/json" }, body: "{}" },
342
+ options,
343
+ isObject,
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Store something on the signed-in reader's own account. Today that is their language, and only that.
349
+ *
350
+ * **The rule it exists to serve is `docs/I18N.md`'s: `pithy_auth_users.locale` is the one home for a
351
+ * person's language — do not put language in your own preferences table.** A preferences table is the
352
+ * right home for time zone, date format, a 24-hour clock; none of those is language. Two homes for one
353
+ * fact is not a duplicate row, it is a magic-link email in the wrong language with nothing anywhere
354
+ * failing to say so, because the send Workflow reads the auth column and the settings pane reads the
355
+ * other one. This is the write that keeps the home single, and `useNegotiatedLocale`'s `persist` seam
356
+ * (`@pithy-sh/i18n`) is the caller it was written for: `persist: (next) => { void updateUser({ locale: next }); }`.
357
+ * The discard is deliberate: `persist` returns `void | Promise<void>` and this resolves to an
358
+ * `AuthResult`, so returning the call straight does not typecheck — and nothing is lost by dropping
359
+ * it, because nothing here throws.
360
+ *
361
+ * **`locale` is the only field it takes, because it is the only kit user field declared `input: true`**
362
+ * (`../data/kitFields`). Every other column Pithy adds is server-set — a client that could name its own
363
+ * device or token family could name somebody else's — while a language is the reader's own preference
364
+ * and no admin route is a thing a reader has. What makes taking it from a client safe is the `Locale`
365
+ * validator on the field rather than this signature: Better Auth runs it before the write, so the same
366
+ * schema that guards every read guards the write one hop earlier, and a megabyte of junk is a 400 here
367
+ * instead of a row that throws for every operator who later lists users.
368
+ *
369
+ * **`null` is accepted deliberately, and it is not the empty string.** Null means *this reader has not
370
+ * chosen*, which is what makes the server fall back to `Accept-Language`; taking a language choice back
371
+ * is an ordinary thing a reader does, and a call that refused it would answer 400 for the one state the
372
+ * schema calls ordinary.
373
+ *
374
+ * **Not `jsonPost`, for the reason {@link signOut} is not.** That folds in the humanity check, and this
375
+ * is not a gated route: `createAuthRoutes` stacks the Turnstile gate on `/sign-in/magic-link` and
376
+ * `/email-otp/send-verification-otp` and on nothing else, so a token folded in here would be an
377
+ * unexpected field on a request Better Auth validates itself. The content type and the `{}` body are
378
+ * the same 415 story as sign-out — a declared JSON type with no body is refused just as an undeclared
379
+ * one is.
380
+ *
381
+ * **And it is kit code rather than an adopter's, because both alternatives are worse than this
382
+ * function.** Standing up a second Better Auth client with `inferAdditionalFields` duplicates
383
+ * configuration `@pithy-sh/auth` already owns. Reaching for {@link callAuth} instead means an adopter
384
+ * spelling out the path, the content type and the guard themselves, one deep import below the surface
385
+ * every other auth call in their app goes through — written into a file Pithy can never fix (#446).
386
+ *
387
+ * **The answer carries no user, whatever the route's own OpenAPI block advertises.** Better Auth's
388
+ * `/update-user` ends `ctx.json({ status: true })`, so `value` is `{ status: true }` and
389
+ * `value.locale` is `undefined`. Nothing is lost by that — a screen already has the value it just
390
+ * wrote — but do not reach into it for the row.
391
+ *
392
+ * **A refusal is a failure to render and never a throw, and it is `client/unreadable` more often than
393
+ * it looks.** This is Better Auth's own route, and Better Auth turns its own `APIError`s into
394
+ * Responses inside `instance.handler`, so they never reach the `apiErrorToPithy` re-homing in
395
+ * `../http/routes`. The body stays Better Auth's flat `{ message, code }` rather than the kit's error
396
+ * envelope, and {@link AUTH_UNREADABLE} is what `readFailure` makes of anything that is not that
397
+ * envelope: a signed-out reader's 401 reads as `client/unreadable`, and so does a tag the `Locale`
398
+ * validator rejected. Which is enough for the caller this exists for — `useNegotiatedLocale` drops a
399
+ * failed preference write either way, because the reader already has the language they picked.
400
+ */
401
+ export function updateUser(
402
+ fields: { locale?: string | null },
403
+ options?: AuthClientOptions,
404
+ ): Promise<AuthResult<Record<string, unknown>>> {
405
+ return callAuth(
406
+ "/update-user",
407
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...fields }) },
408
+ options,
409
+ isObject,
410
+ );
411
+ }
412
+
413
+ /** Sign in with a one-time code the user typed. The answer is the session it created. */
414
+ export function signInWithOtp(
415
+ input: { email: string; otp: string },
416
+ options?: AuthClientOptions,
417
+ ): Promise<AuthResult<Record<string, unknown>>> {
418
+ return callAuth("/sign-in/email-otp", jsonPost({ ...input }, options), options, isObject);
419
+ }
420
+
421
+ /**
422
+ * Mail a one-time code.
423
+ *
424
+ * Gated by the humanity check when one is composed — `options.gate` puts the token where the middleware
425
+ * reads it, and a screen cannot forget to.
426
+ */
427
+ export function sendOtp(
428
+ input: { email: string; type: "sign-in" },
429
+ options?: AuthClientOptions,
430
+ ): Promise<AuthResult<Record<string, unknown>>> {
431
+ return callAuth("/email-otp/send-verification-otp", jsonPost({ ...input }, options), options, isObject);
432
+ }
433
+
434
+ /**
435
+ * Mail a magic link.
436
+ *
437
+ * `callbackURL` is the adopter's own screen, so it is theirs to name. Gated the same way as
438
+ * {@link sendOtp}.
439
+ */
440
+ export function sendMagicLink(
441
+ input: { email: string; callbackURL: string },
442
+ options?: AuthClientOptions,
443
+ ): Promise<AuthResult<Record<string, unknown>>> {
444
+ return callAuth("/sign-in/magic-link", jsonPost({ ...input }, options), options, isObject);
445
+ }
446
+
447
+ /**
448
+ * What asking for a social authorization URL produced. Three outcomes, and a screen must tell them apart.
449
+ *
450
+ * `authorize` is a URL to leave for. `unconfigured` is the provider being switched on in config with a
451
+ * blank credential behind it — the server answers, and the URL it mints names no client. `refused` is
452
+ * our own Worker not answering. Different faults, different copy, and collapsing them would tell a user
453
+ * to try again when the fix is a secret nobody set.
454
+ */
455
+ export type SocialSignIn =
456
+ | { kind: "authorize"; url: string }
457
+ | { kind: "unconfigured" }
458
+ | { kind: "refused"; failure: AuthFailure };
459
+
460
+ /**
461
+ * Whether an answer is an authorization URL a browser may be sent to, and the two reasons it might not be.
462
+ *
463
+ * **The scheme check.** `window.location.href = url` with a `javascript:` URL executes that script in
464
+ * this page, so a response body is not something to hand straight to a navigator however trusted its
465
+ * origin.
466
+ *
467
+ * **The `client_id` check.** An OAuth 2.0 authorization request must carry one, so an authorization URL
468
+ * naming no client is a provider with a blank credential behind it. The client projection carries
469
+ * booleans and never credentials, so a browser cannot know that in advance — refusing the *response* is
470
+ * what turns a blank credential into a sentence on the screen rather than a bounce to Google's own
471
+ * error page.
472
+ */
473
+ function isAuthorization(value: unknown): value is { url: string } {
474
+ if (!isRecord(value) || typeof value.url !== "string") return false;
475
+ try {
476
+ const parsed = new URL(value.url);
477
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
478
+ return (parsed.searchParams.get("client_id") ?? "") !== "";
479
+ } catch {
480
+ return false;
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Ask for a provider's authorization URL.
486
+ *
487
+ * **No humanity check, deliberately**: the redirect carries no token to check and the provider runs its
488
+ * own bot defense, which is what `@pithy-sh/auth` already assumes when it stacks the check on the
489
+ * magic-link route alone. Gating it would only stop people signing in.
490
+ */
491
+ export async function startSocialSignIn(
492
+ input: { provider: string; callbackURL: string },
493
+ options?: AuthClientOptions,
494
+ ): Promise<SocialSignIn> {
495
+ // The gate is dropped rather than passed through, so a screen holding one client options object for
496
+ // the whole page cannot accidentally gate this route.
497
+ const ungated: AuthClientOptions | undefined = options && { ...options, gate: undefined };
498
+ const result = await callAuth("/sign-in/social", jsonPost({ ...input }, undefined), ungated, isObject);
499
+ if (!result.ok) return { kind: "refused", failure: result.failure };
500
+ return isAuthorization(result.value) ? { kind: "authorize", url: result.value.url } : { kind: "unconfigured" };
501
+ }
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * What a browser may know about this project's auth — the shape of `virtual:pithy/auth`.
6
+ *
7
+ * **This declaration is the contract, and the projection is checked against it.** It is written here
8
+ * rather than inferred from the closure that builds it, and that is the whole point: an inferred type
9
+ * follows whatever the producer last happened to say, so a projection that dropped `otpLength`, or that
10
+ * started projecting `baseURL` because one screen wanted it, would take the type with it and nothing
11
+ * would go red. Declared, both are a compile error at the `client:` that wrote them, and widening what
12
+ * every adopter's bundle carries becomes a decision made here, on purpose.
13
+ *
14
+ * The `enabled: false` branch is not this capability's to produce. `resolveClientProjection` answers it
15
+ * for a capability nobody composed, and a front end is written against the capabilities it *may* have.
16
+ * It is declared here because it is what a browser reads, and the browser cannot tell the two apart.
17
+ *
18
+ * **This is the only statement of the shape.** `@pithy-sh/ui-react`'s `templates/client-env.d.ts` — the
19
+ * ambient declaration `pithy ui add react` copies into an adopter's Worker — is generated from this type
20
+ * by `@pithy-sh/vite`'s `clientEnvDeclaration.ts` (#398). The unions and the per-field doc comments below
21
+ * are emitted verbatim, so what is written here is what a screen author reads.
22
+ */
23
+ export type AuthClientProjection =
24
+ | {
25
+ /** Auth is not composed on this worker. A screen branches rather than rendering a sign-in form. */
26
+ enabled: false;
27
+ }
28
+ | {
29
+ /** Auth is composed on this worker. */
30
+ enabled: true;
31
+ /** The path the auth handler mounts under, e.g. `/auth`. */
32
+ basePath: string;
33
+ /**
34
+ * Which social providers are switched on in pithy.config.ts. Credentials never reach the client —
35
+ * they are not in the config this projection can see, they are in the secrets store.
36
+ *
37
+ * Nested, so a screen iterates the set rather than naming four booleans. A fifth provider is one
38
+ * key here — and until it is written here, projecting it is a compile error, not a surprise in
39
+ * somebody's bundle.
40
+ */
41
+ providers: {
42
+ /** Whether Sign in with Google is offered. */
43
+ google: boolean;
44
+ /** Whether Sign in with Apple is offered. */
45
+ apple: boolean;
46
+ /** Whether Sign in with Facebook is offered. */
47
+ facebook: boolean;
48
+ /** Whether Sign in with GitHub is offered. */
49
+ github: boolean;
50
+ };
51
+ /** How many digits an email OTP carries. */
52
+ otpLength: number;
53
+ /** Whether signing in may provision a new user. Drives the sign-up copy. */
54
+ signUpEnabled: boolean;
55
+ };
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
7
+ // config in `vitest.workers.config.ts`: the app `DB` database auth's tables live in, and the dedicated
8
+ // `SECRETS` database plus master key every auth secret is read through.
9
+ declare namespace Cloudflare {
10
+ interface Env {
11
+ DB: D1Database;
12
+ SECRETS: D1Database;
13
+ /** The master-key config as a string (the `.dev.vars` shape), set in `vitest.workers.config.ts`. */
14
+ SECRETS_ENCRYPTION_KEYS: string;
15
+ }
16
+ }