index.ts1810 lines · main
1/**
2 * @briven/auth — drop-in authentication for briven projects.
3 *
4 * import { createBrivenAuth } from '@briven/auth';
5 *
6 * export const auth = createBrivenAuth({
7 * projectId: 'p_abc123',
8 * publicKey: 'pk_briven_auth_...',
9 * });
10 *
11 * const { ok, userId } = await auth.signIn.email({
12 * email: 'jane@example.com',
13 * password: '...',
14 * });
15 *
16 * Subpaths:
17 * - `@briven/auth` → vanilla fetch client (zero React deps)
18 * - `@briven/auth/react` → hooks + `<BrivenSignIn />` component
19 * - `@briven/auth/server` → Next.js App Router helpers
20 *
21 * Wire protocol: every request carries `x-briven-project-id: <projectId>`
22 * and `authorization: Bearer <publicKey>`. The api resolves the tenant
23 * from the header, pulls the right Better Auth instance from the pool,
24 * and forwards to Better Auth's internal handler. Cookies carry the
25 * session token (`Set-Cookie` on the api response; SDK uses
26 * `credentials: 'include'` so the browser stores it).
27 */
28
29export type OAuthProvider =
30 | 'google'
31 | 'github'
32 | 'discord'
33 | 'microsoft'
34 | 'apple'
35 | 'twitter'
36 | 'linkedin'
37 | 'gitlab'
38 | 'bitbucket'
39 | 'dropbox'
40 | 'facebook'
41 | 'spotify';
42
43export interface CreateBrivenAuthOptions {
44 /** briven project id (`p_<ulid>`). Required. */
45 readonly projectId: string;
46 /** SDK key issued from the dashboard's Auth → API Keys panel. Required. */
47 readonly publicKey: string;
48 /**
49 * Override for the api origin. Defaults to `https://api.briven.tech`.
50 * Useful for self-hosted briven installations or local dev.
51 */
52 readonly apiOrigin?: string;
53 /**
54 * Override for the hosted-pages base URL. Defaults to the API origin
55 * (`https://api.briven.tech`) so magic-link / hosted flows use a host
56 * with a valid public TLS cert. Per-project `*.auth.briven.tech` needs
57 * a wildcard cert; without it browsers block the link.
58 */
59 readonly authUrl?: string;
60 /**
61 * Optional fetch implementation. Defaults to `globalThis.fetch`. Tests
62 * pass a stub here; production code never needs to set this.
63 */
64 readonly fetch?: typeof globalThis.fetch;
65}
66
67export interface User {
68 readonly id: string;
69 readonly email: string;
70 readonly emailVerified: boolean;
71 readonly name: string | null;
72 readonly image: string | null;
73 readonly createdAt: string;
74}
75
76export interface Session {
77 readonly userId: string;
78 readonly expiresAt: string;
79}
80
81export interface ClientSession {
82 readonly id: string;
83 readonly userId: string;
84 readonly expiresAt: string;
85 readonly createdAt: string;
86 readonly userAgent: string | null;
87 readonly ipAddress: string | null;
88}
89
90export type SignInErrorCode =
91 | 'invalid_credentials'
92 | 'email_taken'
93 | 'weak_password'
94 | 'rate_limited'
95 | 'unverified_email'
96 | 'tenant_unresolved'
97 | 'network_error'
98 | 'unknown';
99
100export type SignInResult =
101 | { ok: true; userId: string; sessionExpiresAt: string }
102 /**
103 * Password (or other first factor) succeeded but the account has 2FA on.
104 * Caller must complete the challenge with `twoFactor.verify` (TOTP) or
105 * `twoFactor.verifyBackupCode` (single-use recovery codes). The interim
106 * two-factor cookie is already set via credentials: 'include'.
107 */
108 | { ok: true; twoFactorRequired: true }
109 | { ok: false; code: SignInErrorCode; message: string };
110
111/** True when sign-in fully completed (session ready), not merely 2FA-pending. */
112export function isFullySignedIn(
113 result: SignInResult,
114): result is { ok: true; userId: string; sessionExpiresAt: string } {
115 return result.ok === true && 'userId' in result;
116}
117
118export type SimpleResult =
119 | { ok: true }
120 | { ok: false; code: SignInErrorCode; message: string };
121
122export type MagicLinkResult = SimpleResult;
123export type OtpRequestResult = SimpleResult;
124export type PasswordResetResult = SimpleResult;
125
126export type SessionResponse =
127 | { authenticated: true; userId: string; expiresAt: string }
128 | { authenticated: false };
129
130export interface SignInEmailInput {
131 email: string;
132 password: string;
133}
134
135export interface SignUpEmailInput {
136 email: string;
137 password: string;
138 name?: string;
139}
140
141export interface MagicLinkInput {
142 email: string;
143 /** Optional URL the customer's app wants the user to land on post-verify. */
144 redirectTo?: string;
145}
146
147export interface OtpVerifyInput {
148 email: string;
149 otp: string;
150}
151
152export interface SocialInput {
153 provider: OAuthProvider;
154 /** Optional URL the customer's app wants the user to land on post-callback. */
155 redirectTo?: string;
156}
157
158export interface PasswordResetInput {
159 token: string;
160 newPassword: string;
161}
162
163export interface ChangePasswordInput {
164 currentPassword: string;
165 newPassword: string;
166}
167
168export interface UpdateUserInput {
169 name?: string;
170 image?: string;
171}
172
173// ─── Organizations (Phase 2) ──────────────────────────────────────────────
174
175export interface Org {
176 readonly id: string;
177 readonly name: string;
178 readonly slug: string;
179 readonly logo: string | null;
180 readonly metadata: Record<string, unknown>;
181 readonly createdAt: string;
182}
183
184export interface OrgMember {
185 readonly id: string;
186 readonly orgId: string;
187 readonly userId: string;
188 readonly role: 'owner' | 'admin' | 'member';
189 readonly createdAt: string;
190}
191
192export interface OrgInvite {
193 readonly id: string;
194 readonly orgId: string;
195 readonly email: string;
196 readonly role: 'owner' | 'admin' | 'member';
197 readonly token: string;
198 readonly expiresAt: string;
199 readonly invitedBy: string | null;
200 readonly acceptedAt: string | null;
201 readonly createdAt: string;
202}
203
204export type OrgResult<T> = { ok: true; data: T } | { ok: false; code: SignInErrorCode; message: string };
205
206// ─── Phase 4 — Organizations & B2B ────────────────────────────────────────
207
208export type OrgPermission =
209 | 'org:update'
210 | 'org:delete'
211 | 'member:add'
212 | 'member:remove'
213 | 'member:update_role'
214 | 'invite:create'
215 | 'invite:revoke'
216 | 'invite:list'
217 | 'domain:manage'
218 | 'request:approve'
219 | 'billing:view'
220 | 'billing:manage';
221
222export interface OrgRole {
223 readonly id: string;
224 readonly orgId: string;
225 readonly name: string;
226 readonly permissions: OrgPermission[];
227 readonly isSystem: boolean;
228 readonly createdAt: string;
229}
230
231export interface OrgDomain {
232 readonly id: string;
233 readonly orgId: string;
234 readonly domain: string;
235 readonly verificationToken: string;
236 readonly verifiedAt: string | null;
237 readonly autoJoinEnabled: boolean;
238 readonly createdAt: string;
239}
240
241export interface MembershipRequest {
242 readonly id: string;
243 readonly orgId: string;
244 readonly userId: string;
245 readonly status: 'pending' | 'approved' | 'rejected';
246 readonly message: string | null;
247 readonly requestedAt: string;
248 readonly resolvedAt: string | null;
249 readonly resolvedBy: string | null;
250 readonly createdAt: string;
251}
252
253// ─── Phase 5 — Enterprise SSO ─────────────────────────────────────────────
254
255export type SsoProviderType = 'saml' | 'oidc';
256
257export interface SsoConnection {
258 readonly id: string;
259 readonly name: string;
260 readonly providerType: SsoProviderType;
261 readonly domains: string[];
262 readonly jitEnabled: boolean;
263 readonly deactivatedAt: string | null;
264 readonly createdAt: string;
265}
266
267export interface Passkey {
268 readonly id: string;
269 readonly name?: string;
270 readonly userId: string;
271}
272
273// ─── Phase 3 — User metadata & emails ─────────────────────────────────────
274
275export interface UserMetadata {
276 readonly publicMetadata: Record<string, unknown>;
277}
278
279export interface UserEmail {
280 readonly id: string;
281 readonly email: string;
282 readonly verified: boolean;
283 readonly primary: boolean;
284 readonly createdAt: string;
285}
286
287export interface BrivenAuthClient {
288 readonly projectId: string;
289 readonly authUrl: string;
290 readonly apiOrigin: string;
291 readonly signIn: {
292 email(input: SignInEmailInput): Promise<SignInResult>;
293 magicLink(input: MagicLinkInput): Promise<MagicLinkResult>;
294 otpRequest(input: MagicLinkInput): Promise<OtpRequestResult>;
295 otpVerify(input: OtpVerifyInput): Promise<SignInResult>;
296 /** Builds the OAuth start URL. Caller redirects the browser to it. */
297 social(input: SocialInput): { redirectUrl: string };
298 /** Exchange a single-use sign-in token for a session. */
299 token(token: string): Promise<{ ok: true; expiresAt: string } | { ok: false; code: SignInErrorCode; message: string }>;
300 /**
301 * Sign in with username + password.
302 * Resolves the username to an email internally, then uses the standard
303 * email/password flow.
304 */
305 username(input: { username: string; password: string }): Promise<SignInResult>;
306 /**
307 * Exchange a testing token for a session.
308 * Bypasses bot protection, rate limiting, and MFA.
309 */
310 testToken(token: string): Promise<{ ok: true; expiresAt: string } | { ok: false; code: SignInErrorCode; message: string }>;
311 };
312 readonly signUp: {
313 email(input: SignUpEmailInput): Promise<SignInResult>;
314 };
315 sendPasswordReset(email: string): Promise<PasswordResetResult>;
316 resetPassword(input: PasswordResetInput): Promise<PasswordResetResult>;
317 readonly sessions: {
318 list(): Promise<{ ok: true; sessions: ClientSession[] } | { ok: false; code: SignInErrorCode; message: string }>;
319 revoke(sessionId: string): Promise<SimpleResult>;
320 };
321 readonly user: {
322 update(input: UpdateUserInput): Promise<{ ok: true; user: User } | { ok: false; code: SignInErrorCode; message: string }>;
323 changePassword(input: ChangePasswordInput): Promise<SimpleResult>;
324 delete(): Promise<SimpleResult>;
325 /** Get the current user's public metadata (frontend-safe). */
326 getMetadata(): Promise<
327 | { ok: true; publicMetadata: Record<string, unknown> }
328 | { ok: false; code: SignInErrorCode; message: string }
329 >;
330 /** Set (merge) the current user's public metadata. */
331 setMetadata(publicMetadata: Record<string, unknown>): Promise<
332 | { ok: true; publicMetadata: Record<string, unknown> }
333 | { ok: false; code: SignInErrorCode; message: string }
334 >;
335 /** List all additional emails for the current user. */
336 listEmails(): Promise<
337 | { ok: true; emails: UserEmail[] }
338 | { ok: false; code: SignInErrorCode; message: string }
339 >;
340 /** Add an additional email address. */
341 addEmail(email: string): Promise<
342 | { ok: true; email: UserEmail }
343 | { ok: false; code: SignInErrorCode; message: string }
344 >;
345 /** Remove an additional email address by id. */
346 removeEmail(emailId: string): Promise<SimpleResult>;
347 /**
348 * Get a presigned URL to upload an avatar image directly to S3.
349 * After uploading, call updateAvatar with the returned publicUrl.
350 */
351 getAvatarUploadUrl(contentType: string): Promise<
352 | { ok: true; uploadUrl: string; publicUrl: string }
353 | { ok: false; code: SignInErrorCode; message: string }
354 >;
355 /** Update the user's avatar image URL. Pass null to remove. */
356 updateAvatar(imageUrl: string | null): Promise<SimpleResult>;
357 /** Set or change the user's username. */
358 setUsername(username: string): Promise<SimpleResult>;
359 /** Get the user's username, or null if not set. */
360 getUsername(): Promise<
361 | { ok: true; username: string | null }
362 | { ok: false; code: SignInErrorCode; message: string }
363 >;
364 /** Remove the user's username. */
365 removeUsername(): Promise<SimpleResult>;
366 /**
367 * List all linked OAuth / SSO accounts for the current user.
368 */
369 listAccounts(): Promise<
370 | { ok: true; accounts: Array<{ id: string; providerId: string; accountId: string; createdAt: string }> }
371 | { ok: false; code: SignInErrorCode; message: string }
372 >;
373 };
374 readonly organization: {
375 create(input: { name: string; slug: string; logo?: string }): Promise<OrgResult<Org>>;
376 list(): Promise<OrgResult<Org[]>>;
377 get(orgId: string): Promise<OrgResult<Org>>;
378 update(orgId: string, input: { name?: string; slug?: string; logo?: string | null }): Promise<OrgResult<Org>>;
379 delete(orgId: string): Promise<SimpleResult>;
380 listMembers(orgId: string): Promise<OrgResult<OrgMember[]>>;
381 addMember(orgId: string, input: { userId: string; role?: 'admin' | 'member' }): Promise<OrgResult<OrgMember>>;
382 updateMemberRole(orgId: string, userId: string, role: 'admin' | 'member'): Promise<OrgResult<OrgMember>>;
383 removeMember(orgId: string, userId: string): Promise<SimpleResult>;
384 listInvites(orgId: string): Promise<OrgResult<OrgInvite[]>>;
385 createInvite(orgId: string, input: { email: string; role?: 'admin' | 'member' }): Promise<OrgResult<OrgInvite>>;
386 revokeInvite(orgId: string, inviteId: string): Promise<SimpleResult>;
387 acceptInvite(token: string): Promise<OrgResult<{ orgId: string }>>;
388 getInvite(token: string): Promise<OrgResult<OrgInvite>>;
389 // Phase 4 — Custom roles
390 listRoles(orgId: string): Promise<OrgResult<OrgRole[]>>;
391 createRole(orgId: string, input: { name: string; permissions: OrgPermission[] }): Promise<OrgResult<OrgRole>>;
392 updateRole(orgId: string, roleId: string, input: { name?: string; permissions?: OrgPermission[] }): Promise<OrgResult<OrgRole>>;
393 deleteRole(orgId: string, roleId: string): Promise<SimpleResult>;
394 // Phase 4 — Domain verification
395 listDomains(orgId: string): Promise<OrgResult<OrgDomain[]>>;
396 addDomain(orgId: string, domain: string): Promise<OrgResult<OrgDomain>>;
397 verifyDomain(orgId: string, domainId: string): Promise<OrgResult<OrgDomain>>;
398 setDomainAutoJoin(orgId: string, domainId: string, enabled: boolean): Promise<OrgResult<OrgDomain>>;
399 removeDomain(orgId: string, domainId: string): Promise<SimpleResult>;
400 // Phase 4 — Membership requests
401 createMembershipRequest(orgId: string, message?: string): Promise<OrgResult<MembershipRequest>>;
402 listMembershipRequests(orgId: string, status?: 'pending' | 'approved' | 'rejected'): Promise<OrgResult<MembershipRequest[]>>;
403 resolveMembershipRequest(orgId: string, requestId: string, decision: 'approved' | 'rejected'): Promise<OrgResult<MembershipRequest>>;
404 // Phase 4 — Active organization
405 setActive(orgId: string): Promise<SimpleResult>;
406 getActive(): Promise<OrgResult<Org | null>>;
407 };
408 readonly sso: {
409 /** List visible SSO connections (config stripped for security). */
410 listConnections(): Promise<
411 | { ok: true; connections: Array<Pick<SsoConnection, 'id' | 'name' | 'providerType' | 'domains'>> }
412 | { ok: false; code: SignInErrorCode; message: string }
413 >;
414 /** Find an SSO connection by email domain. */
415 getConnectionByDomain(domain: string): Promise<
416 | { ok: true; connection: Pick<SsoConnection, 'id' | 'name' | 'providerType' | 'domains'> }
417 | { ok: false; code: SignInErrorCode; message: string }
418 >;
419 /** Build the SAML/OIDC start URL. Caller redirects the browser to it. */
420 start(connectionId: string, redirectTo?: string, providerType?: 'saml' | 'oidc'): { redirectUrl: string };
421 };
422 readonly twoFactor: {
423 enable(password?: string): Promise<SimpleResult>;
424 /** Complete MFA enroll or sign-in challenge with a TOTP app code. */
425 verify(code: string): Promise<SignInResult>;
426 disable(password?: string): Promise<SimpleResult>;
427 /**
428 * Mint a new set of single-use recovery codes (invalidates old ones).
429 * Better Auth requires the account password unless passwordless backup
430 * generation is enabled on the tenant.
431 */
432 generateBackupCodes(
433 password?: string,
434 ): Promise<{ ok: true; codes: string[] } | { ok: false; code: SignInErrorCode; message: string }>;
435 /**
436 * Sign in using a single-use backup/recovery code when the TOTP device
437 * is lost. Consumes the code on success. This is the account-recovery
438 * path that prevents permanent lockout.
439 */
440 verifyBackupCode(code: string): Promise<SignInResult>;
441 };
442 readonly passkey: {
443 /** Enrol a passkey (requires active session). Optional display name. */
444 register(name?: string): Promise<SimpleResult>;
445 list(): Promise<{ ok: true; passkeys: Passkey[] } | { ok: false; code: SignInErrorCode; message: string }>;
446 signIn(): Promise<SignInResult>;
447 };
448 readonly impersonate: {
449 /** Check if the current session is an impersonation session. */
450 status(): Promise<
451 | { ok: true; impersonating: true; impersonatedBy: string; targetUserId: string }
452 | { ok: true; impersonating: false }
453 | { ok: false; code: SignInErrorCode; message: string }
454 >;
455 /** Stop the current impersonation session. */
456 stop(sessionToken: string): Promise<SimpleResult>;
457 };
458 readonly jwt: {
459 /**
460 * Generate a signed JWT for the current session.
461 * Optionally pass a template name to include custom claims.
462 */
463 getToken(input?: { template?: string }): Promise<
464 | { ok: true; token: string; expiresAt: string }
465 | { ok: false; code: SignInErrorCode; message: string }
466 >;
467 };
468 signOut(): Promise<{ ok: boolean }>;
469 getSession(): Promise<SessionResponse>;
470 getUser(): Promise<User | null>;
471 /**
472 * Build a hosted auth page URL for the given flow.
473 * The customer's app redirects the browser to this URL so auth happens
474 * same-origin on Briven's hosted pages, eliminating cross-origin/CORS
475 * issues on localhost and custom domains.
476 *
477 * @param flow - which hosted page to open
478 * @param callbackURL - where to send the user after successful auth
479 * @param locale - optional BCP 47 locale override (e.g. 'nl', 'fr-FR')
480 */
481 hostedPageURL(
482 flow: 'sign-in' | 'sign-up' | 'magic-link' | 'otp' | 'new-password' | 'profile',
483 callbackURL?: string,
484 locale?: string,
485 ): string;
486}
487
488const DEFAULT_API_ORIGIN = 'https://api.briven.tech';
489const BRIDGE_PREFIX = '/v1/auth-tenant';
490
491/* ── WebAuthn helpers (Better Auth returns PublicKeyCredential*OptionsJSON) ─ */
492
493function bufferToBase64Url(buf: ArrayBuffer): string {
494 const bytes = new Uint8Array(buf);
495 let s = '';
496 for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]!);
497 return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
498}
499
500function base64UrlToBuffer(b64url: string): ArrayBuffer {
501 const pad = '='.repeat((4 - (b64url.length % 4)) % 4);
502 const b64 = (b64url + pad).replace(/-/g, '+').replace(/_/g, '/');
503 const bin = atob(b64);
504 const bytes = new Uint8Array(bin.length);
505 for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
506 return bytes.buffer;
507}
508
509function publicKeyRequestOptionsFromJson(
510 options: Record<string, unknown>,
511): PublicKeyCredentialRequestOptions {
512 const allow = Array.isArray(options.allowCredentials)
513 ? (options.allowCredentials as Array<Record<string, unknown>>).map((c) => ({
514 type: (c.type as PublicKeyCredentialType) ?? 'public-key',
515 id: typeof c.id === 'string' ? base64UrlToBuffer(c.id) : (c.id as ArrayBuffer),
516 transports: c.transports as AuthenticatorTransport[] | undefined,
517 }))
518 : undefined;
519 return {
520 challenge: base64UrlToBuffer(String(options.challenge)),
521 timeout: typeof options.timeout === 'number' ? options.timeout : undefined,
522 rpId: typeof options.rpId === 'string' ? options.rpId : undefined,
523 allowCredentials: allow,
524 userVerification: options.userVerification as UserVerificationRequirement | undefined,
525 };
526}
527
528function publicKeyCreateOptionsFromJson(
529 options: Record<string, unknown>,
530): PublicKeyCredentialCreationOptions {
531 const rp = (options.rp ?? {}) as { name?: string; id?: string };
532 const user = (options.user ?? {}) as { id?: string; name?: string; displayName?: string };
533 const exclude = Array.isArray(options.excludeCredentials)
534 ? (options.excludeCredentials as Array<Record<string, unknown>>).map((c) => ({
535 type: (c.type as PublicKeyCredentialType) ?? 'public-key',
536 id: typeof c.id === 'string' ? base64UrlToBuffer(c.id) : (c.id as ArrayBuffer),
537 transports: c.transports as AuthenticatorTransport[] | undefined,
538 }))
539 : undefined;
540 const pubKeyCredParams = Array.isArray(options.pubKeyCredParams)
541 ? (options.pubKeyCredParams as PublicKeyCredentialParameters[])
542 : [{ type: 'public-key' as const, alg: -7 }];
543 const userIdRaw = typeof user.id === 'string' && user.id.length > 0 ? user.id : 'user';
544 return {
545 rp: { name: rp.name ?? 'briven', id: rp.id },
546 user: {
547 id: base64UrlToBuffer(userIdRaw),
548 name: user.name ?? 'user',
549 displayName: user.displayName ?? user.name ?? 'user',
550 },
551 challenge: base64UrlToBuffer(String(options.challenge)),
552 pubKeyCredParams,
553 timeout: typeof options.timeout === 'number' ? options.timeout : undefined,
554 excludeCredentials: exclude,
555 authenticatorSelection:
556 options.authenticatorSelection as AuthenticatorSelectionCriteria | undefined,
557 attestation: options.attestation as AttestationConveyancePreference | undefined,
558 };
559}
560
561/**
562 * Construct the SDK client. Stateless — all auth state lives in the
563 * browser cookie set by the api on successful sign-in. Re-creating the
564 * client across renders is safe.
565 */
566export function createBrivenAuth(opts: CreateBrivenAuthOptions): BrivenAuthClient {
567 if (!opts.projectId) throw new Error('@briven/auth: projectId is required');
568 if (!opts.publicKey) throw new Error('@briven/auth: publicKey is required');
569 const apiOrigin = opts.apiOrigin ?? DEFAULT_API_ORIGIN;
570 // Prefer API host (valid TLS) over p_….auth.briven.tech until wildcard cert is live.
571 const authUrl = opts.authUrl ?? apiOrigin;
572 const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
573
574 async function post<T>(path: string, body: Record<string, unknown> | null): Promise<T> {
575 const res = await fetchImpl(`${apiOrigin}${BRIDGE_PREFIX}${path}`, {
576 method: 'POST',
577 credentials: 'include',
578 headers: {
579 'content-type': 'application/json',
580 'x-briven-project-id': opts.projectId,
581 authorization: `Bearer ${opts.publicKey}`,
582 },
583 body: body === null ? undefined : JSON.stringify(body),
584 });
585 return (await res.json()) as T;
586 }
587
588 /**
589 * POST that surfaces HTTP status. Needed for magic-link / OTP send so a 404
590 * or 500 is not reported as ok:true (Better Auth returns empty/error JSON).
591 */
592 async function postRaw(
593 path: string,
594 body: Record<string, unknown> | null,
595 ): Promise<{ ok: boolean; status: number; json: unknown; message?: string }> {
596 const res = await fetchImpl(`${apiOrigin}${BRIDGE_PREFIX}${path}`, {
597 method: 'POST',
598 credentials: 'include',
599 headers: {
600 'content-type': 'application/json',
601 'x-briven-project-id': opts.projectId,
602 authorization: `Bearer ${opts.publicKey}`,
603 },
604 body: body === null ? undefined : JSON.stringify(body),
605 });
606 let json: unknown = null;
607 try {
608 json = await res.json();
609 } catch {
610 json = null;
611 }
612 const message =
613 json && typeof json === 'object'
614 ? String(
615 (json as { message?: string; error?: { message?: string } }).message ??
616 (json as { error?: { message?: string } }).error?.message ??
617 '',
618 ) || undefined
619 : undefined;
620 return { ok: res.ok, status: res.status, json, message };
621 }
622
623 async function get<T>(path: string): Promise<T> {
624 const res = await fetchImpl(`${apiOrigin}${BRIDGE_PREFIX}${path}`, {
625 credentials: 'include',
626 headers: {
627 'x-briven-project-id': opts.projectId,
628 authorization: `Bearer ${opts.publicKey}`,
629 },
630 });
631 return (await res.json()) as T;
632 }
633
634 async function patch<T>(path: string, body: Record<string, unknown>): Promise<T> {
635 const res = await fetchImpl(`${apiOrigin}${BRIDGE_PREFIX}${path}`, {
636 method: 'PATCH',
637 credentials: 'include',
638 headers: {
639 'content-type': 'application/json',
640 'x-briven-project-id': opts.projectId,
641 authorization: `Bearer ${opts.publicKey}`,
642 },
643 body: JSON.stringify(body),
644 });
645 return (await res.json()) as T;
646 }
647
648 function asSignInResult(body: unknown): SignInResult {
649 if (body && typeof body === 'object') {
650 const b = body as {
651 user?: { id?: string };
652 token?: string;
653 expiresAt?: string;
654 session?: { expiresAt?: string };
655 twoFactorRedirect?: boolean;
656 error?: { code?: string; message?: string };
657 code?: string;
658 message?: string;
659 };
660 // Better Auth signals "password ok, now finish 2FA" this way.
661 if (b.twoFactorRedirect === true) {
662 return { ok: true, twoFactorRequired: true };
663 }
664 if (b.user?.id) {
665 const expiresAt =
666 b.expiresAt ??
667 b.session?.expiresAt ??
668 new Date(Date.now() + 7 * 86_400_000).toISOString();
669 return { ok: true, userId: b.user.id, sessionExpiresAt: expiresAt };
670 }
671 const code = (b.error?.code ?? b.code ?? 'unknown') as SignInErrorCode;
672 const message = b.error?.message ?? b.message ?? 'sign-in failed';
673 return { ok: false, code: knownCode(code), message };
674 }
675 return { ok: false, code: 'unknown', message: 'sign-in failed' };
676 }
677
678 function asSimpleResult(body: unknown): SimpleResult {
679 if (body && typeof body === 'object') {
680 const b = body as {
681 status?: boolean;
682 error?: { code?: string; message?: string };
683 code?: string;
684 message?: string;
685 };
686 if (b.status === true) {
687 return { ok: true };
688 }
689 const code = (b.error?.code ?? b.code ?? 'unknown') as SignInErrorCode;
690 const message = b.error?.message ?? b.message ?? 'request failed';
691 return { ok: false, code: knownCode(code), message };
692 }
693 return { ok: false, code: 'unknown', message: 'request failed' };
694 }
695
696 return {
697 projectId: opts.projectId,
698 authUrl,
699 apiOrigin,
700 signIn: {
701 async email(input) {
702 try {
703 const body = await post<unknown>('/sign-in/email', input as unknown as Record<string, unknown>);
704 return asSignInResult(body);
705 } catch {
706 return { ok: false, code: 'network_error', message: 'network error' };
707 }
708 },
709 async magicLink(input) {
710 try {
711 // Better Auth field is `callbackURL` (not redirectTo). Map the SDK name.
712 const body = await postRaw('/sign-in/magic-link', {
713 email: input.email,
714 ...(input.redirectTo ? { callbackURL: input.redirectTo } : {}),
715 });
716 if (!body.ok) {
717 return {
718 ok: false as const,
719 code: body.status === 429 ? ('rate_limited' as const) : ('unknown' as const),
720 message: body.message ?? 'magic link send failed',
721 };
722 }
723 return { ok: true as const };
724 } catch {
725 return { ok: false, code: 'network_error', message: 'network error' };
726 }
727 },
728 async otpRequest(input) {
729 try {
730 // Better Auth emailOTP: POST /email-otp/send-verification-otp
731 // (NOT /sign-in/email-otp/send-verification-otp — that path is 404).
732 const body = await postRaw('/email-otp/send-verification-otp', {
733 email: input.email,
734 type: 'sign-in',
735 });
736 if (!body.ok) {
737 return {
738 ok: false as const,
739 code: body.status === 429 ? ('rate_limited' as const) : ('unknown' as const),
740 message: body.message ?? 'otp send failed',
741 };
742 }
743 return { ok: true as const };
744 } catch {
745 return { ok: false, code: 'network_error', message: 'network error' };
746 }
747 },
748 async otpVerify(input) {
749 try {
750 // Better Auth emailOTP sign-in: POST /sign-in/email-otp with { email, otp }
751 // (NOT /sign-in/email-otp/verify — that path is 404).
752 const body = await postRaw('/sign-in/email-otp', {
753 email: input.email,
754 otp: input.otp,
755 });
756 if (!body.ok) {
757 return {
758 ok: false as const,
759 code:
760 body.status === 429
761 ? ('rate_limited' as const)
762 : body.status === 400 || body.status === 401
763 ? ('invalid_credentials' as const)
764 : ('unknown' as const),
765 message: body.message ?? 'otp verify failed',
766 };
767 }
768 return asSignInResult(body.json);
769 } catch {
770 return { ok: false, code: 'network_error', message: 'network error' };
771 }
772 },
773 social(input) {
774 const u = new URL(`${apiOrigin}${BRIDGE_PREFIX}/sign-in/social`);
775 u.searchParams.set('provider', input.provider);
776 u.searchParams.set('callbackURL', input.redirectTo ?? authUrl);
777 u.searchParams.set('projectId', opts.projectId);
778 return { redirectUrl: u.toString() };
779 },
780 async token(token) {
781 try {
782 const body = await post<unknown>('/sign-in/token', { token });
783 if (body && typeof body === 'object') {
784 const b = body as { ok?: boolean; expiresAt?: string; error?: { code?: string; message?: string }; code?: string; message?: string };
785 if (b.ok === true && b.expiresAt) {
786 return { ok: true as const, expiresAt: b.expiresAt };
787 }
788 const code = (b.error?.code ?? b.code ?? 'unknown') as SignInErrorCode;
789 const message = b.error?.message ?? b.message ?? 'token exchange failed';
790 return { ok: false as const, code: knownCode(code), message };
791 }
792 return { ok: false as const, code: 'unknown' as const, message: 'token exchange failed' };
793 } catch {
794 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
795 }
796 },
797 async username(input) {
798 try {
799 const body = await post<unknown>('/username/sign-in', input);
800 return asSignInResult(body);
801 } catch {
802 return { ok: false, code: 'network_error', message: 'network error' };
803 }
804 },
805 async testToken(token) {
806 try {
807 const body = await post<unknown>('/test-token', { token });
808 if (body && typeof body === 'object') {
809 const b = body as { ok?: boolean; expiresAt?: string; code?: string; message?: string };
810 if (b.ok === true && b.expiresAt) {
811 return { ok: true as const, expiresAt: b.expiresAt };
812 }
813 return { ok: false as const, code: knownCode(b.code ?? 'unknown'), message: b.message ?? 'exchange failed' };
814 }
815 return { ok: false as const, code: 'unknown' as const, message: 'exchange failed' };
816 } catch {
817 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
818 }
819 },
820 },
821 signUp: {
822 async email(input) {
823 try {
824 const body = await post<unknown>('/sign-up/email', input as unknown as Record<string, unknown>);
825 return asSignInResult(body);
826 } catch {
827 return { ok: false, code: 'network_error', message: 'network error' };
828 }
829 },
830 },
831 async sendPasswordReset(email) {
832 try {
833 const body = await post<unknown>('/request-password-reset', { email });
834 return asSimpleResult(body);
835 } catch {
836 return { ok: false, code: 'network_error', message: 'network error' };
837 }
838 },
839 async resetPassword(input) {
840 try {
841 const body = await post<unknown>('/reset-password', input as unknown as Record<string, unknown>);
842 return asSimpleResult(body);
843 } catch {
844 return { ok: false, code: 'network_error', message: 'network error' };
845 }
846 },
847 sessions: {
848 async list() {
849 try {
850 const body = await get<unknown>('/list-sessions');
851 if (body && typeof body === 'object') {
852 const b = body as { sessions?: ClientSession[]; error?: { code?: string; message?: string } };
853 if (Array.isArray(b.sessions)) {
854 return { ok: true, sessions: b.sessions };
855 }
856 return {
857 ok: false,
858 code: knownCode(b.error?.code ?? 'unknown'),
859 message: b.error?.message ?? 'failed to list sessions',
860 };
861 }
862 return { ok: false, code: 'unknown', message: 'failed to list sessions' };
863 } catch {
864 return { ok: false, code: 'network_error', message: 'network error' };
865 }
866 },
867 async revoke(sessionId) {
868 try {
869 const body = await post<unknown>('/revoke-session', { sessionId });
870 return asSimpleResult(body);
871 } catch {
872 return { ok: false, code: 'network_error', message: 'network error' };
873 }
874 },
875 },
876 user: {
877 async update(input) {
878 try {
879 const body = await patch<unknown>('/update-user', input as unknown as Record<string, unknown>);
880 if (body && typeof body === 'object') {
881 const b = body as { user?: User; error?: { code?: string; message?: string } };
882 if (b.user) return { ok: true, user: b.user };
883 return {
884 ok: false,
885 code: knownCode(b.error?.code ?? 'unknown'),
886 message: b.error?.message ?? 'update failed',
887 };
888 }
889 return { ok: false, code: 'unknown', message: 'update failed' };
890 } catch {
891 return { ok: false, code: 'network_error', message: 'network error' };
892 }
893 },
894 async changePassword(input) {
895 try {
896 const body = await post<unknown>('/change-password', input as unknown as Record<string, unknown>);
897 return asSimpleResult(body);
898 } catch {
899 return { ok: false, code: 'network_error', message: 'network error' };
900 }
901 },
902 async delete() {
903 try {
904 const body = await post<unknown>('/delete-user', {});
905 return asSimpleResult(body);
906 } catch {
907 return { ok: false, code: 'network_error', message: 'network error' };
908 }
909 },
910 async getMetadata() {
911 try {
912 const body = await get<unknown>('/user/metadata');
913 if (body && typeof body === 'object') {
914 const b = body as { publicMetadata?: Record<string, unknown>; error?: { code?: string; message?: string } };
915 if (b.publicMetadata !== undefined) {
916 return { ok: true as const, publicMetadata: b.publicMetadata };
917 }
918 return {
919 ok: false as const,
920 code: knownCode(b.error?.code ?? 'unknown'),
921 message: b.error?.message ?? 'failed to get metadata',
922 };
923 }
924 return { ok: false as const, code: 'unknown' as const, message: 'failed to get metadata' };
925 } catch {
926 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
927 }
928 },
929 async setMetadata(publicMetadata) {
930 try {
931 const body = await patch<unknown>('/user/metadata', { publicMetadata });
932 if (body && typeof body === 'object') {
933 const b = body as { publicMetadata?: Record<string, unknown>; error?: { code?: string; message?: string } };
934 if (b.publicMetadata !== undefined) {
935 return { ok: true as const, publicMetadata: b.publicMetadata };
936 }
937 return {
938 ok: false as const,
939 code: knownCode(b.error?.code ?? 'unknown'),
940 message: b.error?.message ?? 'failed to set metadata',
941 };
942 }
943 return { ok: false as const, code: 'unknown' as const, message: 'failed to set metadata' };
944 } catch {
945 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
946 }
947 },
948 async listEmails() {
949 try {
950 const body = await get<unknown>('/user/emails');
951 if (body && typeof body === 'object') {
952 const b = body as { emails?: UserEmail[]; error?: { code?: string; message?: string } };
953 if (Array.isArray(b.emails)) {
954 return { ok: true as const, emails: b.emails };
955 }
956 return {
957 ok: false as const,
958 code: knownCode(b.error?.code ?? 'unknown'),
959 message: b.error?.message ?? 'failed to list emails',
960 };
961 }
962 return { ok: false as const, code: 'unknown' as const, message: 'failed to list emails' };
963 } catch {
964 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
965 }
966 },
967 async addEmail(email) {
968 try {
969 const body = await post<unknown>('/user/emails', { email });
970 if (body && typeof body === 'object') {
971 const b = body as { email?: UserEmail; error?: { code?: string; message?: string } };
972 if (b.email) {
973 return { ok: true as const, email: b.email };
974 }
975 return {
976 ok: false as const,
977 code: knownCode(b.error?.code ?? 'unknown'),
978 message: b.error?.message ?? 'failed to add email',
979 };
980 }
981 return { ok: false as const, code: 'unknown' as const, message: 'failed to add email' };
982 } catch {
983 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
984 }
985 },
986 async removeEmail(emailId) {
987 try {
988 const res = await fetchImpl(`${apiOrigin}${BRIDGE_PREFIX}/user/emails/${emailId}`, {
989 method: 'DELETE',
990 credentials: 'include',
991 headers: {
992 'x-briven-project-id': opts.projectId,
993 authorization: `Bearer ${opts.publicKey}`,
994 },
995 });
996 const body = (await res.json()) as unknown;
997 return asSimpleResult(body);
998 } catch {
999 return { ok: false, code: 'network_error', message: 'network error' };
1000 }
1001 },
1002 async getAvatarUploadUrl(contentType) {
1003 try {
1004 const body = await post<unknown>('/user/avatar/presign', { contentType });
1005 if (body && typeof body === 'object') {
1006 const b = body as { uploadUrl?: string; publicUrl?: string; code?: string; message?: string };
1007 if (typeof b.uploadUrl === 'string' && typeof b.publicUrl === 'string') {
1008 return { ok: true as const, uploadUrl: b.uploadUrl, publicUrl: b.publicUrl };
1009 }
1010 if (b.code) {
1011 return { ok: false as const, code: knownCode(b.code), message: b.message ?? 'presign failed' };
1012 }
1013 }
1014 return { ok: false as const, code: 'unknown' as const, message: 'presign failed' };
1015 } catch {
1016 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
1017 }
1018 },
1019 async updateAvatar(imageUrl) {
1020 try {
1021 const body = await post<unknown>('/user/avatar', { imageUrl });
1022 return asSimpleResult(body);
1023 } catch {
1024 return { ok: false, code: 'network_error', message: 'network error' };
1025 }
1026 },
1027 async setUsername(username) {
1028 try {
1029 const body = await post<unknown>('/username', { username });
1030 return asSimpleResult(body);
1031 } catch {
1032 return { ok: false, code: 'network_error', message: 'network error' };
1033 }
1034 },
1035 async getUsername() {
1036 try {
1037 const body = await get<unknown>('/username');
1038 if (body && typeof body === 'object') {
1039 const b = body as { username?: string | null; code?: string; message?: string };
1040 if ('username' in b) {
1041 return { ok: true as const, username: b.username ?? null };
1042 }
1043 if (b.code) {
1044 return { ok: false as const, code: knownCode(b.code), message: b.message ?? 'failed' };
1045 }
1046 }
1047 return { ok: false as const, code: 'unknown' as const, message: 'failed' };
1048 } catch {
1049 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
1050 }
1051 },
1052 async removeUsername() {
1053 try {
1054 const res = await fetchImpl(`${apiOrigin}${BRIDGE_PREFIX}/username`, {
1055 method: 'DELETE',
1056 credentials: 'include',
1057 headers: {
1058 'x-briven-project-id': opts.projectId,
1059 authorization: `Bearer ${opts.publicKey}`,
1060 },
1061 });
1062 const body = (await res.json()) as unknown;
1063 return asSimpleResult(body);
1064 } catch {
1065 return { ok: false, code: 'network_error', message: 'network error' };
1066 }
1067 },
1068 async listAccounts() {
1069 try {
1070 const body = await get<unknown>('/user/accounts');
1071 if (body && typeof body === 'object') {
1072 const b = body as {
1073 accounts?: Array<{ id: string; providerId: string; accountId: string; createdAt: string }>;
1074 code?: string;
1075 message?: string;
1076 };
1077 if (Array.isArray(b.accounts)) {
1078 return { ok: true as const, accounts: b.accounts };
1079 }
1080 if (b.code) {
1081 return { ok: false as const, code: knownCode(b.code), message: b.message ?? 'failed' };
1082 }
1083 }
1084 return { ok: false as const, code: 'unknown' as const, message: 'failed' };
1085 } catch {
1086 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
1087 }
1088 },
1089 },
1090 organization: {
1091 async create(input) {
1092 try {
1093 const body = await post<unknown>('/orgs', input as unknown as Record<string, unknown>);
1094 if (body && typeof body === 'object') {
1095 const b = body as { org?: Org; error?: { code?: string; message?: string } };
1096 if (b.org) return { ok: true, data: b.org };
1097 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'create failed' };
1098 }
1099 return { ok: false, code: 'unknown', message: 'create failed' };
1100 } catch {
1101 return { ok: false, code: 'network_error', message: 'network error' };
1102 }
1103 },
1104 async list() {
1105 try {
1106 const body = await get<unknown>('/orgs');
1107 if (body && typeof body === 'object') {
1108 const b = body as { orgs?: Org[]; error?: { code?: string; message?: string } };
1109 if (Array.isArray(b.orgs)) return { ok: true, data: b.orgs };
1110 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'list failed' };
1111 }
1112 return { ok: false, code: 'unknown', message: 'list failed' };
1113 } catch {
1114 return { ok: false, code: 'network_error', message: 'network error' };
1115 }
1116 },
1117 async get(orgId) {
1118 try {
1119 const body = await get<unknown>(`/orgs/${orgId}`);
1120 if (body && typeof body === 'object') {
1121 const b = body as { org?: Org; error?: { code?: string; message?: string } };
1122 if (b.org) return { ok: true, data: b.org };
1123 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'get failed' };
1124 }
1125 return { ok: false, code: 'unknown', message: 'get failed' };
1126 } catch {
1127 return { ok: false, code: 'network_error', message: 'network error' };
1128 }
1129 },
1130 async update(orgId, input) {
1131 try {
1132 const body = await patch<unknown>(`/orgs/${orgId}`, input as unknown as Record<string, unknown>);
1133 if (body && typeof body === 'object') {
1134 const b = body as { org?: Org; error?: { code?: string; message?: string } };
1135 if (b.org) return { ok: true, data: b.org };
1136 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'update failed' };
1137 }
1138 return { ok: false, code: 'unknown', message: 'update failed' };
1139 } catch {
1140 return { ok: false, code: 'network_error', message: 'network error' };
1141 }
1142 },
1143 async delete(orgId) {
1144 try {
1145 const body = await post<unknown>(`/orgs/${orgId}/delete`, {});
1146 return asSimpleResult(body);
1147 } catch {
1148 return { ok: false, code: 'network_error', message: 'network error' };
1149 }
1150 },
1151 async listMembers(orgId) {
1152 try {
1153 const body = await get<unknown>(`/orgs/${orgId}/members`);
1154 if (body && typeof body === 'object') {
1155 const b = body as { members?: OrgMember[]; error?: { code?: string; message?: string } };
1156 if (Array.isArray(b.members)) return { ok: true, data: b.members };
1157 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'list failed' };
1158 }
1159 return { ok: false, code: 'unknown', message: 'list failed' };
1160 } catch {
1161 return { ok: false, code: 'network_error', message: 'network error' };
1162 }
1163 },
1164 async addMember(orgId, input) {
1165 try {
1166 const body = await post<unknown>(`/orgs/${orgId}/members`, input as unknown as Record<string, unknown>);
1167 if (body && typeof body === 'object') {
1168 const b = body as { member?: OrgMember; error?: { code?: string; message?: string } };
1169 if (b.member) return { ok: true, data: b.member };
1170 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'add failed' };
1171 }
1172 return { ok: false, code: 'unknown', message: 'add failed' };
1173 } catch {
1174 return { ok: false, code: 'network_error', message: 'network error' };
1175 }
1176 },
1177 async updateMemberRole(orgId, userId, role) {
1178 try {
1179 const body = await patch<unknown>(`/orgs/${orgId}/members/${userId}`, { role });
1180 if (body && typeof body === 'object') {
1181 const b = body as { member?: OrgMember; error?: { code?: string; message?: string } };
1182 if (b.member) return { ok: true, data: b.member };
1183 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'update failed' };
1184 }
1185 return { ok: false, code: 'unknown', message: 'update failed' };
1186 } catch {
1187 return { ok: false, code: 'network_error', message: 'network error' };
1188 }
1189 },
1190 async removeMember(orgId, userId) {
1191 try {
1192 const body = await post<unknown>(`/orgs/${orgId}/members/${userId}/delete`, {});
1193 return asSimpleResult(body);
1194 } catch {
1195 return { ok: false, code: 'network_error', message: 'network error' };
1196 }
1197 },
1198 async listInvites(orgId) {
1199 try {
1200 const body = await get<unknown>(`/orgs/${orgId}/invites`);
1201 if (body && typeof body === 'object') {
1202 const b = body as { invites?: OrgInvite[]; error?: { code?: string; message?: string } };
1203 if (Array.isArray(b.invites)) return { ok: true, data: b.invites };
1204 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'list failed' };
1205 }
1206 return { ok: false, code: 'unknown', message: 'list failed' };
1207 } catch {
1208 return { ok: false, code: 'network_error', message: 'network error' };
1209 }
1210 },
1211 async createInvite(orgId, input) {
1212 try {
1213 const body = await post<unknown>(`/orgs/${orgId}/invites`, input as unknown as Record<string, unknown>);
1214 if (body && typeof body === 'object') {
1215 const b = body as { invite?: OrgInvite; error?: { code?: string; message?: string } };
1216 if (b.invite) return { ok: true, data: b.invite };
1217 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'create failed' };
1218 }
1219 return { ok: false, code: 'unknown', message: 'create failed' };
1220 } catch {
1221 return { ok: false, code: 'network_error', message: 'network error' };
1222 }
1223 },
1224 async revokeInvite(orgId, inviteId) {
1225 try {
1226 const body = await post<unknown>(`/orgs/${orgId}/invites/${inviteId}/delete`, {});
1227 return asSimpleResult(body);
1228 } catch {
1229 return { ok: false, code: 'network_error', message: 'network error' };
1230 }
1231 },
1232 async acceptInvite(token) {
1233 try {
1234 const body = await post<unknown>('/invites/accept', { token });
1235 if (body && typeof body === 'object') {
1236 const b = body as { orgId?: string; error?: { code?: string; message?: string } };
1237 if (b.orgId) return { ok: true, data: { orgId: b.orgId } };
1238 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'accept failed' };
1239 }
1240 return { ok: false, code: 'unknown', message: 'accept failed' };
1241 } catch {
1242 return { ok: false, code: 'network_error', message: 'network error' };
1243 }
1244 },
1245 async getInvite(token) {
1246 try {
1247 const body = await get<unknown>(`/invites/${token}`);
1248 if (body && typeof body === 'object') {
1249 const b = body as { invite?: OrgInvite; error?: { code?: string; message?: string } };
1250 if (b.invite) return { ok: true, data: b.invite };
1251 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'get failed' };
1252 }
1253 return { ok: false, code: 'unknown', message: 'get failed' };
1254 } catch {
1255 return { ok: false, code: 'network_error', message: 'network error' };
1256 }
1257 },
1258 // Phase 4 — Custom roles
1259 async listRoles(orgId) {
1260 try {
1261 const body = await get<unknown>(`/orgs/${orgId}/roles`);
1262 if (body && typeof body === 'object') {
1263 const b = body as { roles?: OrgRole[]; error?: { code?: string; message?: string } };
1264 if (Array.isArray(b.roles)) return { ok: true, data: b.roles };
1265 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'list failed' };
1266 }
1267 return { ok: false, code: 'unknown', message: 'list failed' };
1268 } catch {
1269 return { ok: false, code: 'network_error', message: 'network error' };
1270 }
1271 },
1272 async createRole(orgId, input) {
1273 try {
1274 const body = await post<unknown>(`/orgs/${orgId}/roles`, input as unknown as Record<string, unknown>);
1275 if (body && typeof body === 'object') {
1276 const b = body as { role?: OrgRole; error?: { code?: string; message?: string } };
1277 if (b.role) return { ok: true, data: b.role };
1278 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'create failed' };
1279 }
1280 return { ok: false, code: 'unknown', message: 'create failed' };
1281 } catch {
1282 return { ok: false, code: 'network_error', message: 'network error' };
1283 }
1284 },
1285 async updateRole(orgId, roleId, input) {
1286 try {
1287 const body = await patch<unknown>(`/orgs/${orgId}/roles/${roleId}`, input as unknown as Record<string, unknown>);
1288 if (body && typeof body === 'object') {
1289 const b = body as { role?: OrgRole; error?: { code?: string; message?: string } };
1290 if (b.role) return { ok: true, data: b.role };
1291 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'update failed' };
1292 }
1293 return { ok: false, code: 'unknown', message: 'update failed' };
1294 } catch {
1295 return { ok: false, code: 'network_error', message: 'network error' };
1296 }
1297 },
1298 async deleteRole(orgId, roleId) {
1299 try {
1300 const body = await post<unknown>(`/orgs/${orgId}/roles/${roleId}/delete`, {});
1301 return asSimpleResult(body);
1302 } catch {
1303 return { ok: false, code: 'network_error', message: 'network error' };
1304 }
1305 },
1306 // Phase 4 — Domain verification
1307 async listDomains(orgId) {
1308 try {
1309 const body = await get<unknown>(`/orgs/${orgId}/domains`);
1310 if (body && typeof body === 'object') {
1311 const b = body as { domains?: OrgDomain[]; error?: { code?: string; message?: string } };
1312 if (Array.isArray(b.domains)) return { ok: true, data: b.domains };
1313 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'list failed' };
1314 }
1315 return { ok: false, code: 'unknown', message: 'list failed' };
1316 } catch {
1317 return { ok: false, code: 'network_error', message: 'network error' };
1318 }
1319 },
1320 async addDomain(orgId, domain) {
1321 try {
1322 const body = await post<unknown>(`/orgs/${orgId}/domains`, { domain });
1323 if (body && typeof body === 'object') {
1324 const b = body as { domain?: OrgDomain; error?: { code?: string; message?: string } };
1325 if (b.domain) return { ok: true, data: b.domain };
1326 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'add failed' };
1327 }
1328 return { ok: false, code: 'unknown', message: 'add failed' };
1329 } catch {
1330 return { ok: false, code: 'network_error', message: 'network error' };
1331 }
1332 },
1333 async verifyDomain(orgId, domainId) {
1334 try {
1335 const body = await post<unknown>(`/orgs/${orgId}/domains/${domainId}/verify`, {});
1336 if (body && typeof body === 'object') {
1337 const b = body as { domain?: OrgDomain; error?: { code?: string; message?: string } };
1338 if (b.domain) return { ok: true, data: b.domain };
1339 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'verify failed' };
1340 }
1341 return { ok: false, code: 'unknown', message: 'verify failed' };
1342 } catch {
1343 return { ok: false, code: 'network_error', message: 'network error' };
1344 }
1345 },
1346 async setDomainAutoJoin(orgId, domainId, enabled) {
1347 try {
1348 const body = await patch<unknown>(`/orgs/${orgId}/domains/${domainId}/auto-join`, { enabled });
1349 if (body && typeof body === 'object') {
1350 const b = body as { domain?: OrgDomain; error?: { code?: string; message?: string } };
1351 if (b.domain) return { ok: true, data: b.domain };
1352 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'update failed' };
1353 }
1354 return { ok: false, code: 'unknown', message: 'update failed' };
1355 } catch {
1356 return { ok: false, code: 'network_error', message: 'network error' };
1357 }
1358 },
1359 async removeDomain(orgId, domainId) {
1360 try {
1361 const body = await post<unknown>(`/orgs/${orgId}/domains/${domainId}/delete`, {});
1362 return asSimpleResult(body);
1363 } catch {
1364 return { ok: false, code: 'network_error', message: 'network error' };
1365 }
1366 },
1367 // Phase 4 — Membership requests
1368 async createMembershipRequest(orgId, message) {
1369 try {
1370 const body = await post<unknown>(`/orgs/${orgId}/membership-requests`, message ? { message } : {});
1371 if (body && typeof body === 'object') {
1372 const b = body as { request?: MembershipRequest; error?: { code?: string; message?: string } };
1373 if (b.request) return { ok: true, data: b.request };
1374 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'create failed' };
1375 }
1376 return { ok: false, code: 'unknown', message: 'create failed' };
1377 } catch {
1378 return { ok: false, code: 'network_error', message: 'network error' };
1379 }
1380 },
1381 async listMembershipRequests(orgId, status) {
1382 try {
1383 const qs = status ? `?status=${status}` : '';
1384 const body = await get<unknown>(`/orgs/${orgId}/membership-requests${qs}`);
1385 if (body && typeof body === 'object') {
1386 const b = body as { requests?: MembershipRequest[]; error?: { code?: string; message?: string } };
1387 if (Array.isArray(b.requests)) return { ok: true, data: b.requests };
1388 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'list failed' };
1389 }
1390 return { ok: false, code: 'unknown', message: 'list failed' };
1391 } catch {
1392 return { ok: false, code: 'network_error', message: 'network error' };
1393 }
1394 },
1395 async resolveMembershipRequest(orgId, requestId, decision) {
1396 try {
1397 const body = await post<unknown>(`/orgs/${orgId}/membership-requests/${requestId}/resolve`, { decision });
1398 if (body && typeof body === 'object') {
1399 const b = body as { request?: MembershipRequest; error?: { code?: string; message?: string } };
1400 if (b.request) return { ok: true, data: b.request };
1401 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'resolve failed' };
1402 }
1403 return { ok: false, code: 'unknown', message: 'resolve failed' };
1404 } catch {
1405 return { ok: false, code: 'network_error', message: 'network error' };
1406 }
1407 },
1408 // Phase 4 — Active organization
1409 async setActive(orgId) {
1410 try {
1411 const body = await post<unknown>(`/orgs/${orgId}/set-active`, {});
1412 return asSimpleResult(body);
1413 } catch {
1414 return { ok: false, code: 'network_error', message: 'network error' };
1415 }
1416 },
1417 async getActive() {
1418 try {
1419 const body = await get<unknown>('/orgs/active');
1420 if (body && typeof body === 'object') {
1421 const b = body as { activeOrg?: Org | null; error?: { code?: string; message?: string } };
1422 return { ok: true, data: b.activeOrg ?? null };
1423 }
1424 return { ok: false, code: 'unknown', message: 'get failed' };
1425 } catch {
1426 return { ok: false, code: 'network_error', message: 'network error' };
1427 }
1428 },
1429 },
1430 sso: {
1431 async listConnections() {
1432 try {
1433 const body = await get<unknown>('/sso/connections');
1434 if (body && typeof body === 'object') {
1435 const b = body as { connections?: Array<Pick<SsoConnection, 'id' | 'name' | 'providerType' | 'domains'>>; error?: { code?: string; message?: string } };
1436 if (Array.isArray(b.connections)) return { ok: true, connections: b.connections };
1437 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'list failed' };
1438 }
1439 return { ok: false, code: 'unknown', message: 'list failed' };
1440 } catch {
1441 return { ok: false, code: 'network_error', message: 'network error' };
1442 }
1443 },
1444 async getConnectionByDomain(domain) {
1445 try {
1446 const body = await get<unknown>(`/sso/domain/${encodeURIComponent(domain)}`);
1447 if (body && typeof body === 'object') {
1448 const b = body as { connection?: Pick<SsoConnection, 'id' | 'name' | 'providerType' | 'domains'>; error?: { code?: string; message?: string } };
1449 if (b.connection) return { ok: true, connection: b.connection };
1450 return { ok: false, code: knownCode(b.error?.code ?? 'unknown'), message: b.error?.message ?? 'get failed' };
1451 }
1452 return { ok: false, code: 'unknown', message: 'get failed' };
1453 } catch {
1454 return { ok: false, code: 'network_error', message: 'network error' };
1455 }
1456 },
1457 start(connectionId, redirectTo, providerType = 'saml') {
1458 const path = providerType === 'oidc' ? `/sso/oidc/${connectionId}` : `/sso/saml/${connectionId}`;
1459 const u = new URL(`${apiOrigin}${BRIDGE_PREFIX}${path}`);
1460 if (redirectTo) u.searchParams.set('redirectTo', redirectTo);
1461 return { redirectUrl: u.toString() };
1462 },
1463 },
1464 twoFactor: {
1465 async enable(password) {
1466 try {
1467 const body = await post<unknown>('/two-factor/enable', password ? { password } : {});
1468 return asSimpleResult(body);
1469 } catch {
1470 return { ok: false, code: 'network_error', message: 'network error' };
1471 }
1472 },
1473 async verify(code) {
1474 try {
1475 // Better Auth endpoint is verify-totp (not /two-factor/verify).
1476 const body = await post<unknown>('/two-factor/verify-totp', { code });
1477 return asSignInResult(body);
1478 } catch {
1479 return { ok: false, code: 'network_error', message: 'network error' };
1480 }
1481 },
1482 async disable(password) {
1483 try {
1484 const body = await post<unknown>('/two-factor/disable', password ? { password } : {});
1485 return asSimpleResult(body);
1486 } catch {
1487 return { ok: false, code: 'network_error', message: 'network error' };
1488 }
1489 },
1490 async generateBackupCodes(password) {
1491 try {
1492 const body = await post<unknown>(
1493 '/two-factor/generate-backup-codes',
1494 password ? { password } : {},
1495 );
1496 if (body && typeof body === 'object') {
1497 const b = body as { backupCodes?: string[]; error?: { code?: string; message?: string } };
1498 if (Array.isArray(b.backupCodes)) return { ok: true, codes: b.backupCodes };
1499 return {
1500 ok: false,
1501 code: knownCode(b.error?.code ?? 'unknown'),
1502 message: b.error?.message ?? 'generate failed',
1503 };
1504 }
1505 return { ok: false, code: 'unknown', message: 'generate failed' };
1506 } catch {
1507 return { ok: false, code: 'network_error', message: 'network error' };
1508 }
1509 },
1510 async verifyBackupCode(code) {
1511 try {
1512 const body = await post<unknown>('/two-factor/verify-backup-code', { code });
1513 return asSignInResult(body);
1514 } catch {
1515 return { ok: false, code: 'network_error', message: 'network error' };
1516 }
1517 },
1518 },
1519 passkey: {
1520 /**
1521 * Register a passkey for the *currently signed-in* user.
1522 * Better Auth: GET /passkey/generate-register-options → WebAuthn create
1523 * → POST /passkey/verify-registration. Not a single POST /passkey/register.
1524 */
1525 async register(name?: string) {
1526 if (typeof globalThis.PublicKeyCredential === 'undefined') {
1527 return {
1528 ok: false as const,
1529 code: 'unknown' as const,
1530 message: 'passkeys require a browser with WebAuthn (HTTPS or localhost)',
1531 };
1532 }
1533 try {
1534 const options = await get<Record<string, unknown>>(
1535 '/passkey/generate-register-options',
1536 );
1537 if (options && typeof options === 'object' && (options as { error?: unknown }).error) {
1538 const err = (options as { error?: { code?: string; message?: string }; message?: string })
1539 .error;
1540 return {
1541 ok: false as const,
1542 code: knownCode(err?.code ?? 'unknown'),
1543 message: err?.message ?? (options as { message?: string }).message ?? 'register options failed',
1544 };
1545 }
1546 if (!(options as { challenge?: string }).challenge) {
1547 return {
1548 ok: false as const,
1549 code: 'unknown' as const,
1550 message:
1551 'passkey register needs an active session — sign in first, then enrol from account settings',
1552 };
1553 }
1554 const publicKey = publicKeyCreateOptionsFromJson(options);
1555 const cred = (await navigator.credentials.create({
1556 publicKey,
1557 })) as PublicKeyCredential | null;
1558 if (!cred) {
1559 return { ok: false as const, code: 'unknown' as const, message: 'passkey registration cancelled' };
1560 }
1561 const att = cred.response as AuthenticatorAttestationResponse;
1562 const responseBody = {
1563 id: cred.id,
1564 rawId: bufferToBase64Url(cred.rawId),
1565 type: cred.type,
1566 clientExtensionResults:
1567 typeof cred.getClientExtensionResults === 'function'
1568 ? cred.getClientExtensionResults()
1569 : {},
1570 response: {
1571 clientDataJSON: bufferToBase64Url(att.clientDataJSON),
1572 attestationObject: bufferToBase64Url(att.attestationObject),
1573 transports:
1574 typeof att.getTransports === 'function' ? att.getTransports() : undefined,
1575 },
1576 };
1577 const body = await post<unknown>('/passkey/verify-registration', {
1578 response: responseBody,
1579 ...(name ? { name } : {}),
1580 });
1581 return asSimpleResult(body);
1582 } catch (err) {
1583 const msg = err instanceof Error ? err.message : String(err);
1584 if (/cancel|not allowed|abort/i.test(msg)) {
1585 return { ok: false as const, code: 'unknown' as const, message: 'passkey registration cancelled' };
1586 }
1587 return { ok: false as const, code: 'network_error' as const, message: msg || 'network error' };
1588 }
1589 },
1590 async list() {
1591 try {
1592 // Better Auth: GET /passkey/list-user-passkeys → Passkey[]
1593 const body = await get<unknown>('/passkey/list-user-passkeys');
1594 if (Array.isArray(body)) return { ok: true, passkeys: body as Passkey[] };
1595 if (body && typeof body === 'object') {
1596 const b = body as { passkeys?: Passkey[]; error?: { code?: string; message?: string } };
1597 if (Array.isArray(b.passkeys)) return { ok: true, passkeys: b.passkeys };
1598 return {
1599 ok: false,
1600 code: knownCode(b.error?.code ?? 'unknown'),
1601 message: b.error?.message ?? 'list failed',
1602 };
1603 }
1604 return { ok: false, code: 'unknown', message: 'list failed' };
1605 } catch {
1606 return { ok: false, code: 'network_error', message: 'network error' };
1607 }
1608 },
1609 /**
1610 * Sign in with an existing passkey.
1611 * Better Auth: GET /passkey/generate-authenticate-options → WebAuthn get
1612 * → POST /passkey/verify-authentication. POST on generate-* is 404 by design.
1613 */
1614 async signIn() {
1615 if (typeof globalThis.PublicKeyCredential === 'undefined') {
1616 return {
1617 ok: false as const,
1618 code: 'unknown' as const,
1619 message: 'passkeys require a browser with WebAuthn (HTTPS or localhost)',
1620 };
1621 }
1622 try {
1623 const options = await get<Record<string, unknown>>(
1624 '/passkey/generate-authenticate-options',
1625 );
1626 if (options && typeof options === 'object' && (options as { error?: unknown }).error) {
1627 const err = (options as { error?: { code?: string; message?: string } }).error;
1628 return {
1629 ok: false as const,
1630 code: knownCode(err?.code ?? 'unknown'),
1631 message: err?.message ?? 'passkey options failed',
1632 };
1633 }
1634 if (!(options as { challenge?: string }).challenge) {
1635 return {
1636 ok: false as const,
1637 code: 'unknown' as const,
1638 message: 'passkey options missing challenge — is passkey enabled for this project?',
1639 };
1640 }
1641 const publicKey = publicKeyRequestOptionsFromJson(options);
1642 const cred = (await navigator.credentials.get({
1643 publicKey,
1644 })) as PublicKeyCredential | null;
1645 if (!cred) {
1646 return { ok: false as const, code: 'unknown' as const, message: 'passkey cancelled' };
1647 }
1648 const assertion = cred.response as AuthenticatorAssertionResponse;
1649 const responseBody = {
1650 id: cred.id,
1651 rawId: bufferToBase64Url(cred.rawId),
1652 type: cred.type,
1653 clientExtensionResults:
1654 typeof cred.getClientExtensionResults === 'function'
1655 ? cred.getClientExtensionResults()
1656 : {},
1657 response: {
1658 clientDataJSON: bufferToBase64Url(assertion.clientDataJSON),
1659 authenticatorData: bufferToBase64Url(assertion.authenticatorData),
1660 signature: bufferToBase64Url(assertion.signature),
1661 userHandle: assertion.userHandle
1662 ? bufferToBase64Url(assertion.userHandle)
1663 : null,
1664 },
1665 };
1666 const body = await post<unknown>('/passkey/verify-authentication', {
1667 response: responseBody,
1668 });
1669 return asSignInResult(body);
1670 } catch (err) {
1671 const msg = err instanceof Error ? err.message : String(err);
1672 if (/cancel|not allowed|abort/i.test(msg)) {
1673 return { ok: false as const, code: 'unknown' as const, message: 'passkey cancelled' };
1674 }
1675 return { ok: false as const, code: 'network_error' as const, message: msg || 'network error' };
1676 }
1677 },
1678 },
1679 impersonate: {
1680 async status() {
1681 try {
1682 const body = await get<unknown>('/impersonation');
1683 if (body && typeof body === 'object') {
1684 const b = body as {
1685 impersonating?: boolean;
1686 impersonatedBy?: string;
1687 targetUserId?: string;
1688 error?: { code?: string; message?: string };
1689 };
1690 if (b.impersonating === true && b.impersonatedBy && b.targetUserId) {
1691 return {
1692 ok: true as const,
1693 impersonating: true as const,
1694 impersonatedBy: b.impersonatedBy,
1695 targetUserId: b.targetUserId,
1696 };
1697 }
1698 if (b.impersonating === false) {
1699 return { ok: true as const, impersonating: false as const };
1700 }
1701 return {
1702 ok: false as const,
1703 code: knownCode(b.error?.code ?? 'unknown'),
1704 message: b.error?.message ?? 'status check failed',
1705 };
1706 }
1707 return { ok: true as const, impersonating: false as const };
1708 } catch {
1709 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
1710 }
1711 },
1712 async stop(sessionToken) {
1713 try {
1714 const body = await post<unknown>('/impersonation/stop', { sessionToken });
1715 return asSimpleResult(body);
1716 } catch {
1717 return { ok: false, code: 'network_error', message: 'network error' };
1718 }
1719 },
1720 },
1721 jwt: {
1722 async getToken(input = {}) {
1723 try {
1724 const body = await post<unknown>('/jwt/token', { template: input.template });
1725 if (body && typeof body === 'object') {
1726 const b = body as { token?: string; expiresAt?: string; code?: string; message?: string };
1727 if (typeof b.token === 'string' && typeof b.expiresAt === 'string') {
1728 return { ok: true as const, token: b.token, expiresAt: b.expiresAt };
1729 }
1730 if (b.code) {
1731 return { ok: false as const, code: knownCode(b.code), message: b.message ?? 'token generation failed' };
1732 }
1733 }
1734 return { ok: false as const, code: 'unknown' as const, message: 'token generation failed' };
1735 } catch {
1736 return { ok: false as const, code: 'network_error' as const, message: 'network error' };
1737 }
1738 },
1739 },
1740 async signOut() {
1741 try {
1742 await post<unknown>('/sign-out', null);
1743 return { ok: true };
1744 } catch {
1745 return { ok: false };
1746 }
1747 },
1748 async getSession() {
1749 try {
1750 const body = await get<unknown>('/get-session');
1751 if (body && typeof body === 'object') {
1752 const b = body as {
1753 user?: { id?: string };
1754 session?: { expiresAt?: string };
1755 };
1756 if (b.user?.id && b.session?.expiresAt) {
1757 return {
1758 authenticated: true,
1759 userId: b.user.id,
1760 expiresAt: b.session.expiresAt,
1761 };
1762 }
1763 }
1764 return { authenticated: false };
1765 } catch {
1766 return { authenticated: false };
1767 }
1768 },
1769 async getUser() {
1770 try {
1771 const body = await get<unknown>('/get-session');
1772 if (body && typeof body === 'object') {
1773 const b = body as { user?: User };
1774 return b.user ?? null;
1775 }
1776 return null;
1777 } catch {
1778 return null;
1779 }
1780 },
1781 hostedPageURL(flow, callbackURL, locale) {
1782 // Hosted flows on API origin (valid cert). Tenant via query + path.
1783 const u = new URL(authUrl);
1784 u.pathname = `/auth/${opts.projectId}/${flow}`;
1785 u.searchParams.set('briven_project_id', opts.projectId);
1786 if (callbackURL) {
1787 u.searchParams.set('callbackURL', callbackURL);
1788 }
1789 if (locale) {
1790 u.searchParams.set('locale', locale);
1791 }
1792 return u.toString();
1793 },
1794 };
1795}
1796
1797const KNOWN_CODES: ReadonlySet<SignInErrorCode> = new Set<SignInErrorCode>([
1798 'invalid_credentials',
1799 'email_taken',
1800 'weak_password',
1801 'rate_limited',
1802 'unverified_email',
1803 'tenant_unresolved',
1804 'network_error',
1805 'unknown',
1806]);
1807
1808function knownCode(value: string): SignInErrorCode {
1809 return KNOWN_CODES.has(value as SignInErrorCode) ? (value as SignInErrorCode) : 'unknown';
1810}