93 lines
3.1 KiB
TypeScript
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");
|
|
}
|
|
}
|