@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/src/ops/orgs.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ops/users.ts
ADDED
|
@@ -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
|
+
}
|