Files
Ekklesia/surfaces/shell/src/auth-protocol.ts
T
ekklesia a8c7c50b4d
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s
feat: add browser auth shell
2026-08-23 22:01:26 -05:00

93 lines
3.1 KiB
TypeScript

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<Record<HostAuthErrorCode, string>> = 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<HostAuthErrorCode, "AUTH_CREDENTIALS_INVALID">,
signal?: AbortSignal,
): Promise<Response> {
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<void> {
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<void> {
const response = await postJson(config, SIGN_OUT_PATH, {}, "AUTH_SIGN_OUT_FAILED", signal);
if (!response.ok) {
throw new HostAuthError("AUTH_SIGN_OUT_FAILED");
}
}