@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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Operator org-provisioning operations, exposed only as admin-gated
3
+ * WorkerEntrypoint methods.
4
+ *
5
+ * BA-API-vs-direct-Drizzle split: BA's auto-mounted organization routes
6
+ * bind to the calling user's session. Operator flows call `auth.api.*`
7
+ * server-side WITHOUT session headers where BA tolerates it
8
+ * (`createOrganization`, `addMember` — both accept an explicit `userId`
9
+ * on a headerless call). The endpoints that hard-require headers in BA
10
+ * v1.6 (`updateMemberRole`, `removeMember`, `createInvitation`,
11
+ * `cancelInvitation`) fall back to direct Drizzle writes with the same
12
+ * invariants BA enforces (last-owner guards, pending-only cancellation).
13
+ */
14
+ import { and, count, desc, eq, like } from "drizzle-orm";
15
+ import { ulid } from "@somewhatintelligent/kit/ids";
16
+
17
+ import type { GuestlistInstances } from "../instances";
18
+ import { user, organization, member, invitation } from "../schema";
19
+ import { err, type Authed, type RpcResult } from "../rpc";
20
+
21
+ const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
22
+
23
+ /**
24
+ * Roles are configurable (config.organization.roles), so RPC inputs carry
25
+ * plain strings and every write path validates against the configured
26
+ * role set at runtime — a binding caller is not bound by TS types.
27
+ */
28
+ function invalidRole(inst: GuestlistInstances, role: string) {
29
+ return inst.orgRoleNames.includes(role)
30
+ ? null
31
+ : err("validation", `unknown role "${role}" (valid: ${inst.orgRoleNames.join(", ")})`);
32
+ }
33
+
34
+ export async function createOrg(
35
+ inst: GuestlistInstances,
36
+ input: { name: string; slug: string; ownerUserId: string },
37
+ ) {
38
+ if (input.name.length < 2 || input.name.length > 60) {
39
+ return err("validation", "name must be 2-60 chars");
40
+ }
41
+ // Kebab-case: lowercase letters, digits, single hyphens. 2–48 chars.
42
+ if (input.slug.length < 2 || input.slug.length > 48 || !SLUG_RE.test(input.slug)) {
43
+ return err("validation", "slug must be kebab-case, 2-48 chars");
44
+ }
45
+ try {
46
+ const org = await inst.auth.api.createOrganization({
47
+ body: { name: input.name, slug: input.slug, userId: input.ownerUserId },
48
+ // intentionally no `headers:` — that's the BA-documented trigger that
49
+ // makes `userId` meaningful and bypasses the session user check.
50
+ });
51
+ return { ok: true as const, organization: org };
52
+ } catch (e) {
53
+ const message = e instanceof Error ? e.message : String(e);
54
+ // BA's slug-collision path throws BAD_REQUEST with the
55
+ // ORGANIZATION_ALREADY_EXISTS code; translate so the operator UI can
56
+ // distinguish "slug taken" from generic validation failures.
57
+ if (/already.?exists|slug/i.test(message)) return err("slug_taken", message);
58
+ throw e;
59
+ }
60
+ }
61
+
62
+ export async function listOrgs(inst: GuestlistInstances) {
63
+ const { db } = inst;
64
+ // BA has no "list all orgs across the platform" surface — its
65
+ // `listOrganizations` scopes to caller memberships. Direct join.
66
+ const orgs = await db
67
+ .select({
68
+ id: organization.id,
69
+ slug: organization.slug,
70
+ name: organization.name,
71
+ logo: organization.logo,
72
+ createdAt: organization.createdAt,
73
+ memberCount: count(member.id),
74
+ })
75
+ .from(organization)
76
+ .leftJoin(member, eq(member.organizationId, organization.id))
77
+ .groupBy(organization.id)
78
+ .orderBy(desc(organization.createdAt))
79
+ .limit(100);
80
+ // One pass for owner names: earliest creator-role member per org (the
81
+ // original creator wins when multiple owners exist).
82
+ const ownerRows = await db
83
+ .select({
84
+ organizationId: member.organizationId,
85
+ name: user.name,
86
+ createdAt: member.createdAt,
87
+ })
88
+ .from(member)
89
+ .leftJoin(user, eq(member.userId, user.id))
90
+ .where(eq(member.role, inst.orgCreatorRole));
91
+ const ownerByOrg = new Map<string, { name: string | null; createdAt: Date }>();
92
+ for (const row of ownerRows) {
93
+ const prior = ownerByOrg.get(row.organizationId);
94
+ if (!prior || row.createdAt < prior.createdAt) {
95
+ ownerByOrg.set(row.organizationId, { name: row.name, createdAt: row.createdAt });
96
+ }
97
+ }
98
+ return {
99
+ ok: true as const,
100
+ organizations: orgs.map((o) => ({
101
+ ...o,
102
+ ownerName: ownerByOrg.get(o.id)?.name ?? null,
103
+ })),
104
+ };
105
+ }
106
+
107
+ /** Email-prefix user search for the operator's owner/member pickers. */
108
+ export async function searchUsersByEmail(inst: GuestlistInstances, emailPrefix: string) {
109
+ // Min prefix length 2 — anything shorter would be a no-op scan.
110
+ const prefix = emailPrefix.trim();
111
+ if (prefix.length < 2) return { ok: true as const, users: [] };
112
+ // Escape LIKE metacharacters — missing this would let `_` match every email.
113
+ const escaped = prefix.replace(/[\\%_]/g, "\\$&");
114
+ const rows = await inst.db
115
+ .select({ id: user.id, name: user.name, email: user.email, image: user.image })
116
+ .from(user)
117
+ .where(like(user.email, escaped + "%"))
118
+ .orderBy(user.email)
119
+ .limit(10);
120
+ return { ok: true as const, users: rows };
121
+ }
122
+
123
+ export async function getOrg(inst: GuestlistInstances, id: string) {
124
+ const { db } = inst;
125
+ const orgRows = await db.select().from(organization).where(eq(organization.id, id)).limit(1);
126
+ const org = orgRows[0];
127
+ if (!org) return err("not_found");
128
+ const memberRows = await db
129
+ .select({
130
+ memberId: member.id,
131
+ userId: member.userId,
132
+ name: user.name,
133
+ email: user.email,
134
+ image: user.image,
135
+ role: member.role,
136
+ joinedAt: member.createdAt,
137
+ })
138
+ .from(member)
139
+ .leftJoin(user, eq(member.userId, user.id))
140
+ .where(eq(member.organizationId, id));
141
+ // Sort: creator role first, then admin/member, custom roles after,
142
+ // alphabetically by name within a rank. Creator entry comes LAST so it
143
+ // wins the object-literal collision when creatorRole IS "admin"/"member".
144
+ const ROLE_RANK: Record<string, number> = { admin: 1, member: 2, [inst.orgCreatorRole]: 0 };
145
+ memberRows.sort((a, b) => {
146
+ const ra = ROLE_RANK[a.role] ?? 99;
147
+ const rb = ROLE_RANK[b.role] ?? 99;
148
+ if (ra !== rb) return ra - rb;
149
+ return (a.name ?? "").localeCompare(b.name ?? "");
150
+ });
151
+ const invitationRows = await db
152
+ .select({
153
+ id: invitation.id,
154
+ email: invitation.email,
155
+ role: invitation.role,
156
+ status: invitation.status,
157
+ expiresAt: invitation.expiresAt,
158
+ inviterName: user.name,
159
+ })
160
+ .from(invitation)
161
+ .leftJoin(user, eq(invitation.inviterId, user.id))
162
+ .where(and(eq(invitation.organizationId, id), eq(invitation.status, "pending")));
163
+ return { ok: true as const, organization: org, members: memberRows, invitations: invitationRows };
164
+ }
165
+
166
+ export async function addMember(
167
+ inst: GuestlistInstances,
168
+ orgId: string,
169
+ input: { userId: string; role: string },
170
+ ) {
171
+ const bad = invalidRole(inst, input.role);
172
+ if (bad) return bad;
173
+ // BA's `addMember` tolerates no-headers when `body.userId` is set —
174
+ // operator-as-superuser path, same pattern as createOrganization.
175
+ try {
176
+ const created = await inst.auth.api.addMember({
177
+ // BA types `role` as its DEFAULT three-role union (the plugin's
178
+ // custom-roles generic doesn't reach auth.api's static types); the
179
+ // server accepts any configured role, and invalidRole() above
180
+ // already rejected anything not in the configured set.
181
+ body: { userId: input.userId, organizationId: orgId, role: input.role as "member" },
182
+ });
183
+ return { ok: true as const, member: created };
184
+ } catch (e) {
185
+ const message = e instanceof Error ? e.message : String(e);
186
+ if (/already a member/i.test(message)) return err("already_member", message);
187
+ if (/not.?found|user not found/i.test(message)) return err("not_found", message);
188
+ throw e;
189
+ }
190
+ }
191
+
192
+ export async function updateMemberRole(
193
+ inst: GuestlistInstances,
194
+ orgId: string,
195
+ userId: string,
196
+ role: string,
197
+ ): Promise<RpcResult<Record<never, never>>> {
198
+ const bad = invalidRole(inst, role);
199
+ if (bad) return bad;
200
+ const { db } = inst;
201
+ // BA's `updateMemberRole` has `requireHeaders: true`; direct Drizzle
202
+ // UPDATE is the documented fallback.
203
+ const targetRows = await db
204
+ .select({ id: member.id, role: member.role })
205
+ .from(member)
206
+ .where(and(eq(member.organizationId, orgId), eq(member.userId, userId)))
207
+ .limit(1);
208
+ const target = targetRows[0];
209
+ if (!target) return err("member_not_found");
210
+ // Last-owner guard: refuse to demote the only creator-role member. Run
211
+ // BEFORE mutating.
212
+ if (target.role === inst.orgCreatorRole && role !== inst.orgCreatorRole) {
213
+ const owners = await db
214
+ .select({ count: count() })
215
+ .from(member)
216
+ .where(and(eq(member.organizationId, orgId), eq(member.role, inst.orgCreatorRole)));
217
+ if ((owners[0]?.count ?? 0) <= 1) return err("cannot_demote_last_owner");
218
+ }
219
+ await db
220
+ .update(member)
221
+ .set({ role })
222
+ .where(and(eq(member.organizationId, orgId), eq(member.userId, userId)));
223
+ return { ok: true };
224
+ }
225
+
226
+ export async function removeMember(
227
+ inst: GuestlistInstances,
228
+ orgId: string,
229
+ userId: string,
230
+ ): Promise<RpcResult<Record<never, never>>> {
231
+ const { db } = inst;
232
+ // Same fallback rationale as updateMemberRole: BA's removeMember has
233
+ // `requireHeaders: true`.
234
+ const targetRows = await db
235
+ .select({ id: member.id, role: member.role })
236
+ .from(member)
237
+ .where(and(eq(member.organizationId, orgId), eq(member.userId, userId)))
238
+ .limit(1);
239
+ const target = targetRows[0];
240
+ if (!target) return err("member_not_found");
241
+ // Never let the org end up with zero creator-role members through this
242
+ // surface.
243
+ if (target.role === inst.orgCreatorRole) {
244
+ const owners = await db
245
+ .select({ count: count() })
246
+ .from(member)
247
+ .where(and(eq(member.organizationId, orgId), eq(member.role, inst.orgCreatorRole)));
248
+ if ((owners[0]?.count ?? 0) <= 1) return err("cannot_remove_last_owner");
249
+ }
250
+ await db
251
+ .delete(member)
252
+ .where(and(eq(member.organizationId, orgId), eq(member.userId, userId)));
253
+ return { ok: true };
254
+ }
255
+
256
+ export async function createInvitation(
257
+ inst: GuestlistInstances,
258
+ operator: Authed,
259
+ orgId: string,
260
+ input: { email: string; role: string },
261
+ ) {
262
+ const bad = invalidRole(inst, input.role);
263
+ if (bad) return bad;
264
+ const { db } = inst;
265
+ if (input.email.length < 3 || input.email.length > 254 || !input.email.includes("@")) {
266
+ return err("validation", "invalid email");
267
+ }
268
+ // BA's `createInvitation` has `requireHeaders: true` and derives the
269
+ // inviter from the session. Operator path: the operator becomes the
270
+ // inviter; direct insert. `sendInvitationEmail` is NOT invoked here —
271
+ // operator-issued invitations surface the raw link in the UI.
272
+ const orgRows = await db
273
+ .select({ id: organization.id })
274
+ .from(organization)
275
+ .where(eq(organization.id, orgId))
276
+ .limit(1);
277
+ if (!orgRows[0]) return err("org_not_found");
278
+ const now = new Date();
279
+ // Operator-issued invitations expire after 7 days, longer than BA's
280
+ // default 48h window.
281
+ const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
282
+ const id = ulid();
283
+ await db.insert(invitation).values({
284
+ id,
285
+ organizationId: orgId,
286
+ email: input.email,
287
+ role: input.role,
288
+ status: "pending",
289
+ inviterId: operator.user.id,
290
+ createdAt: now,
291
+ expiresAt,
292
+ });
293
+ return {
294
+ ok: true as const,
295
+ invitation: {
296
+ id,
297
+ organizationId: orgId,
298
+ email: input.email,
299
+ role: input.role,
300
+ status: "pending" as const,
301
+ inviterId: operator.user.id,
302
+ createdAt: now.getTime(),
303
+ expiresAt: expiresAt.getTime(),
304
+ },
305
+ };
306
+ }
307
+
308
+ export async function cancelInvitation(
309
+ inst: GuestlistInstances,
310
+ orgId: string,
311
+ invitationId: string,
312
+ ) {
313
+ const { db } = inst;
314
+ // BA's `cancelInvitation` also has `requireHeaders: true`. Direct UPDATE
315
+ // with status transitions; not_found if missing, conflict if terminal.
316
+ const rows = await db
317
+ .select({ id: invitation.id, status: invitation.status })
318
+ .from(invitation)
319
+ .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, orgId)))
320
+ .limit(1);
321
+ const inv = rows[0];
322
+ if (!inv) return err("invitation_not_found");
323
+ if (inv.status !== "pending") return err("invitation_not_pending", inv.status);
324
+ await db.update(invitation).set({ status: "cancelled" }).where(eq(invitation.id, invitationId));
325
+ return { ok: true as const };
326
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * User-directory operations (mentions, member pickers, display-name
3
+ * resolution) + the caller's own OAuth connections. Session-gated by the
4
+ * entrypoint.
5
+ */
6
+ import { eq, inArray, like, or } from "drizzle-orm";
7
+
8
+ import type { GuestlistInstances } from "../instances";
9
+ import { user, oauthClient, oauthConsent } from "../schema";
10
+ import type { Authed } from "../rpc";
11
+
12
+ export interface DirectoryUser {
13
+ id: string;
14
+ name: string;
15
+ email?: string;
16
+ image?: string;
17
+ }
18
+
19
+ /**
20
+ * Search the user directory by name OR email substring.
21
+ * - Empty query returns `[]` (not all users — that'd be a data leak).
22
+ * - `limit` defaults to 20, max 50.
23
+ * - `email` is returned on every hit (v1 trust model — identity already
24
+ * exposes email to org admins; tighten if a less-trusted caller appears).
25
+ */
26
+ export async function searchUsers(
27
+ inst: GuestlistInstances,
28
+ input: { query: string; limit?: number },
29
+ ) {
30
+ const raw = input.query.trim().toLowerCase().slice(0, 200);
31
+ if (raw.length === 0) return { ok: true as const, users: [] as DirectoryUser[] };
32
+ const limit = Math.min(Math.max(input.limit ?? 20, 1), 50);
33
+ const escaped = raw.replace(/[\\%_]/g, "\\$&");
34
+ const pattern = `%${escaped}%`;
35
+ const rows = await inst.db
36
+ .select({ id: user.id, name: user.name, email: user.email, image: user.image })
37
+ .from(user)
38
+ .where(or(like(user.name, pattern), like(user.email, pattern)))
39
+ .orderBy(user.name)
40
+ .limit(limit);
41
+ return {
42
+ ok: true as const,
43
+ users: rows.map(
44
+ (r): DirectoryUser => ({
45
+ id: r.id,
46
+ name: r.name,
47
+ email: r.email,
48
+ ...(r.image !== null && { image: r.image }),
49
+ }),
50
+ ),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Batch-resolve user records by id.
56
+ * - Max 100 ids per call.
57
+ * - Return order matches input order; missing ids are omitted (not nulled).
58
+ */
59
+ export async function getUsersByIds(inst: GuestlistInstances, ids: string[]) {
60
+ const wanted = ids.filter((id) => id.length > 0).slice(0, 100);
61
+ if (wanted.length === 0) return { ok: true as const, users: [] as DirectoryUser[] };
62
+ const rows = await inst.db
63
+ .select({ id: user.id, name: user.name, email: user.email, image: user.image })
64
+ .from(user)
65
+ .where(inArray(user.id, wanted));
66
+ const byId = new Map(rows.map((r) => [r.id, r]));
67
+ const ordered: DirectoryUser[] = [];
68
+ for (const id of wanted) {
69
+ const r = byId.get(id);
70
+ if (!r) continue;
71
+ ordered.push({
72
+ id: r.id,
73
+ name: r.name,
74
+ email: r.email,
75
+ ...(r.image !== null && { image: r.image }),
76
+ });
77
+ }
78
+ return { ok: true as const, users: ordered };
79
+ }
80
+
81
+ /** The calling user's OAuth consents (identity's "connections" page). */
82
+ export async function getConnections(inst: GuestlistInstances, authed: Authed) {
83
+ const rows = await inst.db
84
+ .select({
85
+ consentId: oauthConsent.id,
86
+ clientId: oauthConsent.clientId,
87
+ scopes: oauthConsent.scopes,
88
+ createdAt: oauthConsent.createdAt,
89
+ clientName: oauthClient.name,
90
+ clientIcon: oauthClient.icon,
91
+ })
92
+ .from(oauthConsent)
93
+ .leftJoin(oauthClient, eq(oauthConsent.clientId, oauthClient.clientId))
94
+ .where(eq(oauthConsent.userId, authed.user.id));
95
+ return { ok: true as const, connections: rows };
96
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared shapes for the `Guestlist` WorkerEntrypoint surface.
3
+ *
4
+ * Guestlist's only HTTP routes are the ones better-auth itself provides
5
+ * (`/api/auth/*`, the OIDC well-known metadata) plus the public avatar
6
+ * read broker (`/u/avatar/:refId` — browsers hit it from `<img>` tags).
7
+ * EVERYTHING else — admin, orgs, user directory, avatar mutations — is a
8
+ * typed RPC method on the entrypoint, callable only over a service
9
+ * binding. There is no HTTP spelling of those operations at all.
10
+ *
11
+ * Authorization: methods that act on behalf of a user take the caller's
12
+ * raw `Cookie` header (`cookie`). Identity is derived from the cookie via
13
+ * better-auth — the cookie remains the sole credential; being able to
14
+ * reach the binding does not by itself grant user/admin powers.
15
+ *
16
+ * Expected failures return `{ ok: false, error }` (never throw) so
17
+ * consumers can switch on error codes without parsing serialized
18
+ * exception messages.
19
+ */
20
+ import type { GuestlistAuth } from "./instances";
21
+
22
+ export type RpcErr =
23
+ | { ok: false; error: "unauthorized" }
24
+ | { ok: false; error: "forbidden" }
25
+ | { ok: false; error: string; message?: string };
26
+
27
+ export type RpcResult<T> = ({ ok: true } & T) | RpcErr;
28
+
29
+ export const unauthorized = { ok: false, error: "unauthorized" } as const;
30
+ export const forbidden = { ok: false, error: "forbidden" } as const;
31
+
32
+ export function err(error: string, message?: string): RpcErr {
33
+ return message === undefined ? { ok: false, error } : { ok: false, error, message };
34
+ }
35
+
36
+ /** Rebuild request headers from the caller-forwarded Cookie header. */
37
+ export function cookieHeaders(cookie: string | undefined): Headers {
38
+ const headers = new Headers();
39
+ if (cookie) headers.set("cookie", cookie);
40
+ return headers;
41
+ }
42
+
43
+ type SessionResult = NonNullable<Awaited<ReturnType<GuestlistAuth["api"]["getSession"]>>>;
44
+ export type Authed = { user: SessionResult["user"]; session: SessionResult["session"] };
45
+
46
+ export async function resolveUser(auth: GuestlistAuth, cookie: string | undefined) {
47
+ const session = await auth.api.getSession({ headers: cookieHeaders(cookie) });
48
+ if (!session) return null;
49
+ return { user: session.user, session: session.session } as Authed;
50
+ }
51
+
52
+ export async function resolveAdmin(
53
+ auth: GuestlistAuth,
54
+ cookie: string | undefined,
55
+ ): Promise<Authed | RpcErr> {
56
+ const authed = await resolveUser(auth, cookie);
57
+ if (!authed) return unauthorized;
58
+ if (authed.user.role !== "admin") return forbidden;
59
+ return authed;
60
+ }
61
+
62
+ export function isErr(x: unknown): x is RpcErr {
63
+ return typeof x === "object" && x !== null && "ok" in x && (x as { ok: unknown }).ok === false;
64
+ }