import type { EkklesiaHostConfig } from "./host-protocol.js"; import { resolveApiUrl } from "./host-protocol.js"; const SIGN_IN_PATH = "/api/auth/sign-in/email"; const SIGN_OUT_PATH = "/api/auth/sign-out"; export interface HostSignInCredentials { readonly email: string; readonly password: string; } export type HostAuthErrorCode = | "AUTH_CREDENTIALS_INVALID" | "AUTH_REQUEST_FAILED" | "AUTH_SIGN_OUT_FAILED"; const errorMessages: Readonly> = Object.freeze({ AUTH_CREDENTIALS_INVALID: "Email or password is incorrect.", AUTH_REQUEST_FAILED: "Unable to sign in right now.", AUTH_SIGN_OUT_FAILED: "Unable to sign out right now.", }); /** * A browser-safe authentication failure. It deliberately retains neither a response body nor the * submitted credentials, a request URL, a Better Auth error, or a caught transport exception. */ export class HostAuthError extends Error { readonly code: HostAuthErrorCode; constructor(code: HostAuthErrorCode) { super(errorMessages[code]); this.name = "HostAuthError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } async function postJson( config: EkklesiaHostConfig, path: string, body: unknown, failureCode: Exclude, signal?: AbortSignal, ): Promise { const performRequest = config.fetch ?? fetch; try { return await performRequest(resolveApiUrl(config, path), { body: JSON.stringify(body), cache: "no-store", credentials: "include", headers: { accept: "application/json", "content-type": "application/json", }, method: "POST", redirect: "error", ...(signal ? { signal } : {}), }); } catch { // The thrown value may contain a URL or implementation detail. More importantly, this helper // must never attach the request body (and therefore the password) as an error cause. throw new HostAuthError(failureCode); } } /** * Uses Ekklesia's existing Better Auth transport without exposing Better Auth to browser packages. * Success means only that the mutation succeeded; the shell must resolve `/api/v1/principal` before * it renders authenticated content rather than trusting a response token or user object. */ export async function signInWithEmail( config: EkklesiaHostConfig, credentials: HostSignInCredentials, signal?: AbortSignal, ): Promise { const response = await postJson(config, SIGN_IN_PATH, credentials, "AUTH_REQUEST_FAILED", signal); if (response.status === 400 || response.status === 401) { throw new HostAuthError("AUTH_CREDENTIALS_INVALID"); } if (!response.ok) { throw new HostAuthError("AUTH_REQUEST_FAILED"); } } /** Invalidates the server session. Hiding the shell without this successful mutation is not logout. */ export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSignal): Promise { const response = await postJson(config, SIGN_OUT_PATH, {}, "AUTH_SIGN_OUT_FAILED", signal); if (!response.ok) { throw new HostAuthError("AUTH_SIGN_OUT_FAILED"); } }