@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 +54 -0
- package/src/__tests__/email.test.ts +85 -0
- package/src/blobs.ts +56 -0
- package/src/client/avatar.ts +125 -0
- package/src/client/guestlist.ts +189 -0
- package/src/client/index.ts +17 -0
- package/src/client/plugins.ts +24 -0
- package/src/client/react.ts +32 -0
- package/src/codegen.ts +57 -0
- package/src/config.ts +82 -0
- package/src/cors.ts +64 -0
- package/src/db.ts +18 -0
- package/src/email.ts +93 -0
- package/src/entrypoint.ts +261 -0
- package/src/env.ts +42 -0
- package/src/http.ts +122 -0
- package/src/index.ts +85 -0
- package/src/instances.ts +120 -0
- package/src/log.ts +95 -0
- package/src/ops/admin.ts +190 -0
- package/src/ops/avatar.ts +161 -0
- package/src/ops/orgs.ts +326 -0
- package/src/ops/users.ts +96 -0
- package/src/rpc.ts +64 -0
- package/src/schema.ts +538 -0
- package/src/version.gen.ts +7 -0
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
|
+
}
|