@somewhatintelligent/guestlist 0.0.1-beta.1783705886.297c5be

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@somewhatintelligent/guestlist",
3
+ "version": "0.0.1-beta.1783705886.297c5be",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/apostolos-geyer/platform.git",
12
+ "directory": "packages/guestlist"
13
+ },
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./client": "./src/client/index.ts",
17
+ "./client/react": "./src/client/react.ts",
18
+ "./schema": "./src/schema.ts"
19
+ },
20
+ "files": [
21
+ "src"
22
+ ],
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "vitest run"
26
+ },
27
+ "dependencies": {
28
+ "@better-auth/api-key": "^1.6.0",
29
+ "@better-auth/core": "^1.6.9",
30
+ "@better-auth/oauth-provider": "^1.6.0",
31
+ "@better-auth/passkey": "^1.6.0",
32
+ "@somewhatintelligent/auth": "^0.0.0",
33
+ "@somewhatintelligent/kit": "^0.0.0",
34
+ "better-auth": "^1.6.0",
35
+ "cookie-es": "^3.1.1",
36
+ "drizzle-orm": "^0.45.2",
37
+ "ulidx": "^2.4.1"
38
+ },
39
+ "devDependencies": {
40
+ "@cloudflare/workers-types": "^4",
41
+ "@types/react": "^19.2.14",
42
+ "react": "19.2.4",
43
+ "typescript": "5.9.2",
44
+ "vitest": "^4.1.10"
45
+ },
46
+ "peerDependencies": {
47
+ "react": "19.2.4"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "react": {
51
+ "optional": true
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ defineEmailHandlers,
4
+ toPlatformAuthSendEmail,
5
+ type GuestlistEmail,
6
+ } from "../email";
7
+ import { originPattern } from "../cors";
8
+
9
+ describe("toPlatformAuthSendEmail", () => {
10
+ test("maps every better-auth callback onto the event union", async () => {
11
+ const seen: GuestlistEmail[] = [];
12
+ const sendEmail = async (msg: GuestlistEmail) => {
13
+ seen.push(msg);
14
+ };
15
+ const ba = toPlatformAuthSendEmail(sendEmail);
16
+
17
+ await ba.verification({ user: { email: "a@x.com", name: "A" }, url: "u1" });
18
+ await ba.resetPassword({ user: { email: "a@x.com", name: "A" }, url: "u2" });
19
+ await ba.changeEmail({ user: { email: "a@x.com", name: "A" }, newEmail: "b@x.com", url: "u3" });
20
+ await ba.deleteAccount({ user: { email: "a@x.com", name: "A" }, url: "u4" });
21
+ await ba.magicLink({ email: "a@x.com", url: "u5" });
22
+ await ba.invitation({
23
+ invitationId: "inv1",
24
+ email: "c@x.com",
25
+ inviterName: "A",
26
+ inviterEmail: "a@x.com",
27
+ organizationName: "Acme",
28
+ role: "member",
29
+ inviteUrl: "u6",
30
+ });
31
+
32
+ expect(seen.map((m) => m.kind)).toEqual([
33
+ "verification",
34
+ "reset-password",
35
+ "email-change",
36
+ "delete-account",
37
+ "magic-link",
38
+ "org-invitation",
39
+ ]);
40
+ const invite = seen[5]!;
41
+ if (invite.kind !== "org-invitation") throw new Error("expected org-invitation");
42
+ expect(invite.inviter).toEqual({ name: "A", email: "a@x.com" });
43
+ expect(invite.url).toBe("u6");
44
+ });
45
+
46
+ test("send failures propagate (BA hooks must see rejections)", async () => {
47
+ const ba = toPlatformAuthSendEmail(async () => {
48
+ throw new Error("smtp down");
49
+ });
50
+ await expect(
51
+ ba.verification({ user: { email: "a@x.com", name: "A" }, url: "u" }),
52
+ ).rejects.toThrow("smtp down");
53
+ });
54
+ });
55
+
56
+ describe("defineEmailHandlers", () => {
57
+ test("dispatches to the matching kind handler", async () => {
58
+ const calls: string[] = [];
59
+ const send = defineEmailHandlers({
60
+ verification: async (m) => void calls.push(`v:${m.url}`),
61
+ "reset-password": async () => void calls.push("r"),
62
+ "email-change": async () => void calls.push("c"),
63
+ "delete-account": async () => void calls.push("d"),
64
+ "magic-link": async () => void calls.push("m"),
65
+ "org-invitation": async () => void calls.push("o"),
66
+ });
67
+ await send({ kind: "verification", to: { email: "a@x.com" }, url: "u9" });
68
+ expect(calls).toEqual(["v:u9"]);
69
+ });
70
+ });
71
+
72
+ describe("cors originPattern", () => {
73
+ const p = originPattern("acme.example");
74
+ test("matches the bare apex (the suffix-only footgun this guards)", () => {
75
+ expect(p.test("https://acme.example")).toBe(true);
76
+ });
77
+ test("matches subdomains, both schemes", () => {
78
+ expect(p.test("https://app.acme.example")).toBe(true);
79
+ expect(p.test("http://deep.dev.acme.example")).toBe(true);
80
+ });
81
+ test("rejects lookalike and suffixed hosts", () => {
82
+ expect(p.test("https://evilacme.example")).toBe(false);
83
+ expect(p.test("https://acme.example.attacker.com")).toBe(false);
84
+ });
85
+ });
package/src/blobs.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Blob-storage capability for the avatar surface. Injected by the consumer
3
+ * (`GuestlistConfig.blobs`); when absent, the avatar routes are not mounted
4
+ * and `/u/avatar/*` 404s — the feature is off, not broken.
5
+ *
6
+ * The expected implementation is presigned browser-direct upload against
7
+ * R2/S3: `register` returns a presigned PUT the browser uses directly, so
8
+ * guestlist never streams bytes. Dedup by content hash is the
9
+ * implementation's choice — returning `upload: { status: "ready" }` from
10
+ * `register` signals a dedup hit (blob already stored; skip the PUT).
11
+ *
12
+ * Avatars are capped at 8 MiB, so single-part upload is always sufficient;
13
+ * multipart is deliberately not part of this interface.
14
+ */
15
+ export interface BlobRegisterInput {
16
+ /** sha-256 of the bytes, lowercase hex. */
17
+ hash: string;
18
+ size: number;
19
+ contentType: string;
20
+ /** The authenticated user the blob will belong to. */
21
+ ownerUserId: string;
22
+ }
23
+
24
+ export type BlobUploadPlan =
25
+ | { status: "ready" }
26
+ | { status: "single-part"; uploadUrl: string; requiredHeaders: Record<string, string> };
27
+
28
+ export type BlobRegisterResult =
29
+ | { ok: true; referenceId: string; upload: BlobUploadPlan }
30
+ | { ok: false; error: string };
31
+
32
+ export type BlobReferenceState = "pending" | "stored" | "deleted";
33
+
34
+ export interface GuestlistBlobStore {
35
+ /** Reserve a reference + presigned upload for the given content. */
36
+ register(input: BlobRegisterInput): Promise<BlobRegisterResult>;
37
+ /**
38
+ * Look up a reference the confirm flow is about to finalize. Returns
39
+ * `null` for unknown refs. `ownerUserId` is who the ref was registered
40
+ * for — the route enforces that the confirming user matches.
41
+ */
42
+ getReference(refId: string): Promise<{ state: BlobReferenceState; ownerUserId: string } | null>;
43
+ /** Finalize a pending reference after the browser PUT completed. */
44
+ finalize(refId: string): Promise<{ ok: true } | { ok: false; error: string }>;
45
+ /**
46
+ * Release a reference (replaced or removed avatar). Fire-and-forget from
47
+ * the caller's perspective; implementations should tolerate double
48
+ * release and unknown refs.
49
+ */
50
+ release(refId: string): Promise<void>;
51
+ /**
52
+ * Short-lived public read URL for a stored blob, or `null` if the ref is
53
+ * unknown/deleted. Backs the 302 on `/u/avatar/:refId`.
54
+ */
55
+ readUrl(refId: string, opts: { lifetimeSeconds: number }): Promise<string | null>;
56
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Browser-side avatar upload driver — transport-agnostic.
3
+ *
4
+ * Avatar mutations are WorkerEntrypoint RPC (no HTTP spelling), so the
5
+ * browser cannot call guestlist directly. The consuming app fronts
6
+ * register/confirm with its own endpoints or server fns (each a one-line
7
+ * forward to `env.GUESTLIST.registerAvatarUpload(...)` /
8
+ * `.confirmAvatar(...)` with the user's Cookie header) and injects them
9
+ * here. This driver owns the browser-only mechanics: sha-256 hashing, the
10
+ * presigned direct PUT with progress, and error normalization.
11
+ */
12
+ import type { BlobUploadPlan } from "../blobs";
13
+ import type { AvatarContentType } from "../ops/avatar";
14
+
15
+ export type { AvatarContentType };
16
+
17
+ export class AvatarError extends Error {
18
+ override name = "AvatarError" as const;
19
+ constructor(
20
+ public code: string,
21
+ message: string,
22
+ public status?: number,
23
+ ) {
24
+ super(message);
25
+ }
26
+ }
27
+
28
+ export interface AvatarTransport {
29
+ /** App-provided forward to the `registerAvatarUpload` RPC. */
30
+ register(input: {
31
+ hash: string;
32
+ size: number;
33
+ contentType: AvatarContentType;
34
+ }): Promise<
35
+ | { ok: true; referenceId: string; upload: BlobUploadPlan }
36
+ | { ok: false; error: string; message?: string }
37
+ >;
38
+ /** App-provided forward to the `confirmAvatar` RPC. */
39
+ confirm(input: {
40
+ referenceId: string;
41
+ }): Promise<{ ok: true; image: string } | { ok: false; error: string; message?: string }>;
42
+ }
43
+
44
+ export interface SetAvatarOpts {
45
+ contentType: AvatarContentType;
46
+ onProgress?: (loaded: number, total: number) => void;
47
+ signal?: AbortSignal;
48
+ }
49
+
50
+ /**
51
+ * Three-step presigned upload of a new avatar:
52
+ * 1. `transport.register` — reserve a reference + presigned PUT
53
+ * 2. direct PUT of the bytes to storage (skipped on dedup hit)
54
+ * 3. `transport.confirm` — finalize + write the URL onto `user.image`
55
+ *
56
+ * Returns the new image URL. Throws `AvatarError` if any sub-step fails;
57
+ * guestlist's confirm op handles its own rollback so a failure doesn't
58
+ * leak orphan blobs.
59
+ */
60
+ export async function setAvatar(
61
+ transport: AvatarTransport,
62
+ blob: Blob,
63
+ opts: SetAvatarOpts,
64
+ ): Promise<string> {
65
+ const hash = await sha256Hex(blob);
66
+ const reg = await transport.register({
67
+ hash,
68
+ size: blob.size,
69
+ contentType: opts.contentType,
70
+ });
71
+ if (!reg.ok) throw new AvatarError(reg.error, reg.message ?? "avatar register failed");
72
+
73
+ if (reg.upload.status === "single-part") {
74
+ await putWithProgress(reg.upload.uploadUrl, reg.upload.requiredHeaders, blob, opts);
75
+ }
76
+ // status "ready" = dedup hit; the blob is already stored.
77
+
78
+ const conf = await transport.confirm({ referenceId: reg.referenceId });
79
+ if (!conf.ok) throw new AvatarError(conf.error, conf.message ?? "avatar confirm failed");
80
+ return conf.image;
81
+ }
82
+
83
+ /** sha-256 of the blob, lowercase hex — what `register` expects. */
84
+ export async function sha256Hex(blob: Blob): Promise<string> {
85
+ const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
86
+ return Array.from(new Uint8Array(digest))
87
+ .map((b) => b.toString(16).padStart(2, "0"))
88
+ .join("");
89
+ }
90
+
91
+ /**
92
+ * Direct PUT via XHR (fetch has no upload-progress events). Presigned URLs
93
+ * are method+header bound, so `requiredHeaders` must be set verbatim.
94
+ */
95
+ function putWithProgress(
96
+ url: string,
97
+ requiredHeaders: Record<string, string>,
98
+ blob: Blob,
99
+ opts: Pick<SetAvatarOpts, "onProgress" | "signal">,
100
+ ): Promise<void> {
101
+ return new Promise((resolve, reject) => {
102
+ const xhr = new XMLHttpRequest();
103
+ xhr.open("PUT", url);
104
+ for (const [k, v] of Object.entries(requiredHeaders)) xhr.setRequestHeader(k, v);
105
+ if (opts.onProgress) {
106
+ xhr.upload.onprogress = (e) => {
107
+ if (e.lengthComputable) opts.onProgress!(e.loaded, e.total);
108
+ };
109
+ }
110
+ if (opts.signal) {
111
+ const abort = () => {
112
+ xhr.abort();
113
+ reject(new AvatarError("aborted", "avatar upload aborted"));
114
+ };
115
+ if (opts.signal.aborted) return abort();
116
+ opts.signal.addEventListener("abort", abort, { once: true });
117
+ }
118
+ xhr.onload = () => {
119
+ if (xhr.status >= 200 && xhr.status < 300) resolve();
120
+ else reject(new AvatarError("upload_failed", `storage PUT returned ${xhr.status}`, xhr.status));
121
+ };
122
+ xhr.onerror = () => reject(new AvatarError("upload_failed", "storage PUT network error"));
123
+ xhr.send(blob);
124
+ });
125
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Server-side guestlist client: the better-auth client (whose routes are
3
+ * HTTP on guestlist) plus the cookie/correlation plumbing that makes it
4
+ * safe from SSR. Custom operations (admin, orgs, directory, avatar
5
+ * mutations) are NOT here — call them directly off the service binding
6
+ * RPC (`env.GUESTLIST.adminListOrgs({ cookie, ... })`).
7
+ */
8
+ import { createAuthClient } from "better-auth/client";
9
+ import type { ClientFetchOption } from "@better-auth/core";
10
+ import { parseSetCookie, splitSetCookieString, type CookieSerializeOptions } from "cookie-es";
11
+ import type { Actor } from "@somewhatintelligent/kit/request-context";
12
+ import type { PlatformSession } from "@somewhatintelligent/auth";
13
+ import { guestlistClientPlugins } from "./plugins";
14
+
15
+ /**
16
+ * Framework-agnostic cookie adapter. Matches the shape popularized by
17
+ * `@supabase/ssr`, `@clerk/nextjs`, and `@auth/core` server helpers:
18
+ * consumer apps provide a `getAll` that reads the incoming request's cookie
19
+ * jar and a `setAll` that writes forwarded cookies onto the outgoing
20
+ * response. Both callbacks may be sync or async.
21
+ */
22
+ export interface GuestlistCookieAdapter {
23
+ /** Called before every call. Cookies from the incoming request. */
24
+ getAll: () =>
25
+ | ReadonlyArray<{ name: string; value: string }>
26
+ | Promise<ReadonlyArray<{ name: string; value: string }>>;
27
+ /**
28
+ * Called after every call with any `Set-Cookie` guestlist emitted,
29
+ * parsed into structured form; consumer forwards them onto its outgoing
30
+ * SSR response. Without this, guestlist-emitted cookie refreshes
31
+ * (notably the 5-min session-cache JWT) are silently dropped and the
32
+ * fast-path session reader returns `null` on the next SSR request even
33
+ * when the underlying DB session is valid.
34
+ */
35
+ setAll: (
36
+ cookies: ReadonlyArray<{ name: string; value: string; options?: CookieSerializeOptions }>,
37
+ ) => void | Promise<void>;
38
+ }
39
+
40
+ export interface GuestlistClientOptions {
41
+ /**
42
+ * Guestlist service URL. When calling via a Cloudflare service binding,
43
+ * this host is ignored — any placeholder (e.g. `"http://guestlist.internal"`)
44
+ * works as long as `fetchOptions.customFetchImpl` routes through the binding.
45
+ */
46
+ baseURL: string;
47
+ /**
48
+ * Fetch options passed through to better-auth's client verbatim.
49
+ * `customFetchImpl` is typically `env.GUESTLIST.fetch.bind(env.GUESTLIST)`.
50
+ */
51
+ fetchOptions?: ClientFetchOption;
52
+ /** Cookie adapter — see GuestlistCookieAdapter. */
53
+ cookies?: GuestlistCookieAdapter;
54
+ /** Correlation id resolver; forwarded as `cf-request-id`. */
55
+ getRequestId?: () => string;
56
+ /** Calling app/service name; forwarded as `x-caller-app` (log correlation only). */
57
+ callerApp?: string;
58
+ /**
59
+ * Active actor resolver; forwarded as `x-actor-kind`/`x-actor-id`.
60
+ * LOG CORRELATION ONLY — guestlist never uses these for authz; its
61
+ * authoritative identity flows through cookies → BA get-session → DB.
62
+ */
63
+ getActor?: () => Actor | null | Promise<Actor | null>;
64
+ }
65
+
66
+ export function createGuestlistClient(options: GuestlistClientOptions) {
67
+ if ("window" in globalThis) {
68
+ throw new Error(
69
+ "createGuestlistClient is server-only. " +
70
+ "Use createGuestlistAuthClient from @somewhatintelligent/guestlist/client/react instead.",
71
+ );
72
+ }
73
+
74
+ const { baseURL, fetchOptions, cookies, getRequestId, callerApp, getActor } = options;
75
+
76
+ const baseFetch =
77
+ (fetchOptions?.customFetchImpl as typeof fetch | undefined) ??
78
+ (globalThis.fetch as typeof fetch);
79
+ const cookieWrapped = cookies ? wrapFetchWithCookieAdapter(baseFetch, cookies) : baseFetch;
80
+ const wrappedFetch =
81
+ getRequestId || callerApp || getActor
82
+ ? wrapFetchWithCorrelation(cookieWrapped, { getRequestId, callerApp, getActor })
83
+ : cookieWrapped;
84
+
85
+ const auth = createAuthClient({
86
+ baseURL,
87
+ fetchOptions: { ...fetchOptions, customFetchImpl: wrappedFetch },
88
+ plugins: guestlistClientPlugins(),
89
+ });
90
+
91
+ return {
92
+ /** Full better-auth client with guestlist's plugin set. */
93
+ auth,
94
+
95
+ async getSession(): Promise<PlatformSession | null> {
96
+ const res = await auth.getSession();
97
+ return res.data ?? null;
98
+ },
99
+ };
100
+ }
101
+
102
+ export type GuestlistClient = ReturnType<typeof createGuestlistClient>;
103
+
104
+ /**
105
+ * Wraps a fetch impl to inject correlation headers on every outgoing
106
+ * request — adopted by guestlist at its fetch boundary so the same context
107
+ * threads through every canonical-log line on either side. Caller-set
108
+ * values on `init.headers` win. Actor headers are omitted when
109
+ * `getActor()` returns null (auth flows, anonymous page loads).
110
+ */
111
+ function wrapFetchWithCorrelation(
112
+ realFetch: typeof fetch,
113
+ opts: {
114
+ getRequestId?: () => string;
115
+ callerApp?: string;
116
+ getActor?: () => Actor | null | Promise<Actor | null>;
117
+ },
118
+ ): typeof fetch {
119
+ const wrapped = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
120
+ const headers = new Headers(init?.headers ?? undefined);
121
+ if (opts.getRequestId && !headers.has("cf-request-id")) {
122
+ headers.set("cf-request-id", opts.getRequestId());
123
+ }
124
+ if (opts.callerApp && !headers.has("x-caller-app")) {
125
+ headers.set("x-caller-app", opts.callerApp);
126
+ }
127
+ if (opts.getActor && !headers.has("x-actor-kind")) {
128
+ const actor = await opts.getActor();
129
+ if (actor) {
130
+ headers.set("x-actor-kind", actor.kind);
131
+ headers.set("x-actor-id", actor.kind === "user" ? actor.userId : actor.serviceName);
132
+ }
133
+ }
134
+ return realFetch(input, { ...init, headers });
135
+ };
136
+ return wrapped as unknown as typeof fetch;
137
+ }
138
+
139
+ /**
140
+ * Wraps a fetch impl with two responsibilities:
141
+ * 1. Before the call: fold `cookies.getAll()` into the `Cookie:` header.
142
+ * 2. After: parse every `Set-Cookie` on the response into `cookies.setAll()`.
143
+ */
144
+ function wrapFetchWithCookieAdapter(
145
+ realFetch: typeof fetch,
146
+ cookies: GuestlistCookieAdapter,
147
+ ): typeof fetch {
148
+ const wrapped = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
149
+ const headers = new Headers(init?.headers ?? undefined);
150
+ const incoming = await cookies.getAll();
151
+ if (incoming.length > 0) {
152
+ const folded = incoming.map((c) => `${c.name}=${c.value}`).join("; ");
153
+ const existing = headers.get("cookie");
154
+ headers.set("cookie", existing ? `${existing}; ${folded}` : folded);
155
+ }
156
+
157
+ const response = await realFetch(input, { ...init, headers });
158
+
159
+ const rawSetCookies = readSetCookies(response.headers);
160
+ if (rawSetCookies.length > 0) {
161
+ const parsed: Array<{ name: string; value: string; options?: CookieSerializeOptions }> = [];
162
+ for (const raw of rawSetCookies) {
163
+ const sc = parseSetCookie(raw);
164
+ if (!sc) continue;
165
+ const { name, value, ...rest } = sc;
166
+ parsed.push({ name, value, options: rest as CookieSerializeOptions });
167
+ }
168
+ if (parsed.length > 0) await cookies.setAll(parsed);
169
+ }
170
+
171
+ return response;
172
+ };
173
+ return wrapped as unknown as typeof fetch;
174
+ }
175
+
176
+ /**
177
+ * Reads `Set-Cookie` values off a Headers instance, handling runtimes with
178
+ * `getSetCookie()` (modern Node, CF Workers) and legacy comma-folded
179
+ * strings (split via `splitSetCookieString`, which doesn't choke on commas
180
+ * inside `Expires=` attribute values).
181
+ */
182
+ function readSetCookies(headers: Headers): string[] {
183
+ const maybeGetSetCookie = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
184
+ if (typeof maybeGetSetCookie === "function") {
185
+ return maybeGetSetCookie.call(headers);
186
+ }
187
+ const raw = headers.get("set-cookie");
188
+ return raw ? splitSetCookieString(raw) : [];
189
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ createGuestlistClient,
3
+ type GuestlistClient,
4
+ type GuestlistClientOptions,
5
+ type GuestlistCookieAdapter,
6
+ } from "./guestlist";
7
+ export { guestlistClientPlugins } from "./plugins";
8
+ // Browser avatar driver — the app injects transports that forward to the
9
+ // `registerAvatarUpload` / `confirmAvatar` WorkerEntrypoint RPC methods.
10
+ export {
11
+ setAvatar,
12
+ sha256Hex,
13
+ AvatarError,
14
+ type AvatarTransport,
15
+ type SetAvatarOpts,
16
+ type AvatarContentType,
17
+ } from "./avatar";
@@ -0,0 +1,24 @@
1
+ import {
2
+ adminClient,
3
+ twoFactorClient,
4
+ deviceAuthorizationClient,
5
+ usernameClient,
6
+ magicLinkClient,
7
+ organizationClient,
8
+ } from "better-auth/client/plugins";
9
+ import { oauthProviderClient } from "@better-auth/oauth-provider/client";
10
+ import { passkeyClient } from "@better-auth/passkey/client";
11
+ import { apiKeyClient } from "@better-auth/api-key/client";
12
+
13
+ /** Client plugins matching guestlist's server plugin set. */
14
+ export const guestlistClientPlugins = () => [
15
+ usernameClient(),
16
+ adminClient(),
17
+ twoFactorClient(),
18
+ oauthProviderClient(),
19
+ passkeyClient(),
20
+ apiKeyClient(),
21
+ deviceAuthorizationClient(),
22
+ magicLinkClient(),
23
+ organizationClient(),
24
+ ];
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Browser-side better-auth client with guestlist's plugin set.
5
+ *
6
+ * `baseURL` is the app-root URL the browser should call — typically
7
+ * `window.location.origin` when the consumer app (or bouncer) proxies
8
+ * `/api/auth/*` to guestlist. Avatar uploads are a separate concern: see
9
+ * `setAvatar` in ./avatar.ts (the app injects its register/confirm
10
+ * endpoints, which forward to the WorkerEntrypoint RPC).
11
+ */
12
+ import { createAuthClient } from "better-auth/react";
13
+ import type { ClientFetchOption } from "@better-auth/core";
14
+ import { guestlistClientPlugins } from "./plugins";
15
+
16
+ export interface GuestlistAuthClientOptions {
17
+ baseURL: string;
18
+ fetchOptions?: ClientFetchOption;
19
+ }
20
+
21
+ export function createGuestlistAuthClient(options: GuestlistAuthClientOptions) {
22
+ return createAuthClient({
23
+ baseURL: options.baseURL,
24
+ fetchOptions: {
25
+ credentials: "include",
26
+ ...options.fetchOptions,
27
+ },
28
+ plugins: guestlistClientPlugins(),
29
+ });
30
+ }
31
+
32
+ export type GuestlistAuthClient = ReturnType<typeof createGuestlistAuthClient>;
package/src/codegen.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Schema-codegen entry for consumers.
3
+ *
4
+ * Migrations are CONSUMER-OWNED, derived from the consumer's config:
5
+ * better-auth's CLI generates the drizzle schema from the configured
6
+ * instance's plugin set, so which tables exist follows from which
7
+ * features the consumer enables. The package ships no migrations.
8
+ *
9
+ * Consumer flow (in the consumer repo):
10
+ *
11
+ * ```ts
12
+ * // guestlist.codegen.ts
13
+ * import { createCodegenAuth } from "@somewhatintelligent/guestlist";
14
+ * import { guestlistConfig } from "./guestlist.config";
15
+ * export const auth = createCodegenAuth(guestlistConfig);
16
+ * ```
17
+ *
18
+ * ```sh
19
+ * bunx @better-auth/cli generate --config guestlist.codegen.ts # schema
20
+ * bunx drizzle-kit generate # migrations (consumer journal)
21
+ * ```
22
+ *
23
+ * Every option that doesn't affect the plugin set / schema is stubbed —
24
+ * this instance must never serve traffic.
25
+ */
26
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
27
+ import { createPlatformAuth } from "@somewhatintelligent/auth";
28
+
29
+ import type { GuestlistEnv } from "./env";
30
+ import type { GuestlistConfig } from "./config";
31
+ import { consoleEmail, toPlatformAuthSendEmail } from "./email";
32
+
33
+ export function createCodegenAuth<TEnv extends GuestlistEnv>(config: GuestlistConfig<TEnv>) {
34
+ return createPlatformAuth({
35
+ baseURL: "http://codegen.invalid",
36
+ secret: "codegen-only-never-serves-traffic",
37
+ authDomain: ".codegen.invalid",
38
+ identityUrl: "http://codegen.invalid",
39
+ requireEmailVerification: false,
40
+ cookiePrefix: config.cookiePrefix,
41
+ passkeyRpName: config.passkeyRpName,
42
+ twoFactorIssuer: config.twoFactorIssuer,
43
+ // Adapter construction never touches the db; the CLI only introspects.
44
+ database: drizzleAdapter({} as never, { provider: "sqlite", schema: {} }),
45
+ sendEmail: toPlatformAuthSendEmail(consoleEmail()),
46
+ // Billing present ⇒ the stripe plugin joins the set, so its tables
47
+ // land in the generated schema. Plan CONTENT is schema-neutral (same
48
+ // subscription table for any plan list); keys are stubs; see module doc.
49
+ stripe: config.billing
50
+ ? {
51
+ secretKey: "sk_codegen",
52
+ webhookSecret: "whsec_codegen",
53
+ plans: config.billing.plans,
54
+ }
55
+ : undefined,
56
+ });
57
+ }