feat: add browser auth shell #33

Merged
Zayin_Admin merged 1 commits from phase-7b/browser-auth-shell into main 2026-08-24 03:06:01 +00:00
26 changed files with 2702 additions and 194 deletions
Showing only changes of commit a8c7c50b4d - Show all commits
+2 -1
View File
@@ -9,7 +9,8 @@ Before opening a change, run:
pnpm verify pnpm verify
``` ```
Migration-runner changes also require the disposable live PostgreSQL suite: Migration-runner changes and changes to identity/authentication or the API's session transport also
require the disposable live PostgreSQL suite:
```bash ```bash
pnpm test:migrations:integration pnpm test:migrations:integration
+39 -18
View File
@@ -11,13 +11,15 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
against an independent security audit) is complete: all five passes have landed and the final against an independent security audit) is complete: all five passes have landed and the final
independent whole-repository re-audit has closed, under the current trusted-contributor, independent whole-repository re-audit has closed, under the current trusted-contributor,
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since not-yet-deployed threat model.** Phase 6 completed through 6D, and **Phase 7A passes 1 (the minimal
completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the operational module lifecycle), 2 (the
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/ browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
kiosk host) have landed.** The operational kiosk host) have landed. Phase 7B pass 1 adds the Dashboard's host-owned browser sign-in, sign-out,
session-expiry handling, and authenticated-shell transition.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the untrusted CI contributions, and a public production launch — and neither gates ongoing product
development; see the
pass 5 closure below and pass 5 closure below and
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes [Security model and deployment trust boundaries](docs/development/security.md). The repository includes
public module contracts, independently versioned capabilities, static module manifests, public module contracts, independently versioned capabilities, static module manifests,
@@ -50,10 +52,11 @@ order never grants elevation — but is now **disabled by default, and cannot be
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and [API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
**No email verification, password recovery, or Dashboard exists yet** — that, along with Email verification, password recovery, invitation/operator enrollment, and a supported production
production approval-provider composition, jobs, physical storage provisioning, surfaces, and first-user creation path still do not exist. Public registration remains disabled by default and
feature modules, remains later vertical slices. Public registration is not yet ready for a real prohibited in production. The Dashboard now has a minimal authenticated shell and login/logout flow,
public launch. but not a finished product UI or a production asset-hosting deployment; jobs, production
approval-provider composition, feature modules, and the public Website remain later slices.
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
for platform owners, explicit grants, and relationship-derived grants; a smallest-possible for platform owners, explicit grants, and relationship-derived grants; a smallest-possible
@@ -235,15 +238,15 @@ never a dependency. The long-term goal is one module implementation hosted by Ek
by a standalone application — never two codebases for one domain. See by a standalone application — never two codebases for one domain. See
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md). [ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
**What that pass shipped is the static contract only.** `manifestVersion: 4` adds one **What that pass shipped at its checkpoint was the static contract only.** `manifestVersion: 4`
optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated added one optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
during discovery exactly like `server` and `schema` — inside the package, real file, no source during discovery exactly like `server` and `schema` — inside the package, real file, no source
TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly TypeScript. Discovery stayed inert: **nothing imported, mounted, or rendered that entrypoint**,
like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a exactly like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest was a
one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was one-character edit; version 3 was rejected outright rather than reinterpreted, as version 2 was
before it. **The Dashboard shell, a UI runtime or renderer, navigation before it. The Dashboard shell, runtime mounting, and navigation named as absent at that checkpoint
registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading, landed in subsequent Phase 6 passes. A standalone host, module installer or marketplace, hot-loading,
and every feature module do not exist.** Restart-based discovery, explicit operator-invoked and every real feature module still do not exist. Restart-based discovery, explicit operator-invoked
migrations, and the trusted compiled-module model are unchanged. migrations, and the trusted compiled-module model are unchanged.
Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is, Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
@@ -666,11 +669,29 @@ flow through the unchanged host context. Display inventory/management, variants
public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the
Announcements migration, and Companion integration remain deferred. Announcements migration, and Companion integration remain deferred.
Phase 7B pass 1 adds **host-owned browser authentication to the Dashboard shell**
([ADR 0034](docs/architecture/decisions/0034-host-owned-browser-authentication.md)). Anonymous
bootstrap renders an accessible email/password form; successful submission uses the existing
`POST /api/auth/sign-in/email` endpoint, whose successful browser-visible body is reduced to fixed
`{"ok":true}` while the HttpOnly cookie remains authoritative. The shell resolves
`GET /api/v1/principal` again before protected navigation and module UI, and sign-out uses the
existing `POST /api/auth/sign-out` before the same principal re-resolution. No token is stored or
passed to a module, no arbitrary callback/return URL exists, and leaving authenticated state
unmounts the active module through the generic loader's normal cleanup.
The flow intentionally offers neither signup nor password recovery: production signup remains
prohibited, no email sender exists, and the repository still lacks a supported way to create the
initial production identity required by platform-owner bootstrap. Authentication throttling is
enabled but in-memory and per API process. Host-only cookies, exact trusted origins, and Better
Auth's origin/CSRF checks remain unchanged; production Dashboard DNS/TLS/proxy/CSP and built-asset
hosting are deployment work, not guarantees added by this pass. Display device identity and the
public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested **Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing, folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
and no module-management, settings, or storage UI — module enablement and configuration are an API and and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. Sign-in UI, a production CSP a CLI, not a screen, and object storage is deployment configuration. A production CSP and
and deployment for the built shells, general sidecar asset publication, React runtime sharing, the deployment for the built shells, general sidecar asset publication, React runtime sharing, the
standalone host, and every real feature module also do not exist. standalone host, and every real feature module also do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary **Security model.** Installed modules are trusted compiled backend code that runs arbitrary
+105 -17
View File
@@ -1,7 +1,7 @@
import { import {
isAuthenticatedPrincipal, isAuthenticatedPrincipal,
type ModuleSecretSource,
type ModuleRuntimeServices, type ModuleRuntimeServices,
type ModuleSecretSource,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import type { import type {
@@ -9,31 +9,31 @@ import type {
AuthorizationStore, AuthorizationStore,
PermissionRegistry, PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { DashboardStore } from "@ekklesia/core/dashboard";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import type { import type {
DiscoveredModule, DiscoveredModule,
ModuleConfigurationLookup,
ModuleConfigurationSettings, ModuleConfigurationSettings,
ModuleConfigurationStore, ModuleConfigurationStore,
ModuleLifecycleSnapshot, ModuleLifecycleSnapshot,
ModuleConfigurationLookup,
ModuleLifecycleStore, ModuleLifecycleStore,
ModuleStorageLookup,
ModuleRealtimeRegistry, ModuleRealtimeRegistry,
ModuleStorageLookup,
ModuleUiPublicationRegistry, ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono"; import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js"; import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { mountModuleDisplayRoutes } from "./module-display-routes.js"; import { mountModuleDisplayRoutes } from "./module-display-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js"; import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
import { createModuleUiRoutes } from "./module-ui-routes.js"; import { createModuleUiRoutes } from "./module-ui-routes.js";
@@ -138,6 +138,87 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
return c.json({ code }, status); return c.json({ code }, status);
} }
const EMAIL_SIGN_IN_PATH = "/api/auth/sign-in/email";
const EMAIL_SIGN_IN_SUCCESS_BODY = JSON.stringify({ ok: true });
/**
* Better Auth publishes general-purpose session-management endpoints even when an application has
* no client for them. These four can return the raw bearer-equivalent session token with the
* pinned configuration: get/list/update return a session record; change-password returns a newly
* minted token when asked to revoke other sessions. Ekklesia deliberately exposes only its narrow
* `/api/v1/principal` projection and has no native session/password-management browser UI, so the
* supported method/path pairs are closed at the outer HTTP boundary before Better Auth executes.
*/
const TOKEN_BEARING_AUTH_ENDPOINT_METHODS: Readonly<Record<string, readonly string[]>> =
Object.freeze({
"/api/auth/change-password": Object.freeze(["POST"]),
"/api/auth/get-session": Object.freeze(["GET", "POST"]),
"/api/auth/list-sessions": Object.freeze(["GET"]),
"/api/auth/update-session": Object.freeze(["POST"]),
});
function decodedRequestPathname(request: Request): string | undefined {
const encodedPathname = new URL(request.url).pathname;
try {
// URL.pathname intentionally preserves percent escapes. Decode exactly once before comparing,
// matching the maximum decoding a downstream HTTP router may safely perform; malformed
// encodings are denied rather than delegated through an ambiguous parser boundary.
return decodeURIComponent(encodedPathname);
} catch {
return undefined;
}
}
function isDisabledTokenBearingAuthRequest(request: Request): boolean {
const pathname = decodedRequestPathname(request);
if (pathname === undefined) {
return true;
}
// Better Auth is configured not to skip trailing slashes, but treating trailing-slash spellings
// as the same denied resource prevents a router-option change from opening a bypass. This does
// not collapse interior duplicate slashes or otherwise broaden path normalization.
const canonicalPath = pathname.replace(/\/+$/u, "") || "/";
return TOKEN_BEARING_AUTH_ENDPOINT_METHODS[canonicalPath]?.includes(request.method) ?? false;
}
/**
* Better Auth's native successful email sign-in response includes the newly created session token
* and the complete user object in its JSON body even though the browser already received the
* session through an HttpOnly cookie. The browser boundary must never make either value visible to
* application JavaScript. Keep this deliberately narrower than a general Better Auth response
* adapter: only the exact supported POST endpoint and a body-bearing successful response are
* normalized. Every sign-in failure and every other delegated auth endpoint remains Better Auth's
* native contract; the explicitly denied token-bearing endpoints above are never delegated.
*/
function browserSafeAuthResponse(request: Request, response: Response): Response {
const headers = new Headers(response.headers);
headers.set("Cache-Control", "no-store");
if (
request.method === "POST" &&
decodedRequestPathname(request) === EMAIL_SIGN_IN_PATH &&
response.ok &&
response.status !== 204 &&
response.status !== 205
) {
// The upstream length describes the deliberately discarded token/user JSON. Retaining it could
// truncate or stall the replacement payload once an HTTP adapter serializes this response.
headers.delete("Content-Length");
headers.set("Content-Type", "application/json; charset=UTF-8");
return new Response(EMAIL_SIGN_IN_SUCCESS_BODY, {
headers,
status: response.status,
statusText: response.statusText,
});
}
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
/** /**
* Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a * Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
* dynamic-function `origin` — including one that already has its own `Vary` (module responses * dynamic-function `origin` — including one that already has its own `Vary` (module responses
@@ -204,6 +285,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
app.use("/api/auth/*", corsMiddleware); app.use("/api/auth/*", corsMiddleware);
app.use("/api/v1/*", corsMiddleware); app.use("/api/v1/*", corsMiddleware);
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
// policy to the response.
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
return next();
}
c.header("Cache-Control", "no-store");
return c.json({ code: "NOT_FOUND" }, 404);
});
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES); const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => { app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
try { try {
@@ -212,13 +306,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which // intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
// only affects `c.res`) guarantees this regardless of how the handler produced its result, // only affects `c.res`) guarantees this regardless of how the handler produced its result,
// and never reads or buffers the body. // and never reads or buffers the body.
const headers = new Headers(response.headers); return browserSafeAuthResponse(c.req.raw, response);
headers.set("Cache-Control", "no-store");
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
} catch (error) { } catch (error) {
return sanitizedErrorResponse(c, error, 500); return sanitizedErrorResponse(c, error, 500);
} }
+293 -4
View File
@@ -1,8 +1,10 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import net from "node:net"; import net from "node:net";
import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts"; import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
import { import {
bootstrapPlatformOwner, bootstrapPlatformOwner,
@@ -15,8 +17,12 @@ import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
discoverAllMigrations, discoverAllMigrations,
} from "@ekklesia/core/migrations"; } from "@ekklesia/core/migrations";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import {
import { tmpdir } from "node:os"; bootstrapHost,
type EkklesiaHostConfig,
signInWithEmail,
signOutHost,
} from "@ekklesia/shell";
import { Client, type QueryResultRow } from "pg"; import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest"; import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js"; import type { ClientAddressConfigInput } from "../src/client-address.js";
@@ -26,7 +32,7 @@ import {
MODULE_UI_CONTENT_TYPE, MODULE_UI_CONTENT_TYPE,
moduleUiArtifactUrl, moduleUiArtifactUrl,
} from "../src/module-ui-routes.js"; } from "../src/module-ui-routes.js";
import { startApi, type NodeServerTimeouts, type RunningApi } from "../src/server.js"; import { type NodeServerTimeouts, type RunningApi, startApi } from "../src/server.js";
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join( const coreMigrationsDirectory = path.join(
@@ -602,6 +608,139 @@ function firstCookiePair(setCookieHeader: string): string {
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? ""; return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
} }
interface SafeCookieObservation {
readonly domain: string | undefined;
readonly httpOnly: boolean;
readonly path: string | undefined;
readonly sameSite: string | undefined;
readonly secure: boolean;
}
interface BrowserAuthResponseObservation {
readonly accessControlAllowCredentials: string | null;
readonly accessControlAllowOrigin: string | null;
readonly body: string;
readonly contentLength: string | null;
readonly cookie: SafeCookieObservation;
readonly issuedSetCookie: boolean;
readonly status: number;
}
interface OriginLockedBrowserCookieJar {
readonly fetch: typeof fetch;
hasSessionCookie(): boolean;
signInResponse(): BrowserAuthResponseObservation;
signOutResponse(): BrowserAuthResponseObservation;
}
function observeCookie(setCookie: string | undefined): SafeCookieObservation {
const attributes = (setCookie ?? "")
.split(";")
.slice(1)
.map((part) => part.trim())
.filter(Boolean);
const attributeValue = (name: string): string | undefined => {
const prefix = `${name.toLowerCase()}=`;
const attribute = attributes.find((part) => part.toLowerCase().startsWith(prefix));
return attribute?.slice(prefix.length);
};
const hasAttribute = (name: string): boolean =>
attributes.some((part) => part.toLowerCase() === name.toLowerCase());
return Object.freeze({
domain: attributeValue("domain"),
httpOnly: hasAttribute("httponly"),
path: attributeValue("path"),
sameSite: attributeValue("samesite"),
secure: hasAttribute("secure"),
});
}
/**
* Node deliberately has no ambient browser cookie store. This origin-locked test transport models
* the one host-only session cookie explicitly: it accepts Set-Cookie only from the configured API,
* sends only the cookie pair back to that same origin, clears it on an expiry response, and never
* exposes the stored value to assertions or error output. The observable cookie data contains only
* flags needed to prove the browser contract.
*/
function createOriginLockedBrowserCookieJar(apiBaseUrl: string): OriginLockedBrowserCookieJar {
const apiOrigin = new URL(apiBaseUrl).origin;
let cookiePair: string | undefined;
let signInObservation: BrowserAuthResponseObservation | undefined;
let signOutObservation: BrowserAuthResponseObservation | undefined;
const browserFetch: typeof fetch = async (input, init) => {
const request = new Request(input, init);
const requestUrl = new URL(request.url);
if (requestUrl.origin !== apiOrigin) {
throw harnessError("The browser integration transport refused a cross-origin request.");
}
// A real cross-origin browser supplies Origin itself and its cookie jar only when
// `credentials: include` is selected. Node's fetch has neither ambient behavior, so this
// tightly scoped harness supplies exactly those two transport facts.
request.headers.set("origin", dashboardOrigin);
request.headers.delete("cookie");
if (cookiePair !== undefined && request.credentials === "include") {
request.headers.set("cookie", cookiePair);
}
const response = await fetch(request);
const setCookie = setCookieValue(response);
if (setCookie !== undefined && request.credentials === "include") {
const nextCookiePair = firstCookiePair(setCookie);
const separator = nextCookiePair.indexOf("=");
const nextName = separator < 0 ? "" : nextCookiePair.slice(0, separator);
const nextValue = separator < 0 ? "" : nextCookiePair.slice(separator + 1);
const expiresCookie = /(?:^|;)\s*max-age=0(?:;|$)/iu.test(setCookie);
if (nextName !== "" && nextValue !== "" && !expiresCookie) {
cookiePair = nextCookiePair;
} else if (cookiePair?.startsWith(`${nextName}=`)) {
cookiePair = undefined;
}
}
const pathname = requestUrl.pathname;
if (
request.method === "POST" &&
(pathname === "/api/auth/sign-in/email" || pathname === "/api/auth/sign-out")
) {
const observation = Object.freeze({
accessControlAllowCredentials: response.headers.get("access-control-allow-credentials"),
accessControlAllowOrigin: response.headers.get("access-control-allow-origin"),
body: await response.clone().text(),
contentLength: response.headers.get("content-length"),
cookie: observeCookie(setCookie),
issuedSetCookie: setCookie !== undefined,
status: response.status,
});
if (pathname === "/api/auth/sign-in/email") {
signInObservation = observation;
} else {
signOutObservation = observation;
}
}
return response;
};
return Object.freeze({
fetch: browserFetch,
hasSessionCookie: () => cookiePair !== undefined,
signInResponse: () => {
if (signInObservation === undefined) {
throw harnessError("The browser integration did not observe a sign-in response.");
}
return signInObservation;
},
signOutResponse: () => {
if (signOutObservation === undefined) {
throw harnessError("The browser integration did not observe a sign-out response.");
}
return signOutObservation;
},
});
}
interface SignUpResult { interface SignUpResult {
readonly response: Response; readonly response: Response;
readonly body: { token: string | null; user: { id: string; email: string } }; readonly body: { token: string | null; user: { id: string; email: string } };
@@ -839,12 +978,20 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
const { cookiePair, response } = await signIn(origin, emailOne, password); const { cookiePair, response } = await signIn(origin, emailOne, password);
const setCookie = setCookieValue(response); const setCookie = setCookieValue(response);
const browserVisibleBody = await response.clone().text();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(JSON.parse(browserVisibleBody)).toEqual({ ok: true });
expect(browserVisibleBody).not.toContain("token");
expect(browserVisibleBody).not.toContain("user");
expect(browserVisibleBody).not.toContain("session");
expect(browserVisibleBody).not.toContain(emailOne);
expect(setCookie).toBeDefined(); expect(setCookie).toBeDefined();
expect(cookiePair).toBeDefined(); expect(cookiePair).toBeDefined();
sessionCookie = cookiePair as string; sessionCookie = cookiePair as string;
const cookieText = setCookie as string; const cookieText = setCookie as string;
expect(cookieText).toContain("Path=/");
expect(cookieText).toContain("HttpOnly"); expect(cookieText).toContain("HttpOnly");
expect(cookieText).toContain("SameSite=Lax"); expect(cookieText).toContain("SameSite=Lax");
expect(cookieText).toContain("Secure"); expect(cookieText).toContain("Secure");
@@ -1118,6 +1265,148 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
}); });
}); });
describe.sequential("live API — exported browser auth shell against PostgreSQL", () => {
it("signs a real platform owner in, bootstraps protected Dashboard navigation, and signs out", async () => {
const modulesDirectory = await createFixtureModulesDirectory();
await createUiModule(
modulesDirectory,
"browser-shell",
"1.0.0",
'export default { id: "browser-shell", version: "1.0.0", mount() {} };\n',
);
const database = await createDisposableDatabase("browser_auth");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url, modulesDirectory);
try {
const origin = apiOrigin(running);
const email = `browser-owner-${randomBytes(6).toString("hex")}@example.test`;
const password = "correct horse battery staple";
const created = await signUp(origin, email, password);
expect(created.response.status).toBe(200);
const authorizationDatabase = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(authorizationDatabase.db, {
userId: parseEkklesiaUserId(created.body.user.id),
});
} finally {
await authorizationDatabase.close();
}
const cookieJar = createOriginLockedBrowserCookieJar(origin);
const hostConfig: EkklesiaHostConfig = Object.freeze({
apiBaseUrl: origin,
fetch: cookieJar.fetch,
});
// This is the public framework-free helper the browser shell itself calls — no test-side
// reimplementation of its URL, payload, credentials mode, or response handling.
await signInWithEmail(hostConfig, { email, password });
const signIn = cookieJar.signInResponse();
expect(signIn.status).toBe(200);
expect(JSON.parse(signIn.body)).toEqual({ ok: true });
expect(
signIn.contentLength === null ||
signIn.contentLength === String(Buffer.byteLength(signIn.body)),
).toBe(true);
for (const forbidden of ["token", "user", "session", email]) {
expect(signIn.body).not.toContain(forbidden);
}
expect(signIn.issuedSetCookie).toBe(true);
expect(cookieJar.hasSessionCookie()).toBe(true);
expect(signIn.cookie).toEqual({
domain: undefined,
httpOnly: true,
path: "/",
sameSite: "Lax",
secure: true,
});
expect(signIn.accessControlAllowOrigin).toBe(dashboardOrigin);
expect(signIn.accessControlAllowCredentials).toBe("true");
// The pinned Better Auth build would return the raw session token from the first three
// resources, and from change-password when `revokeOtherSessions` is true. Ekklesia has no
// native session/password-management UI, so the outer API boundary must answer before Better
// Auth can read these authenticated requests. The valid change-password payload makes this a
// meaningful leak regression: if the denylist disappeared it could mint and expose a new
// token, not merely fail validation before reaching the vulnerable response.
const deniedNativeEndpoints = [
{ method: "GET", path: "/api/auth/get-session?source=browser-shell" },
{ method: "GET", path: "/api/auth/%67et-session" },
{ method: "GET", path: "/api/auth/get-session%2F" },
{ body: {}, method: "POST", path: "/api/auth/get-session" },
{ method: "GET", path: "/api/auth/list-sessions" },
{ body: { arbitrary: "value" }, method: "POST", path: "/api/auth/update-session" },
{
body: {
currentPassword: password,
newPassword: "replacement horse battery staple",
revokeOtherSessions: true,
},
method: "POST",
path: "/api/auth/change-password",
},
] as const;
for (const endpoint of deniedNativeEndpoints) {
const response = await cookieJar.fetch(`${origin}${endpoint.path}`, {
credentials: "include",
method: endpoint.method,
...("body" in endpoint
? {
body: JSON.stringify(endpoint.body),
headers: { "content-type": "application/json" },
}
: {}),
});
const visibleBody = await response.text();
expect(response.status).toBe(404);
expect(JSON.parse(visibleBody)).toEqual({ code: "NOT_FOUND" });
for (const forbidden of ["token", "session", email, password]) {
expect(visibleBody).not.toContain(forbidden);
}
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
}
// `bootstrapHost` resolves the principal first, then calls the protected Dashboard endpoint.
// A real module in the result proves the latter request was authenticated and authorized.
const authenticated = await bootstrapHost(hostConfig);
expect(authenticated.principal).toEqual({
kind: "authenticated",
userId: created.body.user.id,
});
expect(authenticated.navigation.groups).toEqual([]);
expect(authenticated.navigation.ungrouped.map((entry) => entry.descriptor.id)).toEqual([
"browser-shell",
]);
await signOutHost(hostConfig);
const signOut = cookieJar.signOutResponse();
expect(signOut.status).toBe(200);
expect(JSON.parse(signOut.body)).toEqual({ success: true });
expect(signOut.issuedSetCookie).toBe(true);
expect(signOut.accessControlAllowOrigin).toBe(dashboardOrigin);
expect(signOut.accessControlAllowCredentials).toBe("true");
expect(cookieJar.hasSessionCookie()).toBe(false);
// Reload through the same exported principal-first shell bootstrap. The expired cookie is no
// longer sent, protected navigation is not probed, and the host returns its frozen empty view.
const reloaded = await bootstrapHost(hostConfig);
expect(reloaded.principal).toEqual({ kind: "anonymous" });
expect(reloaded.navigation).toEqual({ groups: [], ungrouped: [] });
} finally {
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
});
});
describe.sequential("live API — startup and migration guarantees", () => { describe.sequential("live API — startup and migration guarantees", () => {
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => { it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
const database = await createDisposableDatabase("unmigrated"); const database = await createDisposableDatabase("unmigrated");
+233 -6
View File
@@ -11,22 +11,22 @@ import {
type PermissionRegistry, type PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleUiPublicationRegistry, type ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { import {
createApiApp as createApiAppWithModules,
type CreateApiAppAuthorizationOptions, type CreateApiAppAuthorizationOptions,
type CreateApiAppOptions, type CreateApiAppOptions,
createApiApp as createApiAppWithModules,
} from "../src/app.js"; } from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test"; const trustedOrigin = "https://dashboard.example.test";
@@ -96,6 +96,21 @@ function fakeAuthorization(overrides: {
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store }; return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
} }
function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
return createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
}
describe("Ekklesia API Hono application", () => { describe("Ekklesia API Hono application", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => { it("mounts Better Auth exactly under /api/auth/*", async () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
@@ -115,10 +130,222 @@ describe("Ekklesia API Hono application", () => {
const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.text()).toBe("auth-ok"); expect(await response.json()).toEqual({ ok: true });
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
}); });
it("replaces only a successful email sign-in body without exposing Better Auth's token or user", async () => {
const nativeCookie =
"__Secure-better-auth.session_token=opaque-cookie-value; Path=/; HttpOnly; Secure; SameSite=Lax";
const nativeBody = JSON.stringify({
token: "javascript-visible-session-token",
user: { email: "[email protected]", id: "user_123", name: "Owner" },
});
const nativeResponse = new Response(nativeBody, {
headers: {
"Access-Control-Allow-Origin": trustedOrigin,
"Content-Length": "4096",
"Content-Type": "application/json",
"Set-Cookie": nativeCookie,
"X-Native-Auth-Header": "preserved",
},
status: 201,
statusText: "Signed In",
});
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(nativeResponse),
});
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/sign-in/email", {
headers: { origin: trustedOrigin },
method: "POST",
});
const visibleBody = await response.text();
expect(response.status).toBe(201);
expect(response.statusText).toBe("Signed In");
expect(JSON.parse(visibleBody)).toEqual({ ok: true });
expect(visibleBody).not.toContain("token");
expect(visibleBody).not.toContain("user");
expect(visibleBody).not.toContain("session");
expect(visibleBody).not.toContain("[email protected]");
expect(response.headers.get("set-cookie")).toBe(nativeCookie);
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("content-type")).toBe("application/json; charset=UTF-8");
expect(response.headers.get("content-length")).not.toBe("4096");
expect(response.headers.get("x-native-auth-header")).toBe("preserved");
expect(nativeResponse.bodyUsed).toBe(false);
});
it.each([
"/api/auth/%73ign-in/email",
"/api/auth/sign-in%2Femail",
])("normalizes the router-equivalent encoded sign-in path %s", async (path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
Response.json({
token: "javascript-visible-session-token",
user: { email: "[email protected]" },
}),
),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method: "POST" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
it("preserves a failed email sign-in response body and status unchanged", async () => {
const nativeFailure = JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" });
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
new Response(nativeFailure, {
headers: { "Content-Type": "application/json" },
status: 401,
statusText: "Unauthorized",
}),
),
});
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(401);
expect(response.statusText).toBe("Unauthorized");
expect(await response.text()).toBe(nativeFailure);
expect(response.headers.get("cache-control")).toBe("no-store");
});
it.each([
["a GET to the sign-in path", "GET", "/api/auth/sign-in/email"],
["a sign-out POST", "POST", "/api/auth/sign-out"],
["a trailing-slash sign-in POST", "POST", "/api/auth/sign-in/email/"],
])("leaves %s on Better Auth's native success contract", async (_label, method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(new Response("native-auth-body", { status: 200 })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(200);
expect(await response.text()).toBe("native-auth-body");
expect(response.headers.get("cache-control")).toBe("no-store");
});
describe("unused native session-token endpoints", () => {
const disabledRequests = [
["GET", "/api/auth/get-session"],
["POST", "/api/auth/get-session"],
["GET", "/api/auth/list-sessions"],
["POST", "/api/auth/update-session"],
["POST", "/api/auth/change-password"],
] as const;
it.each(
disabledRequests,
)("returns a fixed 404 for %s %s, including query and trailing-slash spellings, without invoking Better Auth", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
Response.json({
session: { token: "javascript-visible-session-token" },
token: "another-session-token",
}),
),
});
const app = createAuthWiringApp(runtime);
for (const requestPath of [path, `${path}?include=session`, `${path}/`]) {
const response = await app.request(requestPath, {
headers: { origin: trustedOrigin },
method,
});
const body = await response.text();
expect(response.status).toBe(404);
expect(JSON.parse(body)).toEqual({ code: "NOT_FOUND" });
expect(body).not.toContain("token");
expect(body).not.toContain("session");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
}
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it.each([
["GET", "/api/auth/%67et-session"],
["GET", "/api/auth/%6Cist-sessions"],
["POST", "/api/auth/%75pdate-session"],
["POST", "/api/auth/change-password%2F"],
["GET", "/api/auth/get-session%2F"],
["GET", "/api/auth%2Fget-session"],
["GET", "/api/auth/get-session%"],
])("does not delegate the encoded or malformed spelling %s %s", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(Response.json({ session: { token: "exposed" } })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it("denies an oversized update-session body before the body limiter or Better Auth reads it", async () => {
const runtime = fakeRuntime();
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/update-session", {
body: "x",
headers: {
"content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1),
"content-type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it.each([
["POST", "/api/auth/list-sessions"],
["GET", "/api/auth/update-session"],
["GET", "/api/auth/change-password"],
["GET", "/api/auth/list-sessions/export"],
])("leaves the neighbouring non-denied request %s %s on the native contract", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(new Response("native-method-response", { status: 200 })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(200);
expect(await response.text()).toBe("native-method-response");
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
});
it("does not route requests outside /api/auth/* to Better Auth", async () => { it("does not route requests outside /api/auth/* to Better Auth", async () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
@@ -289,7 +516,7 @@ describe("Ekklesia API Hono application", () => {
const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.text()).toBe("auth-ok"); expect(await response.json()).toEqual({ ok: true });
expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("cache-control")).toBe("no-store");
}); });
@@ -310,6 +310,11 @@ not exist yet.
## Deferred ## Deferred
> **Cross-reference — see
> [ADR 0034](0034-host-owned-browser-authentication.md).** The host-owned Dashboard sign-in and
> sign-out flow deferred below has now landed. This section remains the historical scope boundary
> of ADR 0026; ADR 0034 is authoritative for browser authentication and session transitions.
Not implemented or promised here: **the Dashboard** — navigation, grouping and folders, ordering, Not implemented or promised here: **the Dashboard** — navigation, grouping and folders, ordering,
icons, a home screen, module categories, configurable or administrator-owned navigation, and the icons, a home screen, module categories, configurable or administrator-owned navigation, and the
display metadata each would need in the inventory; a design system, theme tokens, or component display metadata each would need in the inventory; a design system, theme tokens, or component
@@ -0,0 +1,257 @@
# 0034: Dashboard browser authentication is host-owned and principal-first
- Status: Accepted
- Date: 2026-08-23
- Amends: [0011](0011-identity-http-transport-and-public-enrollment.md)
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md)
## Context
Ekklesia already had email/password authentication, an HttpOnly session cookie, a minimal
`GET /api/v1/principal` contract, authorization middleware, and a Dashboard shell. The shell also
already resolved the principal before requesting protected navigation. What it did not have was a
browser path for an anonymous person to become authenticated or for an authenticated person to end
their session. An anonymous bootstrap stopped at a message telling the viewer to sign in, while no
surface drove the existing authentication endpoints.
Closing that gap affects more than a form. A module UI may have an active API request or realtime
subscription when a session expires or a person signs out. Better Auth's successful email sign-in
response also includes a session token and user object in JSON even though the browser session is
already represented by an HttpOnly cookie. A browser flow must not expose or adopt that redundant
token, turn every authorization denial into logout, create an open-redirect input, or move platform
authentication into a feature module.
The display host adds a second important boundary. It is permission-gated today, but unattended
device identity is explicitly deferred by ADR 0033. Adding Dashboard sign-in must not silently make
the display application a device enrollment flow or add Dashboard chrome to its bundle.
## Decision
### The host owns authentication UX; modules retain the host-neutral context
`surfaces/shell` owns the Dashboard's sign-in form, pending and failure states, sign-out control,
session-transition behavior, and credential submission. A module never renders the platform login,
imports an authentication client, receives a session token, or chooses how the host authenticates.
The public module UI contract remains exactly the five-member, host-neutral
`ModuleUiHostContext`: `moduleId`, `api`, `principal`, `permissions`, and `realtime`. No sign-in,
sign-out, credential, cookie, token, or Ekklesia-specific session callback is added to it. The host
may observe authorization responses from the API client it supplies, but that observation remains
host-private and the module still receives the original response.
This keeps one module artifact runnable under Ekklesia and a future standalone host. A standalone
application will own its authentication experience just as the Ekklesia Dashboard owns this one;
the module will continue to see only the same narrow principal and permission hints.
### Dashboard bootstrap remains principal-first
The Dashboard resolves `GET /api/v1/principal` before requesting navigation or rendering any module
UI:
```text
loading
-> anonymous -> host-owned login
-> authenticated -> protected Dashboard navigation -> selected module
-> unavailable/invalid -> sanitized failure with explicit retry
```
An anonymous principal is an ordinary, successful identity result, not an application failure. A
principal infrastructure failure is not anonymous and does not reveal a login form that would
misrepresent the outage as a missing session. No protected Dashboard request is made and no module
code is loaded until the authenticated branch is established.
After a successful sign-in mutation, the host does not trust an authentication response as a
principal. It resolves `/api/v1/principal` again and only then loads Dashboard navigation. This
changes the rendered state in place; a full page reload is not required.
### Email/password uses the native endpoint behind two narrow browser-safe exceptions
The browser submits JSON credentials with `credentials: "include"` to Better Auth's existing
native endpoint:
```text
POST /api/auth/sign-in/email
```
ADR 0011 made normal Better Auth responses pass through the Core identity runtime unchanged. That
remains true inside Core. This decision introduces exactly two browser-facing exceptions at the
`apps/api` transport boundary:
1. When the exact email sign-in endpoint returns any body-bearing successful `2xx`, `apps/api`
preserves that status and the headers — including `Set-Cookie` — but replaces the body with the
fixed response below.
2. The token-bearing native session inspection/mutation paths `/api/auth/get-session`,
`/api/auth/list-sessions`, `/api/auth/update-session`, and `/api/auth/change-password` are
unavailable. The API returns the same sanitized `404 {"code":"NOT_FOUND"}` as an unsupported
route without invoking Better Auth. Password/account management is outside this pass; the pinned
change-password route can return a new raw token when it revokes other sessions, so it cannot be
left as an unused bypass.
```json
{ "ok": true }
```
Sign-in failures and every other supported Better Auth endpoint keep their existing response
semantics. These exceptions are not a second authentication protocol: supported requests still
reach Better Auth, Better Auth still creates and invalidates the session, and its HttpOnly cookie
remains the only browser session authority. The normalization discards the redundant session token
and user object before browser application JavaScript can read them. The denylist prevents a caller
from bypassing the narrow Ekklesia identity contract to inspect or mutate native session objects.
The supported browser identity read is exclusively `GET /api/v1/principal`; the shell treats
`{ "ok": true }` only as permission to re-resolve it and does not persist, log, or derive identity
from the body.
The form exposes only email and password. It is a semantic browser form with labelled inputs,
`username` and `current-password` autocomplete hints, keyboard submission, one in-flight attempt,
and a visible pending state. A completed attempt clears the password while retaining the email for
correction. Credential rejection has one generic message; service and malformed-response failures
have a separate fixed generic message. Raw response bodies, caught exceptions, credentials, and
request URLs never reach the DOM or console.
### Sign-out invalidates first, then asks the principal authority
The host submits an empty JSON object with `credentials: "include"` to the existing endpoint:
```text
POST /api/auth/sign-out
```
After the sign-out mutation succeeds, the host resolves `/api/v1/principal` instead of assuming
that hiding the Dashboard equals logout. An anonymous result shows the login form. If the sign-out
request throws or the subsequent principal resolution fails, protected UI stays removed and the
host shows a sanitized failure with actions to retry sign-out or explicitly rebootstrap. It neither
claims that the session ended nor automatically restores an authenticated state it has not
revalidated.
While the transition is in progress, the authenticated Dashboard subtree is removed. The generic
module loader therefore invokes the selected module's existing unmount cleanup, and the module's
host-owned realtime subscriptions close through their ordinary cleanup path. No module-specific
logout event or teardown API is introduced.
### Platform bootstrap and module responses use different status policies
A `401` from a platform-owned bootstrap request is an authentication challenge in the host's own
fixed contract. The platform request helper maps it directly to `HOST_UNAUTHENTICATED`, and the
principal-first Dashboard returns to login without treating it as an arbitrary module response. A
platform navigation `403` remains an access denial and receives the existing principal recheck so
the host can distinguish an authenticated denial from a session that expired between the principal
and navigation requests.
A module owns its response semantics and may return `401` or `403` for reasons that do not invalidate
the host session. Either status observed through the module API facade therefore triggers one
deduplicated `/api/v1/principal` revalidation while the module still receives its original response:
- anonymous means the session ended, so the host unmounts the active module and shows login;
- authenticated means the session and mounted module remain in place and the original module
response remains that module's result;
- an infrastructure failure becomes a sanitized retryable host failure, never a fabricated logout.
A burst of module denials shares the in-flight principal revalidation. A response retained by a
module frame that has already unmounted cannot start or overwrite a newer auth transition. A network
error, malformed response, `404`, or `5xx` never clears authentication merely because it occurred.
Realtime retains ADR 0032's native reconnect and five-minute server reauthorization; when a verified
anonymous principal moves the host out of the authenticated state, normal module unmount closes its
EventSource.
### There is no return URL or redirect sink in this pass
Login renders at the current Dashboard URL and successful authentication replaces that state in
place. The host accepts no `return`, `returnTo`, `callbackURL`, or equivalent request/query value,
passes no callback URL to Better Auth, and performs no script-driven navigation. External,
protocol-relative, backslash-prefixed, or scheme-bearing input therefore has no path to become a
redirect target.
A future client router may define an internal return-location contract, but it must be a separately
validated, same-origin route decision. This pass does not create one speculatively.
### Cookie and origin policy does not broaden for the Dashboard
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
Secure when the canonical identity base URL uses HTTPS, Path `/`, and no `Domain` attribute. It is
host-only. Ekklesia does not broaden it to a parent domain merely so sibling subdomains can share a
session.
The simplest production shape is a Dashboard whose `/api` traffic is reverse-proxied on the same
origin. A deliberately split browser/API origin remains possible only through the exact canonical
trusted-origin allowlist and credentialed CORS already defined by ADR 0011, ADR 0012, and ADR 0018.
Wildcards remain invalid. Better Auth's origin and CSRF checks remain explicitly enabled in every
environment; the browser supplies its real `Origin` naturally rather than manufacturing or
weakening it.
The repository still provides no production Dashboard hostname, DNS, TLS, reverse-proxy rule,
Content-Security-Policy, or static-asset deployment. Host-only cookies mean any eventual choice to
place the API and Dashboard on different sibling subdomains must be designed and tested rather than
assumed to share a session.
### Enrollment, recovery, throttling, and first ownership remain explicit gaps
The login page is for an existing account. It contains no create-account link because public signup
is disabled by default and prohibited in production by ADR 0017. It contains no forgot-password or
verification flow because Ekklesia has no email sender, recovery delivery, or verified-email
contract. Their absence is visible product scope, not a hidden link to a non-operational endpoint.
Better Auth's sign-in limiter remains enabled with its strict per-path production rule of three
attempts per ten seconds. Its store is in-memory and per API process: counters do not span multiple
instances and do not survive restart. The server-owned client-address boundary prevents a caller
from choosing a new bucket with a spoofed forwarding header, but it does not turn this into a shared
or durable brute-force defense. A multi-instance or publicly launched deployment still needs a
proven shared or edge limiter.
The platform-owner bootstrap command still accepts only an **existing** Ekklesia user ID. Because
production public signup is prohibited and no invitation or operator-owned user-creation command
exists, this repository does not yet provide a supported way to create the first production
identity that bootstrap needs. Tests may create a development identity, bootstrap it, and prove the
owner's browser session reaches protected Dashboard resources; that evidence must not be described
as production first-user provisioning.
## Consequences
- An anonymous Dashboard visit reaches a real, accessible email/password login without exposing
Better Auth's session token to browser application JavaScript.
- `/api/v1/principal` is the only supported browser session read; token-bearing native session and
password mutation routes cannot become an accidental parallel contract.
- Reload uses the existing cookie and principal endpoint; sign-in and sign-out do not require a
client router or full-page navigation.
- Authentication transitions tear down the selected module through the generic loader's existing
lifecycle, including its realtime cleanup.
- A module remains ignorant of Ekklesia's credentials, auth endpoints, cookies, and response-status
policy.
- An authorization denial does not automatically become logout, while an expired session can still
return to login without a reload.
- The host-only cookie preserves the narrow existing trust boundary and leaves deployment topology
as explicit future work.
## Rejected alternatives
- **Put login in a module or add auth methods to `ModuleUiHostContext`.** Authentication is
platform shell policy and would make portable module UI depend on its Ekklesia host.
- **Let the browser consume Better Auth's native sign-in success JSON.** Its token and user object
duplicate the cookie authority and unnecessarily expose identity/session material to same-realm
JavaScript.
- **Leave native session endpoints mounted because this UI does not call them.** Same-realm code can
call an available route directly; unused token-bearing inspection/mutation endpoints would bypass
the principal contract, and `change-password` can issue a raw replacement token.
- **Trust the sign-in response as the current user.** `/api/v1/principal` is the narrow Ekklesia
identity contract and remains the only authority for host state.
- **Treat every module `401` or `403` as session expiry.** A valid authenticated session may receive
either status from module-owned behavior; automatic logout would let an untrusted response erase
host authentication without consulting the principal authority.
- **Accept a callback or return URL for convenience.** The Dashboard has no client router in this
pass, so such an input creates redirect risk without a product need.
- **Set a parent-domain cookie.** It broadens which hosts receive the credential and prejudges a
deployment topology that does not exist.
- **Add signup or password-reset links that point to incomplete flows.** Production enrollment and
email delivery are not implemented.
## Deferred
- invitation or operator-driven production enrollment, verified email, password recovery,
authenticated account/password management, and production first-user creation;
- shared/durable authentication throttling and proof against the deployed reverse proxy;
- a production Dashboard origin, built-asset hosting, CSP, DNS, TLS, and proxy configuration;
- a client router and any validated internal post-login return-location contract;
- device registration, pairing, unattended credentials, and public-display policy from ADR 0033;
- public Website authentication and routing;
- standalone-host authentication UX;
- every Phase 7B pass 2 product capability.
+1
View File
@@ -42,3 +42,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted - [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted - [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted - [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
+41 -8
View File
@@ -155,7 +155,9 @@ elevation — but is disabled by default as of Phase 5D pass 1, below. See
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and [ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
[the API guide](../development/api.md), and [the API guide](../development/api.md), and
[the identity development guide](../development/identity.md) for the underlying schema and [the identity development guide](../development/identity.md) for the underlying schema and
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out. contracts. Email/password sign-in, principal resolution, and sign-out are operational. Public
registration is available only when explicitly enabled for development/test; production rejects it
until verified enrollment and durable shared abuse controls exist.
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
@@ -226,8 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
independent. independent.
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
dependencies, consumed by module authors and hosts alike. Its host context is four members — dependencies, consumed by module authors and hosts alike. Its host context is five members —
`moduleId`, `api`, `principal`, `permissions`. Module UI reaches its own backend through `moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
`api.request(path)` with a path relative to the module's own API root, never by constructing `api.request(path)` with a path relative to the module's own API root, never by constructing
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible. Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
Client-side permission hints are presentation only; server-side enforcement remains the sole Client-side permission hints are presentation only; server-side enforcement remains the sole
@@ -244,8 +246,9 @@ option in one `tooling` package so module authors write no build configuration.
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host; rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
stylesheet loading contract, the standalone host, and every feature module remain future work. were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
contract, standalone host, and every real feature module remain future work.
**Phase 6B pass 3 makes the browser boundary real and adds the generic host loader.** **Phase 6B pass 3 makes the browser boundary real and adds the generic host loader.**
[ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md) removes the UI SDK's last [ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md) removes the UI SDK's last
@@ -338,9 +341,9 @@ permission the inventory does; organizing requires the new platform permission
shell renders that arrangement and computes none of it. shell renders that arrangement and computes none of it.
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no **Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
Dashboard Home or widgets, no design system, and no client routing. A production CSP and a deployment Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment
for the built shell, sign-in UI, general asset publication, and the standalone host remain future for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
work. pass 1 later supplies the host-owned sign-in flow described below.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged. are all unchanged.
@@ -504,3 +507,33 @@ whether routes exist, and installed-manifest changes remain restart-based. Realt
object URLs are unchanged and flow through the same portable contracts. Named display variants, object URLs are unchanged and flow through the same portable contracts. Named display variants,
targeting, display inventory/management, device/public identity, a standalone host, shared CSS, targeting, display inventory/management, device/public identity, a standalone host, shared CSS,
Announcements migration, and Companion integration remain later work. Announcements migration, and Companion integration remain later work.
Phase 7B pass 1 closes the Dashboard's browser authentication loop
([ADR 0034](decisions/0034-host-owned-browser-authentication.md)). The surface, not a module, owns
the email/password form, sign-out control, pending/failure states, and session transitions. Bootstrap
stays principal-first: anonymous renders login, authenticated proceeds to protected Dashboard
navigation, and an identity infrastructure failure remains a retryable failure rather than a false
anonymous result. Successful sign-in is followed by a fresh `/api/v1/principal` resolution before
any module mounts; sign-out is followed by the same resolution. Leaving authenticated state removes
the Dashboard subtree so the generic loader performs the selected module's normal cleanup, including
its realtime subscription teardown.
The browser posts to Better Auth's existing `/api/auth/sign-in/email` and `/api/auth/sign-out`
routes with credentials included. `apps/api` normalizes any body-bearing successful `2xx` email
sign-in response to fixed `{"ok":true}` while preserving its status and the HttpOnly `Set-Cookie`;
the redundant Better Auth token and user object never reach browser application JavaScript. The
token-bearing native `/api/auth/get-session`, `/api/auth/list-sessions`,
`/api/auth/update-session`, and `/api/auth/change-password` paths are unavailable behind a sanitized
`404`; `/api/v1/principal` is the browser's only supported session read, and password/account
management remains out of scope. The cookie remains host-only and exact trusted origins,
credentialed CORS, and origin/CSRF checks remain unchanged. Login stays at the current Dashboard URL
and accepts no callback or return target, so this pass introduces no redirect sink.
The five-member module UI context is unchanged; modules receive no auth method, credential, cookie,
or session token. Platform-owned bootstrap `401` maps directly to unauthenticated, but module-owned
responses are not host session authority: module `401` and `403` both trigger a deduplicated
principal revalidation, and only an anonymous result resets authentication and unmounts the module.
An authenticated result preserves the response, session, and mounted UI. Display device identity
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
and the missing production first-user creation path are also outside this pass.
@@ -74,6 +74,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader, mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact. concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
- Dashboard login, logout, and session-transition UX are surface-owned
([ADR 0034](decisions/0034-host-owned-browser-authentication.md)). They use the API over HTTP and
must not add credentials, cookies, tokens, or auth methods to `ModuleUiHostContext`; modules keep
receiving only the host-neutral principal and permission hints already in the UI SDK.
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also - The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
statically imports no module and mounts only the exact display descriptor/artifact supplied by the statically imports no module and mounts only the exact display descriptor/artifact supplied by the
API. It contains no Dashboard chrome and does not move display rendering into the surface—the API. It contains no Dashboard chrome and does not move display rendering into the surface—the
+102 -32
View File
@@ -1,8 +1,9 @@
# API # API
Phase 4B adds `apps/api`: a minimal Hono-based HTTP transport that mounts Better Auth and exposes Phase 4B added `apps/api`: a minimal Hono-based HTTP transport that mounts Better Auth and exposes
one Ekklesia-owned route to resolve the current principal. It does not add a Dashboard, Website, or one Ekklesia-owned route to resolve the current principal. Later phases added authorization, module
Mobile Surface, and it does not add roles, permissions, or a platform-owner concept — see transport, the Dashboard shell, and its host-owned browser login/logout flow; Website and Mobile
authentication still do not exist. See
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for the [ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for the
full architectural decision and [Identity](identity.md) for the identity foundation this builds on. full architectural decision and [Identity](identity.md) for the identity foundation this builds on.
A follow-up corrective slice hardened this transport (sanitized Better Auth logging, A follow-up corrective slice hardened this transport (sanitized Better Auth logging,
@@ -24,14 +25,14 @@ keyset listing — see
## What this is, and is not ## What this is, and is not
After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia It is possible to start the API, sign in, resolve an Ekklesia principal, sign out, and drive the
principal, and sign out; public account creation is also possible once explicitly enabled (see Dashboard's authenticated shell. Public account creation is possible only when explicitly enabled
[Public registration policy](#public-registration-policy)). It is **not** possible to verify an for development/test (see [Public registration policy](#public-registration-policy)). It is **not**
email address, recover a forgotten password, use any provider besides email/password, reach a possible to verify an email address, recover a forgotten password, use another provider, or create
Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not the first production identity through a supported enrollment flow. **Public registration is not
ready for a real public launch**: there is no email delivery service, so an unverified email is ready for a real public launch**: there is no email delivery service, so an unverified email is
trusted as entered and a forgotten password has no recovery path, and no durable, distributed trusted as entered and a forgotten password has no recovery path, and no durable, distributed
abuse control exists yet even with signup enabled. abuse control exists even when development signup is enabled.
## Starting the API ## Starting the API
@@ -72,7 +73,9 @@ No real values are shown here — configure per environment and never commit the
| Route | Owner | Notes | | Route | Owner | Notes |
| --- | --- | --- | | --- | --- | --- |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). | | `POST /api/auth/sign-in/email` | Better Auth through Ekklesia's browser-safe response boundary | Uses Better Auth's native email/password request, status, failure, and `Set-Cookie` semantics. Any body-bearing successful `2xx` is normalized to fixed `{"ok":true}` so Better Auth's redundant token/user JSON cannot reappear if its success status changes. The pinned release currently returns `200`. |
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). | | `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
@@ -89,8 +92,9 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). | | `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
| `PUT /api/v1/module-configuration/<module-id>` | Ekklesia | Replace one module's stored configuration. Takes effect at the next API start. Requires `core.modules.configuration.manage` and a trusted `Origin`. See [Module configuration](#module-configuration). | | `PUT /api/v1/module-configuration/<module-id>` | Ekklesia | Replace one module's stored configuration. Takes effect at the next API start. Requires `core.modules.configuration.manage` and a trusted `Origin`. See [Module configuration](#module-configuration). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a Better Auth's handler is unreachable outside `/api/auth/*`. The four denied native
deterministic `404 {"code":"NOT_FOUND"}`. session/password paths inside that prefix are deliberately indistinguishable from an unsupported
route; every other unmatched path returns the same deterministic `404 {"code":"NOT_FOUND"}`.
### `GET /api/v1/principal` response shapes ### `GET /api/v1/principal` response shapes
@@ -121,12 +125,62 @@ dropped column, a revoked `SELECT` privilege, an unreachable database, and a que
produce this same sanitized `503` — carrying no relation name, column name, SQL, driver message, produce this same sanitized `503` — carrying no relation name, column name, SQL, driver message,
host, or stack. See [Identity § How a principal is resolved](identity.md#how-a-principal-is-resolved). host, or stack. See [Identity § How a principal is resolved](identity.md#how-a-principal-is-resolved).
This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response, `/api/v1/principal` is the only supported browser session read. Better Auth's native
which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user `/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the `/api/auth/change-password` routes expose or mutate token-bearing native session/user shapes, so
Ekklesia-owned contract every other Core/Surface consumer should use; it never contains email, Ekklesia denies them with the sanitized `404` above instead of allowing a richer parallel identity
name, image, session id or expiry, session token, account/provider details, or any role, contract. Password/account management is out of scope; the pinned change-password route can return
permission, membership, or volunteer data. a new raw token when asked to revoke other sessions. The principal response never
contains email, name, image, session id or expiry, session token, account/provider details, or any
role, permission, membership, or volunteer data.
## Dashboard browser authentication
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
five-member module UI context gains no authentication method. Bootstrap always requests
`/api/v1/principal` first. Anonymous renders the host login, authenticated proceeds to protected
Dashboard navigation, and a principal `503` remains a retryable infrastructure failure rather than
a false signed-out state.
The login form posts only `{email, password}` to `/api/auth/sign-in/email` with
`credentials: "include"`. On any body-bearing successful `2xx`, the API preserves Better Auth's
status, `Set-Cookie`, and other headers but discards the native token/user response body and returns:
```json
{ "ok": true }
```
The shell neither parses nor stores a token. It resolves `/api/v1/principal` again, and only an
authenticated result permits the navigation request and module mount. Credential denial and
service failure use distinct fixed messages, but no Better Auth response body, exception, email,
password, URL, or stack reaches browser output or logging. The form deliberately offers no signup
or password-reset link because neither is a supported production flow.
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
resolves the principal again. The host does not equate hiding the shell with invalidating a session.
During that transition the Dashboard subtree is unmounted, which invokes the generic module
loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or
principal resolution fails, protected UI stays removed behind a safe failure state with actions to
retry sign-out or explicitly rebootstrap; the host neither claims logout succeeded nor silently
restores authentication.
A `401` from a platform-owned bootstrap request maps directly to the shell's unauthenticated state;
that status is part of the host's own fixed resource contract. A platform navigation `403` causes a
principal recheck so an authenticated access denial is not confused with the narrow expiry race
between principal and navigation.
Module responses have different semantics. A module may return either `401` or `403` without
invalidating the platform session, so both statuses trigger one deduplicated principal revalidation.
The module still receives its original response. Only an anonymous principal unmounts the active
module and changes the shell to login; an authenticated principal preserves the session, response,
and mounted module. A revalidation infrastructure failure becomes a sanitized host failure. Stale
responses from an already-unmounted module frame cannot overwrite a newer auth transition, and
network errors, malformed responses, and `5xx` responses never fabricate logout.
Login remains at the current Dashboard URL. The shell accepts and forwards no return, callback, or
redirect target and performs no post-login navigation, so external and protocol-relative values
have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
## Public registration policy ## Public registration policy
@@ -163,12 +217,13 @@ registered account is an **authentication identity** — proof that someone can
and password — not a church-membership record; it does not imply attendance or involvement, and and password — not a church-membership record; it does not imply attendance or involvement, and
nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every
account, first or five-hundredth, receives identical baseline authenticated status: no Dashboard account, first or five-hundredth, receives identical baseline authenticated status: no Dashboard
access, no module-management access, no staff/volunteer-management access, and no management authority, no module-management access, no staff/volunteer-management access, and no
media/event/form/roster/people-management permission — Core has no authorization schema in this media/event/form/roster/people-management permission. A signed-in identity can satisfy permissions
slice to grant any of that, and no code path in this repository inspects how many accounts already whose registered baseline is deliberately `authenticated` — including the Dashboard's
exist or treats an empty database as special. Baseline authenticated status is what will eventually `core.modules.ui.read` — but signup itself creates no explicit grant and no code path inspects how many
let a signed-in person use ownership-scoped personal features (watch history, continue watching, accounts already exist or treats an empty database as special. Baseline authenticated status may
saved content, preferences) — none of those features are implemented in this PR. also support future ownership-scoped personal features (watch history, continue watching, saved
content, preferences); none of those features are implemented here.
Explicit **platform-owner bootstrap** is now available as a local operator command — see Explicit **platform-owner bootstrap** is now available as a local operator command — see
[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity [Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
@@ -177,6 +232,12 @@ platform-owner authority; signup order is never part of that decision. Future
gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this
one. one.
That existing-identity requirement is an unresolved production bootstrap gap. Production cannot
enable public signup, and Ekklesia has no invitation or operator-owned user-creation command, so a
fresh production installation has no supported path to create the first identity that the owner
bootstrap command requires. Development/test fixtures that enable signup and then bootstrap their
created user prove authorization composition, not a production enrollment workflow.
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not** `emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
is `null`. The caller must call `/api/auth/sign-in/email` explicitly afterward. This is deliberate: is `null`. The caller must call `/api/auth/sign-in/email` explicitly afterward. This is deliberate:
@@ -227,7 +288,10 @@ Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain` configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution) attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
CORS/CSRF above. CORS/CSRF above. The Dashboard does not broaden this policy: same-origin `/api` proxying is the
simplest production topology, while a deliberately split browser/API origin must use the exact
trusted-origin allowlist and credentialed CORS. Sibling subdomains do not implicitly share the
cookie. No production Dashboard hostname or proxy deployment exists in this repository.
## Logging ## Logging
@@ -315,7 +379,9 @@ always the resolved, server-owned address described above — never a caller-con
rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh
bucket, and unrelated real peers are never collapsed into one shared bucket merely because no bucket, and unrelated real peers are never collapsed into one shared bucket merely because no
header was present. This closes the rate-limit-identity gap; it does not add distributed storage — header was present. This closes the rate-limit-identity gap; it does not add distributed storage —
a genuinely multi-instance deployment still needs a shared or edge limiter, reserved for pass 5. a genuinely multi-instance deployment still needs a proven shared or edge limiter. The current
three-attempt/ten-second path rule is useful per-process throttling, not durable brute-force
protection across restarts or replicas.
## Request body limits ## Request body limits
@@ -1295,13 +1361,15 @@ stopped first, through its own `stop` hook, then already-started modules in reve
under the same bounded per-hook deadline — before the failure propagates, and then closes the pool — there is no separate under the same bounded per-hook deadline — before the failure propagates, and then closes the pool — there is no separate
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one. runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
## A real signup → sign-in → principal → sign-out transcript ## A real development signup → browser-safe sign-in → principal → sign-out transcript
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL
database, configured as **development** with public sign-up explicitly enabled — public sign-up is database, configured as **development** with public sign-up explicitly enabled — public sign-up is
disabled by default and cannot be enabled in production at all (see disabled by default and cannot be enabled in production at all (see
[Public registration policy](#public-registration-policy)). Session tokens, cookie values, and [Public registration policy](#public-registration-policy)). Cookie values and generated ids below
generated ids below are sanitized/truncated examples, not real credentials. are sanitized/truncated examples, not real credentials. The successful sign-in body is the exact
browser-visible normalization; Better Auth's native token/user JSON has already been discarded at
the API boundary.
```text ```text
POST /api/auth/sign-up/email POST /api/auth/sign-up/email
@@ -1313,7 +1381,7 @@ POST /api/auth/sign-in/email
{"email":"[email protected]","password":"<redacted>"} {"email":"[email protected]","password":"<redacted>"}
→ 200 → 200
Set-Cookie: better-auth.session_token=<redacted>; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure Set-Cookie: better-auth.session_token=<redacted>; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure
{"redirect":false,"token":"<redacted>","user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1", ...}} {"ok":true}
GET /api/v1/principal GET /api/v1/principal
Cookie: better-auth.session_token=<redacted> Cookie: better-auth.session_token=<redacted>
@@ -1338,5 +1406,7 @@ Cookie: better-auth.session_token=<redacted, now revoked>
See the repository root [README](../../README.md#current-status) and See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status) [platform architecture](../architecture/platform-architecture.md#current-implementation-status)
for what has landed across every phase and what remains before Surface work begins. This document for what has landed across every phase and what remains. Surface work is underway; production
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve. Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
database-composition contracts as they evolve.
+7
View File
@@ -294,6 +294,13 @@ before the id is even parsed as a valid `EkklesiaUserId` — this command never
signup order or "the only user in the database"; the operator must name the exact, opaque id signup order or "the only user in the database"; the operator must name the exact, opaque id
twice. twice.
**The command grants authority; it does not create credentials.** Production public signup is
prohibited, and the repository currently has no invitation flow or operator-owned user-creation
command. A fresh production installation therefore has no supported path to create the initial
identity this command requires. Development integration tests may enable signup, create a user,
bootstrap that existing id, and prove its Dashboard session, but that is test composition — not a
production first-user provisioning workflow. This gap must be closed before launch.
### Bootstrap is permanently single-use ### Bootstrap is permanently single-use
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated, `core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated,
+35 -13
View File
@@ -19,9 +19,11 @@ surface concern. Better Auth is an implementation detail contained entirely behi
Auth instance, session token, or raw database client.** This holds structurally today: modules Auth instance, session token, or raw database client.** This holds structurally today: modules
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
what Core exposes. what Core exposes.
- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**, - **Surfaces authenticate through Core-exposed HTTP/API contracts**, never by querying Better
never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that transport. The Dashboard
transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface exists yet. now owns its browser login/logout UX and uses only `/api/auth/sign-in/email`,
`/api/auth/sign-out`, and the narrow `/api/v1/principal` contract; modules receive none of those
controls. Website and Mobile authentication do not exist yet.
- Better Auth's `user` row is an authentication identity, not the future church-person/profile - Better Auth's `user` row is an authentication identity, not the future church-person/profile
model. Church membership, ministry roles, contact history, attendance, permissions, and other model. Church membership, ministry roles, contact history, attendance, permissions, and other
module-specific fields must never be added to the identity tables; a future, separately designed module-specific fields must never be added to the identity tables; a future, separately designed
@@ -168,7 +170,8 @@ address passes this validation, and a firewall, network policy, or reverse proxy
parsing — is what actually determines who can reach a deployment. parsing — is what actually determines who can reach a deployment.
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010. ADR 0010. The Dashboard flow in ADR 0034 deliberately retains a host-only cookie: a future
deployment must not assume sibling subdomains share a session.
## The identity runtime (Core's Better Auth transport boundary) ## The identity runtime (Core's Better Auth transport boundary)
@@ -184,6 +187,21 @@ exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's sessi
Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the
only current consumer of this boundary; see [API](api.md). only current consumer of this boundary; see [API](api.md).
Core still returns Better Auth's normal `Response` to that consumer. At the browser-facing API
composition boundary, [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md)
defines exactly two exceptions. First, any body-bearing successful `2xx` from
`POST /api/auth/sign-in/email` preserves Better Auth's status, headers, and HttpOnly `Set-Cookie`
but replaces its token/user JSON with fixed `{"ok":true}`. Second, the token-bearing native paths
`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
`/api/auth/change-password` receive a sanitized `404` without reaching Better Auth. The last path
is denied even though password/account management is not built, because the pinned implementation
can return a new raw session token when revoking other sessions.
Failures and every other supported auth endpoint retain their native semantics. The Dashboard
ignores even the normalized sign-in body as identity and re-resolves `/api/v1/principal`, the only
supported browser session read; no session token is exposed to browser application JavaScript or
added to a module context.
Construction also explicitly configures four things the pinned release would otherwise leave on an Construction also explicitly configures four things the pinned release would otherwise leave on an
environment-sensitive or implicit default, all covered by [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md) environment-sensitive or implicit default, all covered by [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
and [API](api.md#logging): and [API](api.md#logging):
@@ -233,13 +251,17 @@ database, and a query timeout therefore all produce a sanitized
logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md). [ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
## What is not operational yet ## What is and is not operational
No email verification, password recovery, invitation flow, email delivery, social or OAuth Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow
provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model are operational — see [API](api.md) and ADR 0034. Public registration remains a future product
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are** policy, not a production capability: it is disabled by default, may be explicitly enabled only for
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why development/test, and production rejects it. No email verification, password recovery, invitation
public registration is intentional and grants no elevation. Core's authorization foundation flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is roles/groups/teams/tenants model exists.
covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still
depends on an authorization model that only now exists and has not yet been built on. Core's authorization foundation (platform owners, explicit grants, and an immutable permission
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
an existing identity; it does not create one. Because production signup is prohibited and no
operator enrollment command exists, creating the initial production identity needed for owner
bootstrap remains an explicit operational gap.
+54 -3
View File
@@ -134,8 +134,8 @@ remain the sole authority for authorization regardless of what a browser loaded.
### The browser host mounts module UI; it grants nothing ### The browser host mounts module UI; it grants nothing
Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through the resolved
`GET /api/v1/module-ui` and mounts them with the generic loader `GET /api/v1/dashboard/navigation` view and mounts each embedded host-neutral descriptor with the generic loader
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). Three properties are ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). Three properties are
security-relevant. security-relevant.
@@ -158,6 +158,44 @@ shell owns the container, the loading state, and every error, so a failing modul
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above. JavaScript, exactly as described above.
### Dashboard authentication stays outside module code
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md) makes the Dashboard
surface — not a module — the owner of login, logout, and session-transition UX. Principal resolution
still precedes every protected navigation request and module mount. The public module UI context
remains `moduleId`, scoped API, narrow principal, permission hints, and realtime; it receives no
credential, cookie, token, auth endpoint, or sign-out callback.
The browser posts email/password to Better Auth's existing `/api/auth/sign-in/email` endpoint with
credentials included. Better Auth establishes the HttpOnly cookie, but its native success JSON also
contains a redundant session token and user object. `apps/api` therefore normalizes any body-bearing
successful `2xx` on that exact route to fixed `{"ok":true}` while preserving the status and
`Set-Cookie`; browser application JavaScript never receives the token and must resolve
`/api/v1/principal` before authenticated rendering. The richer token-bearing native paths
`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
`/api/auth/change-password` are deliberately unavailable behind the same sanitized `404` as an
unsupported route. The last path is denied even though password/account management is out of scope:
the pinned endpoint may return a new raw session token when revoking other sessions. The form clears
the submitted password, suppresses duplicate attempts, uses fixed error messages, and does not log
credentials, raw responses, or caught exceptions.
Sign-out calls `/api/auth/sign-out` and then re-resolves the principal; hiding the shell alone is
never treated as invalidation. Leaving authenticated state removes the active module frame, so its
existing unmount closes API/realtime work without a module-specific auth event. A `401` from the
platform-owned bootstrap contract maps directly to unauthenticated; a platform navigation `403`
receives a principal recheck for the principal/navigation expiry race. Module responses are not host
session authority: either module `401` or `403` triggers one deduplicated principal revalidation,
while the module retains the original response. Only anonymous unmounts the module and shows login;
authenticated preserves the session and mounted module. A stale response from an already-unmounted
frame is ignored, and network/server failures do not fabricate logout.
The flow remains at the current Dashboard URL and accepts no callback/return target, so this pass
adds no open-redirect input. It exposes no signup or recovery link: production signup is prohibited
and no email delivery/recovery system exists. The existing cookie stays host-only; exact trusted
origins, credentialed CORS, and explicitly enabled Better Auth origin/CSRF checks remain the
boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a
separate deployment and threat-model decision.
### A display page is authorized; its artifact bytes are not authority ### A display page is authorized; its artifact bytes are not authority
Manifest version 6 binds a display artifact to one permission declared by the same module Manifest version 6 binds a display artifact to one permission declared by the same module
@@ -445,7 +483,9 @@ This checklist gates a **public launch**. It does not gate development: Surfaces
work all proceed against the local development infrastructure while every item below is still work all proceed against the local development infrastructure while every item below is still
outstanding. outstanding.
1. The reverse-proxy path serves the API successfully. 1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
repository currently supplies neither deployment.
2. The origin port is unreachable from the public Internet. 2. The origin port is unreachable from the public Internet.
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private 3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
proxy path connects. Security must not depend on an unpublished DNS name or origin IP. proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
@@ -458,6 +498,13 @@ outstanding.
7. Proxy logs do not record cookies, authorization values, request bodies, or raw forwarding chains. 7. Proxy logs do not record cookies, authorization values, request bodies, or raw forwarding chains.
8. PostgreSQL is reachable only from the API host, over TLS, with the certificate and hostname 8. PostgreSQL is reachable only from the API host, over TLS, with the certificate and hostname
verification Ekklesia already requires. verification Ekklesia already requires.
9. The deployed login/logout flow preserves the host-only HttpOnly cookie, returns fixed
`{"ok":true}` rather than a token on successful sign-in, keeps the four token-bearing native
session/password paths unavailable, and accepts no external/protocol-relative post-login
destination.
10. The installation has a supported, audited way to create its first production identity before
consuming the single-use platform-owner bootstrap. This repository does not provide that way
yet.
### Rate limiting is per-process today ### Rate limiting is per-process today
@@ -471,6 +518,10 @@ If either changes — public signup is enabled, or a second instance is added
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header. instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.
Ekklesia does not ship one. Do not describe the current limiter as distributed throttling. Ekklesia does not ship one. Do not describe the current limiter as distributed throttling.
Its production sign-in/sign-up-prefixed rule is three attempts per ten seconds, and the
server-owned client address prevents a spoofed forwarding header from selecting a fresh bucket.
Those facts still do not make counters durable across restart or sufficient across replicas; the
public Dashboard login must be assessed with the actual edge/proxy controls before launch.
## Continuous integration boundary ## Continuous integration boundary
+37 -2
View File
@@ -42,6 +42,33 @@ a module entrypoint, or starts a server. Real, working (non-throwing) module fix
production module-runtime startup/shutdown ordering and cleanup against a mocked pool/Better production module-runtime startup/shutdown ordering and cleanup against a mocked pool/Better
Auth/server — see [Authorization](authorization.md#tests). Auth/server — see [Authorization](authorization.md#tests).
Phase 7B pass 1 adds browser-authentication evidence
([ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md)). API tests pin the
two exact browser-boundary exceptions. Any body-bearing successful `2xx` from
`POST /api/auth/sign-in/email` preserves Better Auth's `Set-Cookie` and status but returns fixed
`{"ok":true}`, with no token/user body. `/api/auth/get-session`, `/api/auth/list-sessions`,
`/api/auth/update-session`, and `/api/auth/change-password` each receive the identical sanitized
`404` without invoking the identity handler; failures and every other supported auth route retain
their native semantics. The live browser transport additionally proves the host-only cookie and
that no token, session, user, or email appears in the sign-in body.
Shell protocol and React tests cover principal-first anonymous/authenticated/failure states; the
labelled email/password form and keyboard submission; autocomplete, pending state, and same-tick
duplicate suppression; password clearing; fixed credential-versus-service errors; no credential or
raw exception logging; fresh principal and navigation resolution after login; sign-out followed by
principal resolution; and retry without a full-page reload.
The same shell suites exercise session transitions under repeated timing: active module cleanup and
realtime teardown on sign-out/verified expiry; stale attempts after unmount; platform bootstrap
`401` mapping directly to unauthenticated; and deduplicated principal revalidation for both module
`401` and `403`. They prove an authenticated recheck preserves the module's original response,
session, and mounted UI; an anonymous recheck unmounts once and shows login; a revalidation failure
is a sanitized host failure; and a response from an already-unmounted frame cannot overwrite a newer
transition. Return/callback input is not part of the browser contract, and tests pin that external,
protocol-relative, backslash-prefixed, and scheme-bearing values cannot become navigation.
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
of the separate display bundle.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live `pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database. database.
@@ -79,8 +106,9 @@ A second dedicated suite (`apps/api/tests/api-http.integration.ts`, included in
`pnpm test:migrations:integration` run) starts the real `apps/api` HTTP server against a freshly `pnpm test:migrations:integration` run) starts the real `apps/api` HTTP server against a freshly
migrated disposable database and exercises it with real HTTP requests: public sign-up (with no migrated disposable database and exercises it with real HTTP requests: public sign-up (with no
automatic session, per `autoSignIn: false`), explicit sign-in producing a host-only automatic session, per `autoSignIn: false`), explicit sign-in producing a host-only
`HttpOnly`/`SameSite=Lax`/secure session cookie, `/api/v1/principal` for both the authenticated and `HttpOnly`/`SameSite=Lax`/secure session cookie and fixed `{"ok":true}` body containing no token,
anonymous shapes, sign-out actually invalidating the session, a malformed or missing cookie `/api/v1/principal` for both the authenticated and anonymous shapes, sign-out actually invalidating
the session, a malformed or missing cookie
resolving as anonymous, duplicate sign-up creating no second identity row, two distinct accounts resolving as anonymous, duplicate sign-up creating no second identity row, two distinct accounts
producing identical principal shapes with no elevation and no owner/role/permission data at the producing identical principal shapes with no elevation and no owner/role/permission data at the
database level, no plaintext password ever stored, the exact 8–128 password-length boundary (7 database level, no plaintext password ever stored, the exact 8–128 password-length boundary (7
@@ -90,6 +118,13 @@ outage during principal resolution producing a sanitized `503` rather than a fal
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
shutdown. shutdown.
The browser-authentication chain in that live suite also creates a development identity, invokes
Core's real single-use platform-owner bootstrap for that existing id, signs in through the real HTTP
transport, presents the returned cookie to `/api/v1/principal`, and reaches protected Dashboard
navigation with the owner authority. It proves composition across identity, authorization, and the
Dashboard resource; because production signup is prohibited, it deliberately does **not** claim to
prove a production first-user enrollment path.
A dedicated group within that suite proves session-store readiness against the real database rather A dedicated group within that suite proves session-store readiness against the real database rather
than a mock, since that is the only place a broken store looks exactly like an anonymous request: a than a mock, since that is the only place a broken store looks exactly like an anonymous request: a
request carrying no cookies and one carrying only an unrelated cookie both resolve as anonymous request carrying no cookies and one carrying only an unrelated cookie both resolve as anonymous
+92
View File
@@ -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");
}
}
+342 -68
View File
@@ -1,19 +1,40 @@
import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react"; import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
HostAuthError,
type HostSignInCredentials,
signInWithEmail,
signOutHost,
} from "../auth-protocol.js";
import { import {
bootstrapHost, bootstrapHost,
type DashboardNavigationEntry, type DashboardNavigationEntry,
type EkklesiaHostConfig, type EkklesiaHostConfig,
type HostBootstrap, type HostBootstrap,
HostProtocolError, HostProtocolError,
loadHostPrincipal,
} from "../host-protocol.js"; } from "../host-protocol.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js";
type AuthenticatedPrincipal = Extract<
HostBootstrap["principal"],
{ readonly kind: "authenticated" }
>;
type BootstrapState = type BootstrapState =
| { readonly status: "loading" } | { readonly status: "loading" }
| { readonly status: "ready"; readonly bootstrap: HostBootstrap } | { readonly status: "ready"; readonly bootstrap: HostBootstrap }
| { readonly status: "signed-out" } | { readonly status: "anonymous" }
| { readonly status: "failed" }; | { readonly status: "access-denied"; readonly principal: AuthenticatedPrincipal }
| { readonly status: "failed" }
| { readonly status: "signing-out" }
| { readonly status: "sign-out-failed" };
type ResolvedBootstrapState = Extract<
BootstrapState,
{ readonly status: "ready" | "anonymous" | "access-denied" | "failed" }
>;
export interface HostAppProps { export interface HostAppProps {
/** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal /** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal
@@ -25,6 +46,16 @@ export interface HostAppProps {
} }
const shellStyles = { const shellStyles = {
action: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
},
actions: { display: "flex", gap: "0.5rem", justifyContent: "center" },
aside: { borderRight: "1px solid #e7e5e4", minWidth: "13rem", padding: "1rem" }, aside: { borderRight: "1px solid #e7e5e4", minWidth: "13rem", padding: "1rem" },
groupLabel: { groupLabel: {
color: "#78716c", color: "#78716c",
@@ -34,11 +65,15 @@ const shellStyles = {
textTransform: "uppercase", textTransform: "uppercase",
}, },
header: { header: {
alignItems: "center",
borderBottom: "1px solid #e7e5e4", borderBottom: "1px solid #e7e5e4",
display: "flex",
fontWeight: 600, fontWeight: 600,
gridColumn: "1 / -1", gridColumn: "1 / -1",
justifyContent: "space-between",
padding: "0.75rem 1rem", padding: "0.75rem 1rem",
}, },
heading: { font: "inherit", margin: 0 },
layout: { layout: {
color: "#1c1917", color: "#1c1917",
display: "grid", display: "grid",
@@ -47,11 +82,19 @@ const shellStyles = {
minHeight: "100vh", minHeight: "100vh",
}, },
main: { padding: "1rem" }, main: { padding: "1rem" },
message: { color: "#57534e", padding: "1rem" }, message: {
color: "#57534e",
display: "grid",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
justifyItems: "center",
margin: 0,
padding: "1rem",
textAlign: "center",
},
} as const; } as const;
/** Identity, not placement: a module keeps the same selection key wherever it is arranged, so moving /** Identity, not placement: moving a module between groups does not reset what is open. */
* it between groups never resets what is open or remounts what is showing. */
function moduleKey(entry: DashboardNavigationEntry): string { function moduleKey(entry: DashboardNavigationEntry): string {
return `${entry.descriptor.id}@${entry.descriptor.version}`; return `${entry.descriptor.id}@${entry.descriptor.version}`;
} }
@@ -90,77 +133,305 @@ function ModuleButton(props: {
); );
} }
function HostMessage(props: {
readonly children: string;
readonly testId: string;
readonly actions?: ReactElement;
}): ReactElement {
return (
<main aria-live="polite" data-testid={props.testId} style={shellStyles.message}>
<p style={{ margin: 0 }}>{props.children}</p>
{props.actions}
</main>
);
}
/** /**
* The Ekklesia browser shell: sign-in awareness, the Dashboard's configured navigation, and one * Principal first, navigation second. A 403 after an authenticated principal is authorization, not
* mount target. * evidence that the session vanished. Re-read the principal once only to close the narrow race where
* a session expires between those two sequential requests.
*/
async function resolveBootstrapState(
config: EkklesiaHostConfig,
signal: AbortSignal,
): Promise<ResolvedBootstrapState> {
try {
const bootstrap = await bootstrapHost(config, signal);
return bootstrap.principal.kind === "anonymous"
? { status: "anonymous" }
: { bootstrap, status: "ready" };
} catch (error) {
if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") {
return { status: "anonymous" };
}
if (error instanceof HostProtocolError && error.code === "HOST_ACCESS_DENIED") {
try {
const principal = await loadHostPrincipal(config, signal);
return principal.kind === "anonymous"
? { status: "anonymous" }
: { principal, status: "access-denied" };
} catch {
return { status: "failed" };
}
}
return { status: "failed" };
}
}
/**
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
* *
* ## The shell renders the arrangement; it never computes one * The login form replaces the protected application in place. There is no redirect parameter, URL
* * parser, location assignment, or client router in this shell, so the current internal Dashboard URL
* Groups, their order, their labels, which module sits where, and the order within each group all * simply remains the current URL through sign-in and no external/protocol-relative return value can
* arrive already resolved from the server (ADR 0027). This component reads no manifest, performs no * become a navigation sink. Modules receive the same five-field context after authentication; they
* sorting, and knows nothing about how a placement is decided — so changing the Dashboard's ordering * neither render platform login nor receive sign-in/sign-out controls.
* rules, or eventually where configuration is scoped, needs no change here at all. The module id is
* never displayed: the module's own manifest name is.
*
* ## Still not the finished Dashboard
*
* There is one level of grouping and no nesting, no icons, no drag and drop, no editor, no home
* screen or widgets, no theme, and no client routing — selection is state. What this proves is the
* layer beneath a Dashboard: persistent, platform-owned presentation configuration driving a real
* host that mounts flat, independent modules.
*
* Nothing about any specific module appears in this file, or anywhere in this package.
*/ */
export function HostApp(props: HostAppProps): ReactElement { export function HostApp(props: HostAppProps): ReactElement {
const [state, setState] = useState<BootstrapState>({ status: "loading" }); const [state, setState] = useState<BootstrapState>({ status: "loading" });
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined); const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
const revalidationRef = useRef<AbortController | undefined>(undefined);
const generationRef = useRef(0);
const mountedRef = useRef(true);
const { config, importModule } = props; const { config, importModule } = props;
const beginTransition = useCallback(() => {
activeTransitionRef.current?.abort();
revalidationRef.current?.abort();
revalidationRef.current = undefined;
const controller = new AbortController();
activeTransitionRef.current = controller;
generationRef.current += 1;
return { controller, generation: generationRef.current };
}, []);
const isCurrent = useCallback(
(controller: AbortController, generation: number): boolean =>
mountedRef.current &&
!controller.signal.aborted &&
activeTransitionRef.current === controller &&
generationRef.current === generation,
[],
);
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
setState(resolved);
if (resolved.status === "ready") {
const first = allEntries(resolved.bootstrap)[0];
setSelectedKey(first ? moduleKey(first) : undefined);
} else {
setSelectedKey(undefined);
}
}, []);
useEffect(() => { useEffect(() => {
const abort = new AbortController(); mountedRef.current = true;
return () => {
mountedRef.current = false;
activeTransitionRef.current?.abort();
revalidationRef.current?.abort();
};
}, []);
useEffect(() => {
// The value is an explicit manual-retry generation; reading it is what makes Retry re-run this
// otherwise config-driven bootstrap effect.
void bootstrapAttempt;
const { controller, generation } = beginTransition();
setState({ status: "loading" }); setState({ status: "loading" });
void bootstrapHost(config, abort.signal) setSelectedKey(undefined);
.then((bootstrap) => { void resolveBootstrapState(config, controller.signal).then((resolved) => {
if (abort.signal.aborted) { if (!isCurrent(controller, generation)) {
return; return;
}
if (resolved.status === "failed") {
console.error("[ekklesia] host bootstrap failed");
}
commitResolvedState(resolved);
activeTransitionRef.current = undefined;
});
return () => {
if (activeTransitionRef.current === controller) {
controller.abort();
activeTransitionRef.current = undefined;
}
};
}, [beginTransition, bootstrapAttempt, commitResolvedState, config, isCurrent]);
const authenticate = useCallback(
async (credentials: HostSignInCredentials, formSignal: AbortSignal): Promise<void> => {
const { controller, generation } = beginTransition();
const abortFromForm = (): void => controller.abort();
formSignal.addEventListener("abort", abortFromForm, { once: true });
try {
await signInWithEmail(config, credentials, controller.signal);
const resolved = await resolveBootstrapState(config, controller.signal);
if (!isCurrent(controller, generation)) {
throw new HostAuthError("AUTH_REQUEST_FAILED");
} }
setState( if (resolved.status === "failed") {
bootstrap.principal.kind === "anonymous" console.error("[ekklesia] post-sign-in principal resolution failed");
? { status: "signed-out" }
: { bootstrap, status: "ready" },
);
const first = allEntries(bootstrap)[0];
setSelectedKey(first ? moduleKey(first) : undefined);
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
return;
} }
console.error("[ekklesia] host bootstrap failed", error); commitResolvedState(resolved);
setState( activeTransitionRef.current = undefined;
error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED" if (resolved.status === "anonymous" || resolved.status === "failed") {
? { status: "signed-out" } throw new HostAuthError("AUTH_REQUEST_FAILED");
: { status: "failed" }, }
); } finally {
}); formSignal.removeEventListener("abort", abortFromForm);
return () => abort.abort(); if (activeTransitionRef.current === controller) {
}, [config]); activeTransitionRef.current = undefined;
}
}
},
[beginTransition, commitResolvedState, config, isCurrent],
);
const signOut = useCallback(async (): Promise<void> => {
const { controller, generation } = beginTransition();
// The authenticated subtree disappears before the request starts. That synchronously schedules
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
setSelectedKey(undefined);
setState({ status: "signing-out" });
try {
await signOutHost(config, controller.signal);
const principal = await loadHostPrincipal(config, controller.signal);
if (!isCurrent(controller, generation)) {
return;
}
setState(
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" },
);
} catch {
if (isCurrent(controller, generation)) {
console.error("[ekklesia] sign out failed");
setState({ status: "sign-out-failed" });
}
} finally {
if (activeTransitionRef.current === controller) {
activeTransitionRef.current = undefined;
}
}
}, [beginTransition, config, isCurrent]);
const onAuthorizationResponse = useCallback(
(status: 401 | 403): void => {
// Module handlers are not the platform auth authority: one may emit an ordinary local 401,
// while Ekklesia authorization uses 403 for both anonymous and authenticated denials. Re-read
// principal once, de-duplicated across a burst of either status. Only an anonymous answer
// expires the shell; an authenticated answer leaves the module's original response alone. A
// principal failure is an unavailable platform, never a fabricated logout.
void status;
if (revalidationRef.current) {
return;
}
const controller = new AbortController();
const observedGeneration = generationRef.current;
revalidationRef.current = controller;
void loadHostPrincipal(config, controller.signal)
.then((principal) => {
if (
controller.signal.aborted ||
!mountedRef.current ||
generationRef.current !== observedGeneration
) {
return;
}
if (principal.kind === "anonymous") {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setState({ status: "anonymous" });
}
})
.catch(() => {
if (
controller.signal.aborted ||
!mountedRef.current ||
generationRef.current !== observedGeneration
) {
return;
}
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
console.error("[ekklesia] session revalidation failed");
setState({ status: "failed" });
})
.finally(() => {
if (revalidationRef.current === controller) {
revalidationRef.current = undefined;
}
});
},
[beginTransition, config],
);
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
if (state.status === "loading") { if (state.status === "loading") {
return <p style={shellStyles.message}>Loading Ekklesia…</p>; return <HostMessage testId="host-loading">Loading Ekklesia…</HostMessage>;
} }
if (state.status === "signed-out") { if (state.status === "anonymous") {
return ( return <LoginForm onAuthenticate={authenticate} />;
<p data-testid="host-signed-out" style={shellStyles.message}>
Sign in to Ekklesia to use the module host.
</p>
);
} }
if (state.status === "failed") { if (state.status === "failed") {
return ( return (
<p data-testid="host-error" style={shellStyles.message}> <HostMessage
Ekklesia could not be reached. Refresh to try again. actions={
</p> <button onClick={retryBootstrap} style={shellStyles.action} type="button">
Retry
</button>
}
testId="host-error"
>
Ekklesia could not be reached.
</HostMessage>
);
}
if (state.status === "signing-out") {
return <HostMessage testId="host-signing-out">Signing out…</HostMessage>;
}
if (state.status === "sign-out-failed") {
return (
<HostMessage
actions={
<div style={shellStyles.actions}>
<button onClick={() => void signOut()} style={shellStyles.action} type="button">
Retry sign out
</button>
<button onClick={retryBootstrap} style={shellStyles.action} type="button">
Return to Ekklesia
</button>
</div>
}
testId="host-sign-out-error"
>
Ekklesia could not sign you out. Try again.
</HostMessage>
);
}
if (state.status === "access-denied") {
return (
<HostMessage
actions={
<button
data-testid="host-logout"
onClick={() => void signOut()}
style={shellStyles.action}
type="button"
>
Sign out
</button>
}
testId="host-access-denied"
>
You are signed in, but do not have access to this Dashboard.
</HostMessage>
); );
} }
@@ -171,7 +442,17 @@ export function HostApp(props: HostAppProps): ReactElement {
return ( return (
<div style={shellStyles.layout}> <div style={shellStyles.layout}>
<header style={shellStyles.header}>Ekklesia</header> <header style={shellStyles.header}>
<h1 style={shellStyles.heading}>Ekklesia</h1>
<button
data-testid="host-logout"
onClick={() => void signOut()}
style={shellStyles.action}
type="button"
>
Sign out
</button>
</header>
<aside style={shellStyles.aside}> <aside style={shellStyles.aside}>
{entries.length === 0 && groups.length === 0 ? ( {entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}> <p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
@@ -180,8 +461,6 @@ export function HostApp(props: HostAppProps): ReactElement {
) : ( ) : (
<nav aria-label="Modules" style={{ display: "grid", gap: "1rem" }}> <nav aria-label="Modules" style={{ display: "grid", gap: "1rem" }}>
{groups.map((group) => ( {groups.map((group) => (
// An empty group still renders: an administrator created it, and hiding it would look
// like it had been deleted.
<section data-testid={`group-${group.id}`} key={group.id}> <section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2> <h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}> <div style={{ display: "grid", gap: "0.125rem" }}>
@@ -197,9 +476,6 @@ export function HostApp(props: HostAppProps): ReactElement {
</section> </section>
))} ))}
{ungrouped.length > 0 ? ( {ungrouped.length > 0 ? (
// Where a module lives until someone decides otherwise — including one installed five
// minutes ago. The heading is omitted when nothing is grouped, so an unconfigured
// installation reads as a plain module list rather than a mysterious "Other" bucket.
<section data-testid="group-ungrouped"> <section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined} {groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}> <div style={{ display: "grid", gap: "0.125rem" }}>
@@ -219,14 +495,12 @@ export function HostApp(props: HostAppProps): ReactElement {
</aside> </aside>
<main style={shellStyles.main}> <main style={shellStyles.main}>
{selected ? ( {selected ? (
// Keyed by module identity — not by group — so switching selection discards this frame and
// its container entirely, while re-grouping a module never remounts it. See
// `ModuleUiFrame` for why that is what makes a slow, stale load harmless.
<ModuleUiFrame <ModuleUiFrame
config={config} config={config}
descriptor={selected.descriptor} descriptor={selected.descriptor}
{...(importModule ? { importModule } : {})} {...(importModule ? { importModule } : {})}
key={moduleKey(selected)} key={moduleKey(selected)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal} principal={bootstrap.principal}
/> />
) : undefined} ) : undefined}
+200
View File
@@ -0,0 +1,200 @@
import {
type FormEvent,
type KeyboardEvent,
type ReactElement,
useEffect,
useRef,
useState,
} from "react";
import { HostAuthError, type HostSignInCredentials } from "../auth-protocol.js";
export interface LoginFormProps {
readonly onAuthenticate: (
credentials: HostSignInCredentials,
signal: AbortSignal,
) => Promise<void>;
}
const loginStyles = {
button: {
background: "#1c1917",
border: "1px solid #1c1917",
borderRadius: "0.375rem",
color: "#fafaf9",
cursor: "pointer",
font: "inherit",
fontWeight: 600,
padding: "0.625rem 0.875rem",
},
card: {
background: "#ffffff",
border: "1px solid #e7e5e4",
borderRadius: "0.75rem",
boxShadow: "0 12px 32px rgb(28 25 23 / 0.08)",
display: "grid",
gap: "1rem",
maxWidth: "24rem",
padding: "1.5rem",
width: "100%",
},
error: { color: "#9f1239", margin: 0 },
field: { display: "grid", gap: "0.375rem" },
form: { display: "grid", gap: "1rem" },
heading: { fontSize: "1.5rem", margin: 0 },
host: {
alignItems: "center",
background: "#fafaf9",
color: "#1c1917",
display: "flex",
fontFamily: "system-ui, sans-serif",
justifyContent: "center",
minHeight: "100vh",
padding: "1rem",
},
input: {
border: "1px solid #a8a29e",
borderRadius: "0.375rem",
font: "inherit",
padding: "0.625rem 0.75rem",
width: "100%",
},
introduction: { color: "#57534e", margin: 0 },
label: { fontWeight: 600 },
} as const;
/** Host-owned email/password sign-in. Modules never render or call this component. */
export function LoginForm(props: LoginFormProps): ReactElement {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [pending, setPending] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const pendingRef = useRef(false);
const passwordRef = useRef<HTMLInputElement | null>(null);
const attemptRef = useRef<AbortController | undefined>(undefined);
const mountedRef = useRef(true);
useEffect(() => {
// React development effect replay cleans up and sets up the same mounted component again.
mountedRef.current = true;
return () => {
mountedRef.current = false;
attemptRef.current?.abort();
};
}, []);
useEffect(() => {
if (errorMessage && !pending) {
passwordRef.current?.focus();
}
}, [errorMessage, pending]);
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
// The ref closes the same-tick gap before React commits `pending`, while the disabled button
// covers ordinary button and keyboard interaction after that commit.
if (pendingRef.current) {
return;
}
pendingRef.current = true;
setPending(true);
setErrorMessage(undefined);
const attempt = new AbortController();
attemptRef.current = attempt;
try {
// Capture the submitted value for this one request, then remove it from React state and the
// DOM as soon as the credential callback has begun. Principal/navigation resolution may take
// much longer than the POST and must not keep the password resident in the form meanwhile.
const authentication = props.onAuthenticate({ email, password }, attempt.signal);
setPassword("");
await authentication;
} catch (error) {
if (!attempt.signal.aborted && mountedRef.current) {
setErrorMessage(
error instanceof HostAuthError ? error.message : "Unable to sign in right now.",
);
}
} finally {
if (attemptRef.current === attempt) {
attemptRef.current = undefined;
pendingRef.current = false;
if (mountedRef.current) {
setPending(false);
}
}
}
};
const submitOnEnter = (event: KeyboardEvent<HTMLFormElement>): void => {
if (
event.key !== "Enter" ||
event.defaultPrevented ||
event.repeat ||
event.nativeEvent.isComposing ||
!(event.target instanceof HTMLInputElement)
) {
return;
}
// Browsers implicitly submit a form from these fields. Calling requestSubmit explicitly keeps
// that keyboard contract testable in DOM implementations that do not emulate the default key
// action, while still using native constraint validation and the single onSubmit path.
event.preventDefault();
event.currentTarget.requestSubmit();
};
return (
<main data-testid="host-login" style={loginStyles.host}>
<section aria-labelledby="ekklesia-login-heading" style={loginStyles.card}>
<h1 id="ekklesia-login-heading" style={loginStyles.heading}>
Sign in to Ekklesia
</h1>
<p style={loginStyles.introduction}>Use your existing Ekklesia account.</p>
<form
aria-busy={pending}
onKeyDown={submitOnEnter}
onSubmit={submit}
style={loginStyles.form}
>
<label style={loginStyles.field}>
<span style={loginStyles.label}>Email</span>
<input
autoComplete="username"
data-testid="login-email"
disabled={pending}
inputMode="email"
name="email"
onChange={(event) => setEmail(event.currentTarget.value)}
required
style={loginStyles.input}
type="email"
value={email}
/>
</label>
<label style={loginStyles.field}>
<span style={loginStyles.label}>Password</span>
<input
aria-describedby={errorMessage ? "ekklesia-login-error" : undefined}
autoComplete="current-password"
data-testid="login-password"
disabled={pending}
name="password"
onChange={(event) => setPassword(event.currentTarget.value)}
ref={passwordRef}
required
style={loginStyles.input}
type="password"
value={password}
/>
</label>
{errorMessage ? (
<p id="ekklesia-login-error" role="alert" style={loginStyles.error}>
{errorMessage}
</p>
) : undefined}
<button disabled={pending} style={loginStyles.button} type="submit">
{pending ? "Signing in…" : "Sign in"}
</button>
</form>
</section>
</main>
);
}
+21 -3
View File
@@ -10,6 +10,8 @@ export interface ModuleUiFrameProps {
readonly config: EkklesiaHostConfig; readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor; readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal; readonly principal: ModuleUiPrincipal;
/** Host-internal session signal. It never enters the module-facing context. */
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove /** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
* the stale-load rule. Production composition leaves it unset and the loader's own * the stale-load rule. Production composition leaves it unset and the loader's own
* `(url) => import(url)` runs. */ * `(url) => import(url)` runs. */
@@ -49,7 +51,7 @@ export interface ModuleUiFrameProps {
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement { export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading"); const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, principal } = props; const { config, descriptor, importModule, onAuthorizationResponse, principal } = props;
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -60,11 +62,27 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined; let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
setStatus("loading"); setStatus("loading");
const reportAuthorizationResponse = (status: 401 | 403): void => {
// A module may retain an in-flight request while logout or another auth transition unmounts
// this frame. Its eventual response still belongs to that module call, but must not be allowed
// to cancel or overwrite the newer host transition.
if (!cancelled) {
onAuthorizationResponse?.(status);
}
};
void (async () => { void (async () => {
try { try {
const instance = await loadModuleUi({ const instance = await loadModuleUi({
container, container,
context: createModuleUiContext({ config, descriptor, principal }), context: createModuleUiContext({
config,
descriptor,
principal,
...(onAuthorizationResponse
? { onAuthorizationResponse: reportAuthorizationResponse }
: {}),
}),
...(importModule ? { importModule } : {}), ...(importModule ? { importModule } : {}),
source: { source: {
moduleId: descriptor.id, moduleId: descriptor.id,
@@ -94,7 +112,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
// already in progress, and a module that fails to clean up must not take the shell with it. // already in progress, and a module that fails to clean up must not take the shell with it.
void mounted?.unmount().catch(() => undefined); void mounted?.unmount().catch(() => undefined);
}; };
}, [config, descriptor, importModule, principal]); }, [config, descriptor, importModule, onAuthorizationResponse, principal]);
return ( return (
<section aria-live="polite" style={{ display: "grid", gap: "0.75rem" }}> <section aria-live="polite" style={{ display: "grid", gap: "0.75rem" }}>
+7 -2
View File
@@ -43,8 +43,10 @@ const SEMANTIC_VERSION_PATTERN =
/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/u; /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9][0-9]*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/u;
export type HostProtocolErrorCode = export type HostProtocolErrorCode =
/** The caller is not signed in, or lacks `core.modules.ui.read`. */ /** The caller is not signed in. */
| "HOST_UNAUTHENTICATED" | "HOST_UNAUTHENTICATED"
/** The caller is signed in but may not read the Dashboard resource. */
| "HOST_ACCESS_DENIED"
/** The API answered, but not with something this shell understands. */ /** The API answered, but not with something this shell understands. */
| "HOST_RESPONSE_INVALID" | "HOST_RESPONSE_INVALID"
/** The API could not be reached, or failed. */ /** The API could not be reached, or failed. */
@@ -61,6 +63,7 @@ export type HostProtocolErrorCode =
| "DISPLAY_ACCESS_DENIED"; | "DISPLAY_ACCESS_DENIED";
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
HOST_ACCESS_DENIED: "You do not have access to the Ekklesia Dashboard.",
HOST_REQUEST_FAILED: "Ekklesia could not be reached.", HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.", HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.",
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.", HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
@@ -460,7 +463,9 @@ export async function loadDashboardNavigation(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<DashboardNavigation> { ): Promise<DashboardNavigation> {
return parseDashboardNavigation( return parseDashboardNavigation(
await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal), await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal, {
forbidden: "HOST_ACCESS_DENIED",
}),
); );
} }
+7
View File
@@ -39,3 +39,10 @@ export {
createModuleUiContext, createModuleUiContext,
moduleUiSourceUrl, moduleUiSourceUrl,
} from "./module-context.js"; } from "./module-context.js";
export {
HostAuthError,
type HostAuthErrorCode,
type HostSignInCredentials,
signInWithEmail,
signOutHost,
} from "./auth-protocol.js";
+12 -2
View File
@@ -220,6 +220,7 @@ function createModuleRealtimeClient(
function createModuleApiClient( function createModuleApiClient(
config: EkklesiaHostConfig, config: EkklesiaHostConfig,
descriptor: HostModuleUiDescriptor, descriptor: HostModuleUiDescriptor,
onAuthorizationResponse?: (status: 401 | 403) => void,
): ModuleUiApiClient { ): ModuleUiApiClient {
const performRequest = config.fetch ?? fetch; const performRequest = config.fetch ?? fetch;
const moduleRoot = new URL(resolveApiUrl(config, descriptor.apiBaseUrl)); const moduleRoot = new URL(resolveApiUrl(config, descriptor.apiBaseUrl));
@@ -244,7 +245,13 @@ function createModuleApiClient(
// The module's own `init` is forwarded whole — method, headers, body, signal — and only // The module's own `init` is forwarded whole — method, headers, body, signal — and only
// `credentials` is imposed, because session handling is the host's job and a module UI never // `credentials` is imposed, because session handling is the host's job and a module UI never
// sees or supplies a token (ADR 0022). A module cannot opt out of it. // sees or supplies a token (ADR 0022). A module cannot opt out of it.
return performRequest(target.href, { ...init, credentials: "include" }); const response = await performRequest(target.href, { ...init, credentials: "include" });
if (response.status === 401 || response.status === 403) {
// This callback stays entirely on the host side. The module still receives its original
// response, and the public ModuleUiContext gains no authentication control or event.
onAuthorizationResponse?.(response.status);
}
return response;
}, },
}); });
} }
@@ -253,6 +260,9 @@ export interface CreateModuleUiContextOptions {
readonly config: EkklesiaHostConfig; readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor; readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal; readonly principal: ModuleUiPrincipal;
/** Host-only signal used by the Dashboard to revalidate an apparently expired session. It is not
* included in the returned ModuleUiHostContext and is intentionally absent from the UI SDK. */
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
} }
/** /**
@@ -271,7 +281,7 @@ export interface CreateModuleUiContextOptions {
export function createModuleUiContext(options: CreateModuleUiContextOptions): ModuleUiHostContext { export function createModuleUiContext(options: CreateModuleUiContextOptions): ModuleUiHostContext {
const granted = new Set(options.descriptor.permissions); const granted = new Set(options.descriptor.permissions);
return Object.freeze({ return Object.freeze({
api: createModuleApiClient(options.config, options.descriptor), api: createModuleApiClient(options.config, options.descriptor, options.onAuthorizationResponse),
moduleId: options.descriptor.id, moduleId: options.descriptor.id,
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
principal: options.principal, principal: options.principal,
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import { HostAuthError, signInWithEmail, signOutHost } from "../src/auth-protocol.js";
const apiBaseUrl = "https://ekklesia.example.test";
function recordingFetch(response: Response) {
const calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }> = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ init, url: String(input) });
return response.clone();
}) as unknown as typeof fetch;
return { calls, implementation };
}
describe("browser authentication transport", () => {
it("posts email/password to the existing endpoint with browser-managed credentials", async () => {
const { calls, implementation } = recordingFetch(Response.json({ user: {} }));
const password = "correct horse battery staple";
await signInWithEmail(
{ apiBaseUrl, fetch: implementation },
{ email: "[email protected]", password },
);
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/auth/sign-in/email`);
expect(calls[0]?.url).not.toContain(password);
expect(calls[0]?.init).toMatchObject({
cache: "no-store",
credentials: "include",
method: "POST",
redirect: "error",
});
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
email: "[email protected]",
password,
});
expect(calls[0]?.init?.headers).toEqual({
accept: "application/json",
"content-type": "application/json",
});
expect(JSON.stringify(calls[0]?.init?.headers).toLowerCase()).not.toContain("origin");
});
it("maps invalid credentials to one account-neutral, response-neutral failure", async () => {
for (const status of [400, 401]) {
const response = new Response(
JSON.stringify({ detail: "user exists at /private/database", email: "[email protected]" }),
{ status },
);
const { implementation } = recordingFetch(response);
const error = await signInWithEmail(
{ apiBaseUrl, fetch: implementation },
{ email: "[email protected]", password: "wrong-secret" },
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(HostAuthError);
expect(error).toMatchObject({
code: "AUTH_CREDENTIALS_INVALID",
message: "Email or password is incorrect.",
});
expect(JSON.stringify(error)).not.toContain("[email protected]");
expect(JSON.stringify(error)).not.toContain("wrong-secret");
expect((error as Error).message).not.toContain("private");
}
});
it("sanitizes transport and server failures without retaining credentials or a cause", async () => {
const password = "do-not-log-this";
const implementation = vi.fn(async () => {
throw new Error(`network failed password=${password} /private/socket`);
}) as unknown as typeof fetch;
const error = await signInWithEmail(
{ apiBaseUrl, fetch: implementation },
{ email: "[email protected]", password },
).catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: "AUTH_REQUEST_FAILED",
message: "Unable to sign in right now.",
});
expect("cause" in (error as object)).toBe(false);
expect(JSON.stringify(error)).not.toContain(password);
expect((error as Error).message).not.toContain("private");
});
it("posts an empty body to the existing sign-out endpoint", async () => {
const { calls, implementation } = recordingFetch(Response.json({ success: true }));
await signOutHost({ apiBaseUrl, fetch: implementation });
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/auth/sign-out`);
expect(calls[0]?.init).toMatchObject({
body: "{}",
credentials: "include",
method: "POST",
});
});
it("uses a sign-out-specific safe error", async () => {
const { implementation } = recordingFetch(
new Response("internal session token", { status: 500 }),
);
await expect(signOutHost({ apiBaseUrl, fetch: implementation })).rejects.toMatchObject({
code: "AUTH_SIGN_OUT_FAILED",
message: "Unable to sign out right now.",
});
});
});
+639 -5
View File
@@ -18,6 +18,7 @@ let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true; globalThis.IS_REACT_ACT_ENVIRONMENT = true;
window.history.replaceState({}, "", "/");
container = document.createElement("div"); container = document.createElement("div");
document.body.append(container); document.body.append(container);
root = createRoot(container); root = createRoot(container);
@@ -134,6 +135,92 @@ function query(testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`); return container.querySelector(`[data-testid="${testId}"]`);
} }
async function setInput(testId: string, value: string): Promise<void> {
const input = query(testId) as HTMLInputElement | null;
if (!input) {
throw new Error(`missing input ${testId}`);
}
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function submitLogin(): Promise<void> {
const form = query("host-login")?.querySelector("form");
if (!form) {
throw new Error("missing login form");
}
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
interface StatefulAuthPlatform {
readonly calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }>;
readonly fetch: typeof fetch;
authenticated(): boolean;
setAuthenticated(value: boolean): void;
}
function statefulAuthPlatform(
options: {
readonly initiallyAuthenticated?: boolean;
readonly moduleStatus?: () => number;
readonly onModuleRequest?: () => Promise<void>;
readonly onPrincipalRequest?: (authenticated: boolean) => Promise<void>;
readonly onSignIn?: () => Promise<void>;
readonly onSignOut?: () => Promise<void>;
} = {},
): StatefulAuthPlatform {
let authenticated = options.initiallyAuthenticated ?? false;
const calls: StatefulAuthPlatform["calls"] = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
if (url.endsWith("/api/auth/sign-in/email")) {
await options.onSignIn?.();
const body = JSON.parse(String(init?.body)) as { email?: unknown; password?: unknown };
if (body.email !== "[email protected]" || body.password !== "correct password") {
return Response.json({ code: "INVALID_EMAIL_OR_PASSWORD" }, { status: 401 });
}
authenticated = true;
return Response.json({ ok: true });
}
if (url.endsWith("/api/auth/sign-out")) {
await options.onSignOut?.();
authenticated = false;
return Response.json({ success: true });
}
if (url.endsWith("/api/v1/principal")) {
await options.onPrincipalRequest?.(authenticated);
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
if (url.includes("/api/v1/modules/example/")) {
await options.onModuleRequest?.();
return new Response("{}", { status: options.moduleStatus?.() ?? 200 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return {
calls,
fetch: implementation,
authenticated: () => authenticated,
setAuthenticated: (value) => {
authenticated = value;
},
};
}
describe("Ekklesia shell — bootstrap", () => { describe("Ekklesia shell — bootstrap", () => {
it("renders the module list the navigation returned", async () => { it("renders the module list the navigation returned", async () => {
await render({ await render({
@@ -147,17 +234,19 @@ describe("Ekklesia shell — bootstrap", () => {
expect(query("select-calendar")).not.toBeNull(); expect(query("select-calendar")).not.toBeNull();
}); });
it("asks an anonymous viewer to sign in, and mounts nothing", async () => { it("renders the host login for an anonymous viewer, and mounts nothing", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) }); await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) });
expect(query("host-signed-out")).not.toBeNull(); expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull(); expect(query("module-container")).toBeNull();
}); });
it("treats refused navigation as signed out rather than as a crash", async () => { it("keeps an authenticated navigation denial distinct from sign-out", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) }); await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) });
expect(query("host-signed-out")).not.toBeNull(); expect(query("host-access-denied")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull();
}); });
it("renders a host-owned error when the platform cannot be reached", async () => { it("renders a host-owned error when the platform cannot be reached", async () => {
@@ -174,6 +263,549 @@ describe("Ekklesia shell — bootstrap", () => {
}); });
}); });
describe("Ekklesia shell — browser authentication", () => {
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => {
releasePrincipal = resolve;
});
const calls: string[] = [];
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/api/v1/principal")) {
await principalPending;
return Response.json({ kind: "anonymous" });
}
return Response.json({ groups: [], ungrouped: [] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation });
expect(query("host-loading")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).toBeNull();
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
await act(async () => {
releasePrincipal?.();
await principalPending;
});
expect(query("host-login")).not.toBeNull();
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
it("renders an accessible semantic email/password form with no signup or dead reset action", async () => {
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch });
const email = query("login-email") as HTMLInputElement;
const password = query("login-password") as HTMLInputElement;
const form = query("host-login")?.querySelector("form");
const submit = form?.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(email.type).toBe("email");
expect(email.autocomplete).toBe("username");
expect(email.labels?.[0]?.textContent).toContain("Email");
expect(password.type).toBe("password");
expect(password.autocomplete).toBe("current-password");
expect(password.labels?.[0]?.textContent).toContain("Password");
expect(submit.textContent).toBe("Sign in");
expect(form?.tagName).toBe("FORM");
expect(text()).not.toContain("Create account");
expect(text()).not.toContain("Forgot password");
});
it("fails invalid credentials generically, clears the password, and never puts it in a URL", async () => {
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "wrong and secret");
await submitLogin();
const error = query("host-login")?.querySelector('[role="alert"]');
expect(error?.textContent).toBe("Email or password is incorrect.");
expect((query("login-email") as HTMLInputElement).value).toBe("[email protected]");
expect((query("login-password") as HTMLInputElement).value).toBe("");
expect(document.activeElement).toBe(query("login-password"));
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("wrong and secret");
expect(consoleError).not.toHaveBeenCalled();
});
it("prevents duplicate submissions while one sign-in is pending", async () => {
let releaseSignIn: (() => void) | undefined;
const signInPending = new Promise<void>((resolve) => {
releaseSignIn = resolve;
});
const platform = statefulAuthPlatform({ onSignIn: () => signInPending });
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
await submitLogin();
const signInCalls = platform.calls.filter((call) =>
call.url.endsWith("/api/auth/sign-in/email"),
);
expect(signInCalls).toHaveLength(1);
expect(
query("host-login")?.querySelector('button[type="submit"]')?.hasAttribute("disabled"),
).toBe(true);
await act(async () => {
releaseSignIn?.();
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("clears the password while post-sign-in principal resolution is still pending", async () => {
let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => {
releasePrincipal = resolve;
});
const platform = statefulAuthPlatform({
onPrincipalRequest: (authenticated) => (authenticated ? principalPending : Promise.resolve()),
});
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(platform.authenticated()).toBe(true);
expect((query("login-password") as HTMLInputElement).value).toBe("");
expect(
JSON.parse(
String(
platform.calls.find((call) => call.url.endsWith("/api/auth/sign-in/email"))?.init?.body,
),
),
).toEqual({ email: "[email protected]", password: "correct password" });
expect(query("host-login")?.querySelector("form")?.getAttribute("aria-busy")).toBe("true");
await act(async () => {
releasePrincipal?.();
await principalPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("submits from the password field with the Enter key", async () => {
let releaseSignIn: (() => void) | undefined;
const signInPending = new Promise<void>((resolve) => {
releaseSignIn = resolve;
});
const platform = statefulAuthPlatform({ onSignIn: () => signInPending });
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await act(async () => {
query("login-password")?.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }),
);
await Promise.resolve();
});
expect(
platform.calls.filter((call) => call.url.endsWith("/api/auth/sign-in/email")),
).toHaveLength(1);
expect((query("login-password") as HTMLInputElement).value).toBe("");
await act(async () => {
releaseSignIn?.();
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("re-resolves principal and navigation after login without a page reload", async () => {
const platform = statefulAuthPlatform();
const importer = vi.fn(async () => artifact("example", "0.1.0"));
await render({ apiBaseUrl, fetch: platform.fetch }, importer);
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted");
expect(importer).toHaveBeenCalledOnce();
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
"/api/v1/principal",
"/api/auth/sign-in/email",
"/api/v1/principal",
"/api/v1/dashboard/navigation",
]);
});
it("keeps the current internal URL in place and ignores external or protocol-relative return inputs", async () => {
window.history.replaceState(
{},
"",
"/?returnTo=%2F%2Fevil.example.test%2Fsteal&next=https%3A%2F%2Fevil.example.test&backslash=%5C%5Cevil.example.test&scheme=javascript%3Aalert%281%29",
);
const expectedLocation = window.location.href;
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(window.location.href).toBe(expectedLocation);
expect(platform.calls.every((call) => new URL(call.url).origin === apiBaseUrl)).toBe(true);
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("evil.example.test");
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("javascript");
});
it("shows a safe retry when principal resolution is malformed or unavailable", async () => {
let healthy = false;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return healthy
? Response.json({ kind: "authenticated", userId: "user-1" })
: new Response("database failed at /private/path", { status: 503 });
}
return Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation }, async () =>
artifact("example", "0.1.0"),
);
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("private");
healthy = true;
const retry = query("host-error")?.querySelector("button");
expect(retry).not.toBeNull();
await act(async () => {
(retry as HTMLButtonElement).click();
});
expect(query("host-logout")).not.toBeNull();
});
it("fails safely on a malformed successful principal response and recovers on retry", async () => {
let healthy = false;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return Response.json(
healthy
? { kind: "authenticated", userId: "user-1" }
: { kind: "authenticated", privatePath: "/sessions/private" },
);
}
return Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation }, async () =>
artifact("example", "0.1.0"),
);
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("private");
expect(query("host-login")).toBeNull();
healthy = true;
const retry = query("host-error")?.querySelector("button");
expect(retry).not.toBeNull();
await act(async () => {
(retry as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
});
describe("Ekklesia shell — logout and session expiration", () => {
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
releaseSignOut = resolve;
});
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
onSignOut: () => signOutPending,
});
let unmountCalls = 0;
let closeCalls = 0;
const source = {
CLOSED: 2,
CONNECTING: 0,
OPEN: 1,
addEventListener: vi.fn(),
close: () => {
closeCalls += 1;
},
readyState: 0,
removeEventListener: vi.fn(),
} as unknown as EventSource;
await render(
{
apiBaseUrl,
eventSourceFactory: () => source,
fetch: platform.fetch,
},
async () => ({
default: {
id: "example",
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = "example mounted";
const subscription = context.realtime.subscribe("state", {});
return () => {
unmountCalls += 1;
subscription.close();
};
},
version: "0.1.0",
},
}),
);
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-signing-out")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(unmountCalls).toBe(1);
expect(closeCalls).toBe(1);
expect(platform.authenticated()).toBe(true);
await act(async () => {
releaseSignOut?.();
await signOutPending;
await Promise.resolve();
});
expect(platform.authenticated()).toBe(false);
expect(query("host-login")).not.toBeNull();
// A new shell resolves the invalidated server session as anonymous too.
await act(async () => root.unmount());
root = createRoot(container);
await render({ apiBaseUrl, fetch: platform.fetch });
expect(query("host-login")).not.toBeNull();
});
it("ignores a retained module request that resolves after logout unmounts its frame", async () => {
let authenticatedPrincipalRequests = 0;
let releaseModuleRequest: (() => void) | undefined;
const moduleRequestPending = new Promise<void>((resolve) => {
releaseModuleRequest = resolve;
});
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
releaseSignOut = resolve;
});
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
onModuleRequest: () => moduleRequestPending,
onPrincipalRequest: (authenticated) => {
if (!authenticated) {
return Promise.resolve();
}
authenticatedPrincipalRequests += 1;
return authenticatedPrincipalRequests === 1
? Promise.resolve()
: Promise.reject(new Error("stale revalidation must never run"));
},
onSignOut: () => signOutPending,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
const lateRequest = capture.context?.api.request("retained-after-unmount");
if (!lateRequest) {
throw new Error("module context was not captured");
}
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-signing-out")).not.toBeNull();
expect(capture.unmountCalls).toBe(1);
expect(platform.authenticated()).toBe(true);
await act(async () => {
releaseModuleRequest?.();
await lateRequest;
await Promise.resolve();
});
// The 401 belongs to a request retained by an already-unmounted module. It must not abort or
// replace the newer sign-out transition while the server still owns an authenticated session.
expect(query("host-signing-out")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(platform.authenticated()).toBe(true);
expect(authenticatedPrincipalRequests).toBe(1);
await act(async () => {
releaseSignOut?.();
await signOutPending;
await Promise.resolve();
});
expect(platform.authenticated()).toBe(false);
expect(query("host-login")).not.toBeNull();
});
it("does not claim logout succeeded when session invalidation fails", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
onSignOut: () => Promise.reject(new Error("private session failure")),
});
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-sign-out-error")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("module-container")).toBeNull();
expect(text()).not.toContain("private session failure");
});
function sessionAwareArtifact(
capture: { context?: ModuleUiHostContext; unmountCalls: number },
options: { readonly subscribeToRealtime?: boolean } = {},
) {
return {
default: {
id: "example",
mount(element: Element, context: ModuleUiHostContext) {
capture.context = context;
element.textContent = "example mounted";
const subscription = options.subscribeToRealtime
? context.realtime.subscribe("state", {})
: undefined;
return () => {
capture.unmountCalls += 1;
subscription?.close();
element.textContent = "";
};
},
version: "0.1.0",
},
};
}
it("returns to login when a module API 401 recheck proves the session became anonymous", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
});
let closeCalls = 0;
const source = {
CLOSED: 2,
CONNECTING: 0,
OPEN: 1,
addEventListener: vi.fn(),
close: () => {
closeCalls += 1;
},
readyState: 0,
removeEventListener: vi.fn(),
} as unknown as EventSource;
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render(
{
apiBaseUrl,
eventSourceFactory: () => source,
fetch: platform.fetch,
},
async () => sessionAwareArtifact(capture, { subscribeToRealtime: true }),
);
platform.setAuthenticated(false);
const principalCallsBefore = platform.calls.filter((call) =>
call.url.endsWith("/api/v1/principal"),
).length;
await act(async () => {
await capture.context?.api.request("expired");
});
expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(capture.unmountCalls).toBe(1);
expect(closeCalls).toBe(1);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(
principalCallsBefore + 1,
);
});
it("preserves an authenticated session after an ordinary module API 401", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
await act(async () => {
const response = await capture.context?.api.request("module-authentication-required");
expect(response?.status).toBe(401);
await Promise.resolve();
});
expect(query("host-login")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted");
expect(capture.unmountCalls).toBe(0);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
});
it("rechecks but does not log out an authenticated principal after an ordinary 403", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 403,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
await act(async () => {
const response = await capture.context?.api.request("forbidden");
expect(response?.status).toBe(403);
await Promise.resolve();
});
expect(query("host-login")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted");
expect(capture.unmountCalls).toBe(0);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
});
it("returns to login when a 403 recheck proves the session became anonymous", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 403,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
platform.setAuthenticated(false);
await act(async () => {
await capture.context?.api.request("forbidden-after-expiry");
await Promise.resolve();
});
expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(capture.unmountCalls).toBe(1);
});
});
describe("Ekklesia shell — Dashboard navigation", () => { describe("Ekklesia shell — Dashboard navigation", () => {
const mediaGroup = () => const mediaGroup = () =>
group("media", "Media", [entry("video", "Video"), entry("livestream", "Livestream", "2.0.0")]); group("media", "Media", [entry("video", "Video"), entry("livestream", "Livestream", "2.0.0")]);
@@ -209,7 +841,9 @@ describe("Ekklesia shell — Dashboard navigation", () => {
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent); const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
expect(labels).toEqual(["Planning", "Media"]); expect(labels).toEqual(["Planning", "Media"]);
const buttons = [...container.querySelectorAll("button")].map((button) => button.textContent); const buttons = [...container.querySelectorAll("nav button")].map(
(button) => button.textContent,
);
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]); expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
}); });
+42 -10
View File
@@ -128,16 +128,20 @@ describe("host protocol — descriptor validation", () => {
} }
}); });
it("reports an unauthenticated caller distinctly from any other failure", async () => { it("distinguishes an authentication challenge from an authorization denial", async () => {
for (const status of [401, 403]) { const unauthenticated = stubFetch({
const { implementation } = stubFetch({ "/api/v1/dashboard/navigation": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, 401),
"/api/v1/dashboard/navigation": () => });
jsonResponse({ code: "AUTHORIZATION_DENIED" }, status), await expect(
}); loadDashboardNavigation({ apiBaseUrl, fetch: unauthenticated.implementation }),
await expect( ).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" });
loadDashboardNavigation({ apiBaseUrl, fetch: implementation }),
).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" }); const forbidden = stubFetch({
} "/api/v1/dashboard/navigation": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, 403),
});
await expect(
loadDashboardNavigation({ apiBaseUrl, fetch: forbidden.implementation }),
).rejects.toMatchObject({ code: "HOST_ACCESS_DENIED" });
const { implementation } = stubFetch({ const { implementation } = stubFetch({
"/api/v1/dashboard/navigation": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500), "/api/v1/dashboard/navigation": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500),
@@ -346,6 +350,34 @@ describe("module UI host context", () => {
}); });
}); });
it("reports only 401/403 to the host while returning the module's original response", async () => {
const statuses = [401, 403, 404, 200];
const onAuthorizationResponse = vi.fn();
const implementation = vi.fn(async () => {
const status = statuses.shift();
if (status === undefined) {
throw new Error("unexpected request");
}
return new Response("module response", { status });
}) as unknown as typeof fetch;
const context = createModuleUiContext({
config: { apiBaseUrl, fetch: implementation },
descriptor: exampleDescriptor,
onAuthorizationResponse,
principal: { kind: "authenticated", userId: "user-1" },
});
const responses = await Promise.all([
context.api.request("one"),
context.api.request("two"),
context.api.request("three"),
context.api.request("four"),
]);
expect(responses.map((response) => response.status)).toEqual([401, 403, 404, 200]);
expect(onAuthorizationResponse.mock.calls).toEqual([[401], [403]]);
});
it("refuses to address anything outside the module's own API", async () => { it("refuses to address anything outside the module's own API", async () => {
const { calls, implementation } = recordingFetch(); const { calls, implementation } = recordingFetch();
const api = contextFor(implementation).api; const api = contextFor(implementation).api;
+12
View File
@@ -149,11 +149,21 @@ describe("the shell builds no module knowledge into its bundle", () => {
// time from the navigation the server resolved. // time from the navigation the server resolved.
expect(bundle).toContain('"/api/v1/principal"'); expect(bundle).toContain('"/api/v1/principal"');
expect(bundle).toContain('"/api/v1/dashboard/navigation"'); expect(bundle).toContain('"/api/v1/dashboard/navigation"');
expect(bundle).toContain('"/api/auth/sign-in/email"');
expect(bundle).toContain('"/api/auth/sign-out"');
// The shell reads the navigation view, which already contains the inventory; it never calls // The shell reads the navigation view, which already contains the inventory; it never calls
// the host-neutral inventory endpoint itself. // the host-neutral inventory endpoint itself.
expect(bundle).not.toContain('"/api/v1/module-ui"'); expect(bundle).not.toContain('"/api/v1/module-ui"');
}); });
it("contains only the browser HTTP auth adapter, never Better Auth or server identity internals", () => {
expect(bundle.toLowerCase()).not.toContain("better-auth");
expect(bundle.toLowerCase()).not.toContain("drizzle");
expect(bundle).not.toContain("EKKLESIA_IDENTITY_SECRET");
expect(bundle).not.toContain("bootstrapPlatformOwner");
expect(bundle).not.toContain("session_token");
});
it("bakes in no group, label, or placement", () => { it("bakes in no group, label, or placement", () => {
// Grouping is server-resolved configuration. A group id or label appearing in the bundle would // Grouping is server-resolved configuration. A group id or label appearing in the bundle would
// mean the shell had an opinion about an arrangement it must only render. // mean the shell had an opinion about an arrangement it must only render.
@@ -208,6 +218,8 @@ describe("the display host is a separate generic browser bundle", () => {
expect(bundle).toContain('"/api/v1/principal"'); expect(bundle).toContain('"/api/v1/principal"');
expect(bundle).toContain('"/api/v1/module-displays"'); expect(bundle).toContain('"/api/v1/module-displays"');
expect(bundle).not.toContain('"/api/v1/dashboard/navigation"'); expect(bundle).not.toContain('"/api/v1/dashboard/navigation"');
expect(bundle).not.toContain('"/api/auth/sign-in/email"');
expect(bundle).not.toContain('"/api/auth/sign-out"');
expect(bundle).not.toContain("Loading Ekklesia"); expect(bundle).not.toContain("Loading Ekklesia");
expect(bundle).not.toContain("Sign in to Ekklesia"); expect(bundle).not.toContain("Sign in to Ekklesia");
expect(bundle.toLowerCase()).not.toContain("socket.io"); expect(bundle.toLowerCase()).not.toContain("socket.io");