auth-orgs.ts1145 lines · main
1/**
2 * Briven Auth Organizations — customer-facing multi-tenant teams.
3 *
4 * These tables live in each project's own database (proj_<id>) and are
5 * queried via `runInProjectDatabase`. They are NOT Better Auth models;
6 * they extend the auth system with org/team management that customer
7 * apps consume through the SDK.
8 *
9 * Roles: owner > admin > member. Only one owner per org; ownership
10 * transfers explicitly. Deleting an org cascades to members + invites.
11 */
12
13import { promises as dns } from 'node:dns';
14
15import { ValidationError } from '@briven/shared';
16
17import { runInProjectDatabase } from '../db/data-plane.js';
18
19const ORG_ROLES = ['owner', 'admin', 'member'] as const;
20export type OrgRole = (typeof ORG_ROLES)[number];
21
22function assertRole(role: string): asserts role is OrgRole {
23 if (!ORG_ROLES.includes(role as OrgRole)) {
24 throw new ValidationError(`invalid org role: ${role}`);
25 }
26}
27
28// ─── Phase 4 — Permissions ────────────────────────────────────────────────
29
30export const ORG_PERMISSIONS = [
31 'org:update',
32 'org:delete',
33 'member:add',
34 'member:remove',
35 'member:update_role',
36 'invite:create',
37 'invite:revoke',
38 'invite:list',
39 'domain:manage',
40 'request:approve',
41 'billing:view',
42 'billing:manage',
43] as const;
44
45export type OrgPermission = (typeof ORG_PERMISSIONS)[number];
46
47const DEFAULT_ROLE_PERMISSIONS: Record<string, OrgPermission[]> = {
48 owner: [...ORG_PERMISSIONS],
49 admin: ORG_PERMISSIONS.filter((p) => p !== 'org:delete' && p !== 'billing:manage'),
50 member: ['billing:view'],
51};
52
53function assertPermission(perm: string): asserts perm is OrgPermission {
54 if (!ORG_PERMISSIONS.includes(perm as OrgPermission)) {
55 throw new ValidationError(`invalid org permission: ${perm}`);
56 }
57}
58
59function newToken(): string {
60 const bytes = new Uint8Array(32);
61 crypto.getRandomValues(bytes);
62 return Array.from(bytes)
63 .map((b) => b.toString(16).padStart(2, '0'))
64 .join('');
65}
66
67// ─── Org CRUD ─────────────────────────────────────────────────────────────
68
69export interface CreateOrgInput {
70 readonly name: string;
71 readonly slug: string;
72 readonly logo?: string | null;
73}
74
75export interface OrgOutput {
76 id: string;
77 name: string;
78 slug: string;
79 logo: string | null;
80 metadata: Record<string, unknown>;
81 createdAt: string;
82}
83
84export async function createOrg(
85 projectId: string,
86 userId: string,
87 input: CreateOrgInput,
88): Promise<OrgOutput> {
89 if (!input.name || input.name.length > 128) {
90 throw new ValidationError('name is required and must be <= 128 chars');
91 }
92 if (!input.slug || !/^[a-z0-9-]{1,64}$/.test(input.slug)) {
93 throw new ValidationError('slug must be 1-64 lowercase alphanumerics or hyphens');
94 }
95
96 return runInProjectDatabase<OrgOutput>(projectId, async (tx) => {
97 const orgRows = (await tx.unsafe(
98 `INSERT INTO "_briven_auth_orgs" (name, slug, logo)
99 VALUES ($1, $2, $3)
100 RETURNING id, name, slug, logo, metadata, created_at`,
101 [input.name, input.slug, input.logo ?? null],
102 )) as {
103 id: string; name: string; slug: string; logo: string | null;
104 metadata: unknown; created_at: Date;
105 }[];
106 const org = orgRows[0];
107 if (!org) throw new Error('org insert failed');
108
109 await tx.unsafe(
110 `INSERT INTO "_briven_auth_org_members" (org_id, user_id, role)
111 VALUES ($1, $2, 'owner')`,
112 [org.id, userId],
113 );
114
115 // Seed default roles (Phase 4)
116 for (const [roleName, perms] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
117 await tx.unsafe(
118 `INSERT INTO "_briven_auth_org_roles" (org_id, name, permissions, is_system)
119 VALUES ($1, $2, $3, true)
120 ON CONFLICT (org_id, name) DO NOTHING`,
121 [org.id, roleName, JSON.stringify(perms)],
122 );
123 }
124
125 return {
126 id: org.id,
127 name: org.name,
128 slug: org.slug,
129 logo: org.logo,
130 metadata: (org.metadata as Record<string, unknown>) ?? {},
131 createdAt: org.created_at.toISOString(),
132 };
133 });
134}
135
136export async function listOrgsForUser(
137 projectId: string,
138 userId: string,
139): Promise<OrgOutput[]> {
140 return runInProjectDatabase<OrgOutput[]>(projectId, async (tx) => {
141 const rows = (await tx.unsafe(
142 `SELECT o.id, o.name, o.slug, o.logo, o.metadata, o.created_at
143 FROM "_briven_auth_orgs" o
144 INNER JOIN "_briven_auth_org_members" m ON o.id = m.org_id
145 WHERE m.user_id = $1`,
146 [userId],
147 )) as {
148 id: string; name: string; slug: string; logo: string | null;
149 metadata: unknown; created_at: Date;
150 }[];
151
152 return rows.map((r) => ({
153 id: r.id,
154 name: r.name,
155 slug: r.slug,
156 logo: r.logo,
157 metadata: (r.metadata as Record<string, unknown>) ?? {},
158 createdAt: r.created_at.toISOString(),
159 }));
160 });
161}
162
163export async function getOrg(
164 projectId: string,
165 orgId: string,
166): Promise<OrgOutput | null> {
167 return runInProjectDatabase<OrgOutput | null>(projectId, async (tx) => {
168 const rows = (await tx.unsafe(
169 `SELECT id, name, slug, logo, metadata, created_at
170 FROM "_briven_auth_orgs"
171 WHERE id = $1
172 LIMIT 1`,
173 [orgId],
174 )) as {
175 id: string; name: string; slug: string; logo: string | null;
176 metadata: unknown; created_at: Date;
177 }[];
178 const row = rows[0];
179 if (!row) return null;
180 return {
181 id: row.id,
182 name: row.name,
183 slug: row.slug,
184 logo: row.logo,
185 metadata: (row.metadata as Record<string, unknown>) ?? {},
186 createdAt: row.created_at.toISOString(),
187 };
188 });
189}
190
191export async function updateOrg(
192 projectId: string,
193 orgId: string,
194 patch: { name?: string; logo?: string | null; slug?: string },
195): Promise<OrgOutput> {
196 if (patch.slug && !/^[a-z0-9-]{1,64}$/.test(patch.slug)) {
197 throw new ValidationError('slug must be 1-64 lowercase alphanumerics or hyphens');
198 }
199 return runInProjectDatabase<OrgOutput>(projectId, async (tx) => {
200 const sets: string[] = [];
201 const params: (string | null)[] = [];
202 if (patch.name) {
203 sets.push(`name = $${params.length + 1}`);
204 params.push(patch.name);
205 }
206 if (patch.logo !== undefined) {
207 sets.push(`logo = $${params.length + 1}`);
208 params.push(patch.logo);
209 }
210 if (patch.slug) {
211 sets.push(`slug = $${params.length + 1}`);
212 params.push(patch.slug);
213 }
214 sets.push(`updated_at = now()`);
215 params.push(orgId);
216
217 const rows = (await tx.unsafe(
218 `UPDATE "_briven_auth_orgs"
219 SET ${sets.join(', ')}
220 WHERE id = $${params.length}
221 RETURNING id, name, slug, logo, metadata, created_at`,
222 params,
223 )) as {
224 id: string; name: string; slug: string; logo: string | null;
225 metadata: unknown; created_at: Date;
226 }[];
227 const row = rows[0];
228 if (!row) throw new ValidationError('org not found');
229 return {
230 id: row.id,
231 name: row.name,
232 slug: row.slug,
233 logo: row.logo,
234 metadata: (row.metadata as Record<string, unknown>) ?? {},
235 createdAt: row.created_at.toISOString(),
236 };
237 });
238}
239
240export async function deleteOrg(projectId: string, orgId: string): Promise<void> {
241 await runInProjectDatabase(projectId, async (tx) => {
242 await tx.unsafe(
243 `DELETE FROM "_briven_auth_orgs" WHERE id = $1`,
244 [orgId],
245 );
246 });
247}
248
249// ─── Members ──────────────────────────────────────────────────────────────
250
251export interface MemberOutput {
252 id: string;
253 orgId: string;
254 userId: string;
255 role: OrgRole;
256 createdAt: string;
257}
258
259export async function listOrgMembers(
260 projectId: string,
261 orgId: string,
262): Promise<MemberOutput[]> {
263 return runInProjectDatabase<MemberOutput[]>(projectId, async (tx) => {
264 const rows = (await tx.unsafe(
265 `SELECT id, org_id, user_id, role, created_at
266 FROM "_briven_auth_org_members"
267 WHERE org_id = $1`,
268 [orgId],
269 )) as {
270 id: string; org_id: string; user_id: string; role: string; created_at: Date;
271 }[];
272 return rows.map((r) => ({
273 id: r.id,
274 orgId: r.org_id,
275 userId: r.user_id,
276 role: r.role as OrgRole,
277 createdAt: r.created_at.toISOString(),
278 }));
279 });
280}
281
282export async function addOrgMember(
283 projectId: string,
284 orgId: string,
285 userId: string,
286 role: OrgRole,
287): Promise<MemberOutput> {
288 assertRole(role);
289 return runInProjectDatabase<MemberOutput>(projectId, async (tx) => {
290 try {
291 const rows = (await tx.unsafe(
292 `INSERT INTO "_briven_auth_org_members" (org_id, user_id, role)
293 VALUES ($1, $2, $3)
294 RETURNING id, org_id, user_id, role, created_at`,
295 [orgId, userId, role],
296 )) as {
297 id: string; org_id: string; user_id: string; role: string; created_at: Date;
298 }[];
299 const row = rows[0];
300 if (!row) throw new ValidationError('user is already a member of this org');
301 return {
302 id: row.id,
303 orgId: row.org_id,
304 userId: row.user_id,
305 role: row.role as OrgRole,
306 createdAt: row.created_at.toISOString(),
307 };
308 } catch (err) {
309 // 23505 = unique_violation (ON CONFLICT not used with unsafe)
310 if (err && typeof err === 'object' && 'code' in err && err.code === '23505') {
311 throw new ValidationError('user is already a member of this org');
312 }
313 throw err;
314 }
315 });
316}
317
318export async function updateMemberRole(
319 projectId: string,
320 orgId: string,
321 userId: string,
322 role: OrgRole,
323): Promise<MemberOutput> {
324 assertRole(role);
325 return runInProjectDatabase<MemberOutput>(projectId, async (tx) => {
326 const rows = (await tx.unsafe(
327 `UPDATE "_briven_auth_org_members"
328 SET role = $1, updated_at = now()
329 WHERE org_id = $2 AND user_id = $3
330 RETURNING id, org_id, user_id, role, created_at`,
331 [role, orgId, userId],
332 )) as {
333 id: string; org_id: string; user_id: string; role: string; created_at: Date;
334 }[];
335 const row = rows[0];
336 if (!row) throw new ValidationError('member not found');
337 return {
338 id: row.id,
339 orgId: row.org_id,
340 userId: row.user_id,
341 role: row.role as OrgRole,
342 createdAt: row.created_at.toISOString(),
343 };
344 });
345}
346
347export async function removeOrgMember(
348 projectId: string,
349 orgId: string,
350 userId: string,
351): Promise<void> {
352 await runInProjectDatabase(projectId, async (tx) => {
353 await tx.unsafe(
354 `DELETE FROM "_briven_auth_org_members"
355 WHERE org_id = $1 AND user_id = $2`,
356 [orgId, userId],
357 );
358 });
359}
360
361// ─── Invitations ──────────────────────────────────────────────────────────
362
363export interface InviteOutput {
364 id: string;
365 orgId: string;
366 email: string;
367 role: OrgRole;
368 token: string;
369 expiresAt: string;
370 invitedBy: string | null;
371 acceptedAt: string | null;
372 createdAt: string;
373}
374
375export async function createOrgInvite(
376 projectId: string,
377 orgId: string,
378 invitedByUserId: string,
379 input: { email: string; role?: OrgRole },
380): Promise<InviteOutput> {
381 const role = input.role ?? 'member';
382 assertRole(role);
383 if (!input.email || !input.email.includes('@')) {
384 throw new ValidationError('valid email is required');
385 }
386
387 return runInProjectDatabase<InviteOutput>(projectId, async (tx) => {
388 const token = newToken();
389 const rows = (await tx.unsafe(
390 `INSERT INTO "_briven_auth_org_invites"
391 (org_id, email, role, token, expires_at, invited_by)
392 VALUES ($1, $2, $3, $4, now() + interval '7 days', $5)
393 RETURNING id, org_id, email, role, token, expires_at, invited_by, accepted_at, created_at`,
394 [orgId, input.email.toLowerCase(), role, token, invitedByUserId],
395 )) as {
396 id: string; org_id: string; email: string; role: string; token: string;
397 expires_at: Date; invited_by: string | null; accepted_at: Date | null; created_at: Date;
398 }[];
399 const row = rows[0];
400 if (!row) throw new Error('invite insert failed');
401 return {
402 id: row.id,
403 orgId: row.org_id,
404 email: row.email,
405 role: row.role as OrgRole,
406 token: row.token,
407 expiresAt: row.expires_at.toISOString(),
408 invitedBy: row.invited_by,
409 acceptedAt: row.accepted_at?.toISOString() ?? null,
410 createdAt: row.created_at.toISOString(),
411 };
412 });
413}
414
415export async function getInviteByToken(
416 projectId: string,
417 token: string,
418): Promise<InviteOutput | null> {
419 return runInProjectDatabase<InviteOutput | null>(projectId, async (tx) => {
420 const rows = (await tx.unsafe(
421 `SELECT id, org_id, email, role, token, expires_at, invited_by, accepted_at, created_at
422 FROM "_briven_auth_org_invites"
423 WHERE token = $1 AND expires_at > now() AND accepted_at IS NULL
424 LIMIT 1`,
425 [token],
426 )) as {
427 id: string; org_id: string; email: string; role: string; token: string;
428 expires_at: Date; invited_by: string | null; accepted_at: Date | null; created_at: Date;
429 }[];
430 const row = rows[0];
431 if (!row) return null;
432 return {
433 id: row.id,
434 orgId: row.org_id,
435 email: row.email,
436 role: row.role as OrgRole,
437 token: row.token,
438 expiresAt: row.expires_at.toISOString(),
439 invitedBy: row.invited_by,
440 acceptedAt: row.accepted_at?.toISOString() ?? null,
441 createdAt: row.created_at.toISOString(),
442 };
443 });
444}
445
446export async function acceptInvite(
447 projectId: string,
448 token: string,
449 userId: string,
450): Promise<{ orgId: string }> {
451 return runInProjectDatabase<{ orgId: string }>(projectId, async (tx) => {
452 const invites = (await tx.unsafe(
453 `SELECT id, org_id, role FROM "_briven_auth_org_invites"
454 WHERE token = $1 AND expires_at > now() AND accepted_at IS NULL
455 LIMIT 1`,
456 [token],
457 )) as { id: string; org_id: string; role: string }[];
458 const invite = invites[0];
459 if (!invite) throw new ValidationError('invite not found or expired');
460
461 await tx.unsafe(
462 `UPDATE "_briven_auth_org_invites" SET accepted_at = now() WHERE id = $1`,
463 [invite.id],
464 );
465
466 await tx.unsafe(
467 `INSERT INTO "_briven_auth_org_members" (org_id, user_id, role)
468 VALUES ($1, $2, $3)
469 ON CONFLICT (org_id, user_id) DO NOTHING`,
470 [invite.org_id, userId, invite.role],
471 );
472
473 return { orgId: invite.org_id };
474 });
475}
476
477export async function listPendingInvites(
478 projectId: string,
479 orgId: string,
480): Promise<InviteOutput[]> {
481 return runInProjectDatabase<InviteOutput[]>(projectId, async (tx) => {
482 const rows = (await tx.unsafe(
483 `SELECT id, org_id, email, role, token, expires_at, invited_by, accepted_at, created_at
484 FROM "_briven_auth_org_invites"
485 WHERE org_id = $1 AND accepted_at IS NULL
486 ORDER BY created_at`,
487 [orgId],
488 )) as {
489 id: string; org_id: string; email: string; role: string; token: string;
490 expires_at: Date; invited_by: string; accepted_at: Date | null; created_at: Date;
491 }[];
492 return rows.map((r) => ({
493 id: r.id,
494 orgId: r.org_id,
495 email: r.email,
496 role: r.role as OrgRole,
497 token: r.token,
498 expiresAt: r.expires_at.toISOString(),
499 invitedBy: r.invited_by,
500 acceptedAt: r.accepted_at?.toISOString() ?? null,
501 createdAt: r.created_at.toISOString(),
502 }));
503 });
504}
505
506export async function revokeInvite(projectId: string, inviteId: string): Promise<void> {
507 await runInProjectDatabase(projectId, async (tx) => {
508 await tx.unsafe(
509 `DELETE FROM "_briven_auth_org_invites" WHERE id = $1`,
510 [inviteId],
511 );
512 });
513}
514
515// ─── Role check ───────────────────────────────────────────────────────────
516
517export async function getUserOrgRole(
518 projectId: string,
519 orgId: string,
520 userId: string,
521): Promise<OrgRole | null> {
522 return runInProjectDatabase<OrgRole | null>(projectId, async (tx) => {
523 const rows = (await tx.unsafe(
524 `SELECT role FROM "_briven_auth_org_members" WHERE org_id = $1 AND user_id = $2 LIMIT 1`,
525 [orgId, userId],
526 )) as { role: string }[];
527 return (rows[0]?.role as OrgRole) ?? null;
528 });
529}
530
531// ─── Phase 4 — Permissions ────────────────────────────────────────────────
532
533export async function getUserOrgPermissions(
534 projectId: string,
535 orgId: string,
536 userId: string,
537): Promise<OrgPermission[]> {
538 return runInProjectDatabase<OrgPermission[]>(projectId, async (tx) => {
539 const rows = (await tx.unsafe(
540 `SELECT m.role, r.permissions
541 FROM "_briven_auth_org_members" m
542 LEFT JOIN "_briven_auth_org_roles" r
543 ON r.org_id = m.org_id AND r.name = m.role
544 WHERE m.org_id = $1 AND m.user_id = $2
545 LIMIT 1`,
546 [orgId, userId],
547 )) as { role: string; permissions: unknown }[];
548 const row = rows[0];
549 if (!row) return [];
550 // Fallback to hardcoded defaults if roles table row missing (legacy orgs)
551 if (!row.permissions) {
552 return (DEFAULT_ROLE_PERMISSIONS[row.role] ?? []) as OrgPermission[];
553 }
554 const perms = Array.isArray(row.permissions) ? row.permissions : [];
555 return perms.filter((p): p is OrgPermission =>
556 ORG_PERMISSIONS.includes(p as OrgPermission),
557 );
558 });
559}
560
561export async function hasPermission(
562 projectId: string,
563 orgId: string,
564 userId: string,
565 permission: OrgPermission,
566): Promise<boolean> {
567 const perms = await getUserOrgPermissions(projectId, orgId, userId);
568 return perms.includes(permission);
569}
570
571// ─── Phase 4 — Custom Roles ───────────────────────────────────────────────
572
573export interface OrgRoleOutput {
574 id: string;
575 orgId: string;
576 name: string;
577 permissions: OrgPermission[];
578 isSystem: boolean;
579 createdAt: string;
580}
581
582export async function listOrgRoles(
583 projectId: string,
584 orgId: string,
585): Promise<OrgRoleOutput[]> {
586 return runInProjectDatabase<OrgRoleOutput[]>(projectId, async (tx) => {
587 const rows = (await tx.unsafe(
588 `SELECT id, org_id, name, permissions, is_system, created_at
589 FROM "_briven_auth_org_roles"
590 WHERE org_id = $1
591 ORDER BY created_at`,
592 [orgId],
593 )) as {
594 id: string; org_id: string; name: string; permissions: unknown;
595 is_system: boolean; created_at: Date;
596 }[];
597 return rows.map((r) => ({
598 id: r.id,
599 orgId: r.org_id,
600 name: r.name,
601 permissions: Array.isArray(r.permissions)
602 ? (r.permissions as string[]).filter((p): p is OrgPermission =>
603 ORG_PERMISSIONS.includes(p as OrgPermission),
604 )
605 : [],
606 isSystem: r.is_system,
607 createdAt: r.created_at.toISOString(),
608 }));
609 });
610}
611
612export async function createOrgRole(
613 projectId: string,
614 orgId: string,
615 input: { name: string; permissions: OrgPermission[] },
616): Promise<OrgRoleOutput> {
617 if (!input.name || input.name.length > 64) {
618 throw new ValidationError('name is required and must be <= 64 chars');
619 }
620 if (!Array.isArray(input.permissions)) {
621 throw new ValidationError('permissions must be an array');
622 }
623 for (const p of input.permissions) assertPermission(p);
624 return runInProjectDatabase<OrgRoleOutput>(projectId, async (tx) => {
625 const rows = (await tx.unsafe(
626 `INSERT INTO "_briven_auth_org_roles" (org_id, name, permissions)
627 VALUES ($1, $2, $3)
628 RETURNING id, org_id, name, permissions, is_system, created_at`,
629 [orgId, input.name, JSON.stringify(input.permissions)],
630 )) as {
631 id: string; org_id: string; name: string; permissions: unknown;
632 is_system: boolean; created_at: Date;
633 }[];
634 const row = rows[0];
635 if (!row) throw new Error('role insert failed');
636 return {
637 id: row.id,
638 orgId: row.org_id,
639 name: row.name,
640 permissions: Array.isArray(row.permissions)
641 ? (row.permissions as string[]).filter((p): p is OrgPermission =>
642 ORG_PERMISSIONS.includes(p as OrgPermission),
643 )
644 : [],
645 isSystem: row.is_system,
646 createdAt: row.created_at.toISOString(),
647 };
648 });
649}
650
651export async function updateOrgRole(
652 projectId: string,
653 orgId: string,
654 roleId: string,
655 input: { name?: string; permissions?: OrgPermission[] },
656): Promise<OrgRoleOutput> {
657 return runInProjectDatabase<OrgRoleOutput>(projectId, async (tx) => {
658 const existing = (await tx.unsafe(
659 `SELECT is_system FROM "_briven_auth_org_roles"
660 WHERE id = $1 AND org_id = $2 LIMIT 1`,
661 [roleId, orgId],
662 )) as { is_system: boolean }[];
663 if (!existing[0]) throw new ValidationError('role not found');
664 if (existing[0].is_system) {
665 throw new ValidationError('system roles cannot be modified');
666 }
667
668 const sets: string[] = [];
669 const params: (string | null)[] = [];
670 if (input.name) {
671 sets.push(`name = $${params.length + 1}`);
672 params.push(input.name);
673 }
674 if (input.permissions) {
675 for (const p of input.permissions) assertPermission(p);
676 sets.push(`permissions = $${params.length + 1}`);
677 params.push(JSON.stringify(input.permissions));
678 }
679 sets.push(`updated_at = now()`);
680 params.push(roleId);
681
682 const rows = (await tx.unsafe(
683 `UPDATE "_briven_auth_org_roles"
684 SET ${sets.join(', ')}
685 WHERE id = $${params.length} AND org_id = $${params.length + 1}
686 RETURNING id, org_id, name, permissions, is_system, created_at`,
687 [...params, orgId],
688 )) as {
689 id: string; org_id: string; name: string; permissions: unknown;
690 is_system: boolean; created_at: Date;
691 }[];
692 const row = rows[0];
693 if (!row) throw new ValidationError('role not found');
694 return {
695 id: row.id,
696 orgId: row.org_id,
697 name: row.name,
698 permissions: Array.isArray(row.permissions)
699 ? (row.permissions as string[]).filter((p): p is OrgPermission =>
700 ORG_PERMISSIONS.includes(p as OrgPermission),
701 )
702 : [],
703 isSystem: row.is_system,
704 createdAt: row.created_at.toISOString(),
705 };
706 });
707}
708
709export async function deleteOrgRole(
710 projectId: string,
711 orgId: string,
712 roleId: string,
713): Promise<void> {
714 await runInProjectDatabase(projectId, async (tx) => {
715 const existing = (await tx.unsafe(
716 `SELECT is_system FROM "_briven_auth_org_roles"
717 WHERE id = $1 AND org_id = $2 LIMIT 1`,
718 [roleId, orgId],
719 )) as { is_system: boolean }[];
720 if (!existing[0]) throw new ValidationError('role not found');
721 if (existing[0].is_system) {
722 throw new ValidationError('system roles cannot be deleted');
723 }
724 await tx.unsafe(
725 `DELETE FROM "_briven_auth_org_roles" WHERE id = $1 AND org_id = $2`,
726 [roleId, orgId],
727 );
728 });
729}
730
731// ─── Phase 4 — Domain Verification ────────────────────────────────────────
732
733export interface OrgDomainOutput {
734 id: string;
735 orgId: string;
736 domain: string;
737 verificationToken: string;
738 verifiedAt: string | null;
739 autoJoinEnabled: boolean;
740 createdAt: string;
741}
742
743function newDomainToken(): string {
744 const bytes = new Uint8Array(32);
745 crypto.getRandomValues(bytes);
746 return 'briven-verify=' + Array.from(bytes)
747 .map((b) => b.toString(16).padStart(2, '0'))
748 .join('');
749}
750
751export async function listOrgDomains(
752 projectId: string,
753 orgId: string,
754): Promise<OrgDomainOutput[]> {
755 return runInProjectDatabase<OrgDomainOutput[]>(projectId, async (tx) => {
756 const rows = (await tx.unsafe(
757 `SELECT id, org_id, domain, verification_token, verified_at, auto_join_enabled, created_at
758 FROM "_briven_auth_org_domains"
759 WHERE org_id = $1
760 ORDER BY created_at`,
761 [orgId],
762 )) as {
763 id: string; org_id: string; domain: string; verification_token: string;
764 verified_at: Date | null; auto_join_enabled: boolean; created_at: Date;
765 }[];
766 return rows.map((r) => ({
767 id: r.id,
768 orgId: r.org_id,
769 domain: r.domain,
770 verificationToken: r.verification_token,
771 verifiedAt: r.verified_at?.toISOString() ?? null,
772 autoJoinEnabled: r.auto_join_enabled,
773 createdAt: r.created_at.toISOString(),
774 }));
775 });
776}
777
778export async function addOrgDomain(
779 projectId: string,
780 orgId: string,
781 domain: string,
782): Promise<OrgDomainOutput> {
783 if (!domain || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(domain)) {
784 throw new ValidationError('invalid domain format');
785 }
786 const token = newDomainToken();
787 return runInProjectDatabase<OrgDomainOutput>(projectId, async (tx) => {
788 const rows = (await tx.unsafe(
789 `INSERT INTO "_briven_auth_org_domains" (org_id, domain, verification_token)
790 VALUES ($1, $2, $3)
791 RETURNING id, org_id, domain, verification_token, verified_at, auto_join_enabled, created_at`,
792 [orgId, domain.toLowerCase(), token],
793 )) as {
794 id: string; org_id: string; domain: string; verification_token: string;
795 verified_at: Date | null; auto_join_enabled: boolean; created_at: Date;
796 }[];
797 const row = rows[0];
798 if (!row) throw new Error('domain insert failed');
799 return {
800 id: row.id,
801 orgId: row.org_id,
802 domain: row.domain,
803 verificationToken: row.verification_token,
804 verifiedAt: row.verified_at?.toISOString() ?? null,
805 autoJoinEnabled: row.auto_join_enabled,
806 createdAt: row.created_at.toISOString(),
807 };
808 });
809}
810
811export async function verifyOrgDomain(
812 projectId: string,
813 orgId: string,
814 domainId: string,
815): Promise<OrgDomainOutput> {
816 // Fetch the domain row first so we can inspect the token before updating.
817 const domainRow = await runInProjectDatabase<
818 {
819 id: string;
820 org_id: string;
821 domain: string;
822 verification_token: string;
823 verified_at: Date | null;
824 auto_join_enabled: boolean;
825 created_at: Date;
826 } | null
827 >(projectId, async (tx) => {
828 const rows = (await tx.unsafe(
829 `SELECT id, org_id, domain, verification_token, verified_at, auto_join_enabled, created_at
830 FROM "_briven_auth_org_domains"
831 WHERE id = $1 AND org_id = $2
832 LIMIT 1`,
833 [domainId, orgId],
834 )) as {
835 id: string; org_id: string; domain: string; verification_token: string;
836 verified_at: Date | null; auto_join_enabled: boolean; created_at: Date;
837 }[];
838 return rows[0] ?? null;
839 });
840
841 if (!domainRow) throw new ValidationError('domain not found');
842
843 // DNS TXT challenge validation (Sprint S4).
844 // Prefer `briven-domain-verification=<token>` but still accept raw token.
845 let txtRecords: string[][] = [];
846 try {
847 txtRecords = await dns.resolveTxt(domainRow.domain);
848 } catch {
849 // DNS query failed — treat as no records.
850 txtRecords = [];
851 }
852
853 const { txtRecordsContainDomainToken, domainVerificationTxt } = await import(
854 './auth-hardening.js'
855 );
856 const token = domainRow.verification_token;
857 const found = txtRecordsContainDomainToken(txtRecords, token);
858 if (!found) {
859 throw new ValidationError(
860 `dns txt record not found. add a txt record on ${domainRow.domain} with value "${domainVerificationTxt(token)}" (or the raw verification token).`,
861 );
862 }
863
864 // Token validated — mark as verified.
865 return runInProjectDatabase<OrgDomainOutput>(projectId, async (tx) => {
866 const rows = (await tx.unsafe(
867 `UPDATE "_briven_auth_org_domains"
868 SET verified_at = now(), updated_at = now()
869 WHERE id = $1 AND org_id = $2
870 RETURNING id, org_id, domain, verification_token, verified_at, auto_join_enabled, created_at`,
871 [domainId, orgId],
872 )) as {
873 id: string; org_id: string; domain: string; verification_token: string;
874 verified_at: Date | null; auto_join_enabled: boolean; created_at: Date;
875 }[];
876 const row = rows[0]!;
877 return {
878 id: row.id,
879 orgId: row.org_id,
880 domain: row.domain,
881 verificationToken: row.verification_token,
882 verifiedAt: row.verified_at?.toISOString() ?? null,
883 autoJoinEnabled: row.auto_join_enabled,
884 createdAt: row.created_at.toISOString(),
885 };
886 });
887}
888
889export async function setOrgDomainAutoJoin(
890 projectId: string,
891 orgId: string,
892 domainId: string,
893 enabled: boolean,
894): Promise<OrgDomainOutput> {
895 return runInProjectDatabase<OrgDomainOutput>(projectId, async (tx) => {
896 const rows = (await tx.unsafe(
897 `UPDATE "_briven_auth_org_domains"
898 SET auto_join_enabled = $1, updated_at = now()
899 WHERE id = $2 AND org_id = $3
900 RETURNING id, org_id, domain, verification_token, verified_at, auto_join_enabled, created_at`,
901 [enabled, domainId, orgId],
902 )) as {
903 id: string; org_id: string; domain: string; verification_token: string;
904 verified_at: Date | null; auto_join_enabled: boolean; created_at: Date;
905 }[];
906 const row = rows[0];
907 if (!row) throw new ValidationError('domain not found');
908 return {
909 id: row.id,
910 orgId: row.org_id,
911 domain: row.domain,
912 verificationToken: row.verification_token,
913 verifiedAt: row.verified_at?.toISOString() ?? null,
914 autoJoinEnabled: row.auto_join_enabled,
915 createdAt: row.created_at.toISOString(),
916 };
917 });
918}
919
920export async function removeOrgDomain(
921 projectId: string,
922 orgId: string,
923 domainId: string,
924): Promise<void> {
925 await runInProjectDatabase(projectId, async (tx) => {
926 await tx.unsafe(
927 `DELETE FROM "_briven_auth_org_domains" WHERE id = $1 AND org_id = $2`,
928 [domainId, orgId],
929 );
930 });
931}
932
933export async function getVerifiedDomainForEmail(
934 projectId: string,
935 email: string,
936): Promise<{ orgId: string; domain: string } | null> {
937 const domainPart = email.split('@')[1];
938 if (!domainPart) return null;
939 return runInProjectDatabase<{ orgId: string; domain: string } | null>(projectId, async (tx) => {
940 const rows = (await tx.unsafe(
941 `SELECT org_id, domain FROM "_briven_auth_org_domains"
942 WHERE domain = $1 AND verified_at IS NOT NULL AND auto_join_enabled = true
943 LIMIT 1`,
944 [domainPart.toLowerCase()],
945 )) as { org_id: string; domain: string }[];
946 const row = rows[0];
947 if (!row) return null;
948 return { orgId: row.org_id, domain: row.domain };
949 });
950}
951
952/** Auto-add a user to an org if their email domain matches a verified auto-join domain. */
953export async function maybeAutoJoinOrg(
954 projectId: string,
955 userId: string,
956 email: string,
957): Promise<{ orgId: string } | null> {
958 const match = await getVerifiedDomainForEmail(projectId, email);
959 if (!match) return null;
960 try {
961 await addOrgMember(projectId, match.orgId, userId, 'member');
962 } catch {
963 // Already a member — swallow.
964 }
965 return { orgId: match.orgId };
966}
967
968// ─── Phase 4 — Membership Requests ────────────────────────────────────────
969
970export interface MembershipRequestOutput {
971 id: string;
972 orgId: string;
973 userId: string;
974 status: 'pending' | 'approved' | 'rejected';
975 message: string | null;
976 requestedAt: string;
977 resolvedAt: string | null;
978 resolvedBy: string | null;
979 createdAt: string;
980}
981
982export async function createMembershipRequest(
983 projectId: string,
984 orgId: string,
985 userId: string,
986 message?: string,
987): Promise<MembershipRequestOutput> {
988 return runInProjectDatabase<MembershipRequestOutput>(projectId, async (tx) => {
989 const rows = (await tx.unsafe(
990 `INSERT INTO "_briven_auth_org_membership_requests"
991 (org_id, user_id, status, message)
992 VALUES ($1, $2, 'pending', $3)
993 ON CONFLICT (org_id, user_id) DO UPDATE SET
994 status = 'pending',
995 message = COALESCE(EXCLUDED.message, "_briven_auth_org_membership_requests".message),
996 resolved_at = NULL,
997 resolved_by = NULL,
998 updated_at = now()
999 RETURNING id, org_id, user_id, status, message, requested_at, resolved_at, resolved_by, created_at`,
1000 [orgId, userId, message ?? null],
1001 )) as {
1002 id: string; org_id: string; user_id: string; status: string;
1003 message: string | null; requested_at: Date; resolved_at: Date | null;
1004 resolved_by: string | null; created_at: Date;
1005 }[];
1006 const row = rows[0];
1007 if (!row) throw new Error('membership request insert failed');
1008 return {
1009 id: row.id,
1010 orgId: row.org_id,
1011 userId: row.user_id,
1012 status: row.status as MembershipRequestOutput['status'],
1013 message: row.message,
1014 requestedAt: row.requested_at.toISOString(),
1015 resolvedAt: row.resolved_at?.toISOString() ?? null,
1016 resolvedBy: row.resolved_by,
1017 createdAt: row.created_at.toISOString(),
1018 };
1019 });
1020}
1021
1022export async function listMembershipRequests(
1023 projectId: string,
1024 orgId: string,
1025 status?: 'pending' | 'approved' | 'rejected',
1026): Promise<MembershipRequestOutput[]> {
1027 return runInProjectDatabase<MembershipRequestOutput[]>(projectId, async (tx) => {
1028 const sql = status
1029 ? `SELECT id, org_id, user_id, status, message, requested_at, resolved_at, resolved_by, created_at
1030 FROM "_briven_auth_org_membership_requests"
1031 WHERE org_id = $1 AND status = $2
1032 ORDER BY requested_at DESC`
1033 : `SELECT id, org_id, user_id, status, message, requested_at, resolved_at, resolved_by, created_at
1034 FROM "_briven_auth_org_membership_requests"
1035 WHERE org_id = $1
1036 ORDER BY requested_at DESC`;
1037 const params = status ? [orgId, status] : [orgId];
1038 const rows = (await tx.unsafe(sql, params)) as {
1039 id: string; org_id: string; user_id: string; status: string;
1040 message: string | null; requested_at: Date; resolved_at: Date | null;
1041 resolved_by: string | null; created_at: Date;
1042 }[];
1043 return rows.map((r) => ({
1044 id: r.id,
1045 orgId: r.org_id,
1046 userId: r.user_id,
1047 status: r.status as MembershipRequestOutput['status'],
1048 message: r.message,
1049 requestedAt: r.requested_at.toISOString(),
1050 resolvedAt: r.resolved_at?.toISOString() ?? null,
1051 resolvedBy: r.resolved_by,
1052 createdAt: r.created_at.toISOString(),
1053 }));
1054 });
1055}
1056
1057export async function resolveMembershipRequest(
1058 projectId: string,
1059 orgId: string,
1060 requestId: string,
1061 resolverUserId: string,
1062 decision: 'approved' | 'rejected',
1063): Promise<MembershipRequestOutput> {
1064 return runInProjectDatabase<MembershipRequestOutput>(projectId, async (tx) => {
1065 const rows = (await tx.unsafe(
1066 `UPDATE "_briven_auth_org_membership_requests"
1067 SET status = $1, resolved_at = now(), resolved_by = $2, updated_at = now()
1068 WHERE id = $3 AND org_id = $4
1069 RETURNING id, org_id, user_id, status, message, requested_at, resolved_at, resolved_by, created_at`,
1070 [decision, resolverUserId, requestId, orgId],
1071 )) as {
1072 id: string; org_id: string; user_id: string; status: string;
1073 message: string | null; requested_at: Date; resolved_at: Date | null;
1074 resolved_by: string | null; created_at: Date;
1075 }[];
1076 const row = rows[0];
1077 if (!row) throw new ValidationError('request not found');
1078
1079 if (decision === 'approved') {
1080 await tx.unsafe(
1081 `INSERT INTO "_briven_auth_org_members" (org_id, user_id, role)
1082 VALUES ($1, $2, 'member')
1083 ON CONFLICT (org_id, user_id) DO NOTHING`,
1084 [orgId, row.user_id],
1085 );
1086 }
1087
1088 return {
1089 id: row.id,
1090 orgId: row.org_id,
1091 userId: row.user_id,
1092 status: row.status as MembershipRequestOutput['status'],
1093 message: row.message,
1094 requestedAt: row.requested_at.toISOString(),
1095 resolvedAt: row.resolved_at?.toISOString() ?? null,
1096 resolvedBy: row.resolved_by,
1097 createdAt: row.created_at.toISOString(),
1098 };
1099 });
1100}
1101
1102// ─── Phase 4 — Active Organization per Session ────────────────────────────
1103
1104export async function setSessionActiveOrg(
1105 projectId: string,
1106 sessionId: string,
1107 orgId: string,
1108): Promise<void> {
1109 await runInProjectDatabase(projectId, async (tx) => {
1110 await tx.unsafe(
1111 `INSERT INTO "_briven_auth_session_orgs" (session_id, org_id)
1112 VALUES ($1, $2)
1113 ON CONFLICT (session_id) DO UPDATE SET
1114 org_id = EXCLUDED.org_id,
1115 updated_at = now()`,
1116 [sessionId, orgId],
1117 );
1118 });
1119}
1120
1121export async function getSessionActiveOrg(
1122 projectId: string,
1123 sessionId: string,
1124): Promise<string | null> {
1125 return runInProjectDatabase<string | null>(projectId, async (tx) => {
1126 const rows = (await tx.unsafe(
1127 `SELECT org_id FROM "_briven_auth_session_orgs"
1128 WHERE session_id = $1 LIMIT 1`,
1129 [sessionId],
1130 )) as { org_id: string }[];
1131 return rows[0]?.org_id ?? null;
1132 });
1133}
1134
1135export async function clearSessionActiveOrg(
1136 projectId: string,
1137 sessionId: string,
1138): Promise<void> {
1139 await runInProjectDatabase(projectId, async (tx) => {
1140 await tx.unsafe(
1141 `DELETE FROM "_briven_auth_session_orgs" WHERE session_id = $1`,
1142 [sessionId],
1143 );
1144 });
1145}