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