feat: add browser auth shell
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user