diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff17e7f..1833ffc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,8 @@ Before opening a change, run: 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 pnpm test:migrations:integration diff --git a/README.md b/README.md index 7414c42..be71e20 100644 --- a/README.md +++ b/README.md @@ -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 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, -not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since -completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the +not-yet-deployed threat model.** Phase 6 completed through 6D, and **Phase 7A passes 1 (the minimal +operational module lifecycle), 2 (the 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/ -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 -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 [Security model and deployment trust boundaries](docs/development/security.md). The repository includes 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 [API](docs/development/api.md) and [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 -production approval-provider composition, jobs, physical storage provisioning, surfaces, and -feature modules, remains later vertical slices. Public registration is not yet ready for a real -public launch. +Email verification, password recovery, invitation/operator enrollment, and a supported production +first-user creation path still do not exist. Public registration remains disabled by default and +prohibited in production. The Dashboard now has a minimal authenticated shell and login/logout flow, +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 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 [ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md). -**What that pass shipped is the static contract only.** `manifestVersion: 4` adds one -optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated +**What that pass shipped at its checkpoint was the static contract only.** `manifestVersion: 4` +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 -TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly -like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a -one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was -before it. **The Dashboard shell, a UI runtime or renderer, navigation -registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading, -and every feature module do not exist.** Restart-based discovery, explicit operator-invoked +TypeScript. Discovery stayed inert: **nothing imported, mounted, or rendered that entrypoint**, +exactly like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest was a +one-character edit; version 3 was rejected outright rather than reinterpreted, as version 2 was +before it. The Dashboard shell, runtime mounting, and navigation named as absent at that checkpoint +landed in subsequent Phase 6 passes. A standalone host, module installer or marketplace, hot-loading, +and every real feature module still do not exist. Restart-based discovery, explicit operator-invoked 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, @@ -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 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 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 -a CLI, not a screen, and object storage is deployment configuration. Sign-in UI, a production CSP -and deployment for the built shells, general sidecar asset publication, React runtime sharing, the +a CLI, not a screen, and object storage is deployment configuration. A production CSP and +deployment for the built shells, general sidecar asset publication, React runtime sharing, the standalone host, and every real feature module also do not exist. **Security model.** Installed modules are trusted compiled backend code that runs arbitrary diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dca5455..ad24cbf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,7 +1,7 @@ import { isAuthenticatedPrincipal, - type ModuleSecretSource, type ModuleRuntimeServices, + type ModuleSecretSource, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; import type { @@ -9,31 +9,31 @@ import type { AuthorizationStore, PermissionRegistry, } from "@ekklesia/core/authorization"; -import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import type { DashboardStore } from "@ekklesia/core/dashboard"; +import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import type { DiscoveredModule, + ModuleConfigurationLookup, ModuleConfigurationSettings, ModuleConfigurationStore, ModuleLifecycleSnapshot, - ModuleConfigurationLookup, ModuleLifecycleStore, - ModuleStorageLookup, ModuleRealtimeRegistry, + ModuleStorageLookup, ModuleUiPublicationRegistry, } from "@ekklesia/core/modules"; -import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; +import { Hono } from "hono"; import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.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 { createGrantsRoutes } from "./grants-routes.js"; +import { createModuleConfigurationRoutes } from "./module-configuration-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 { createModuleUiRoutes } from "./module-ui-routes.js"; @@ -138,6 +138,87 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) { 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> = + 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 * 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/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); app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => { try { @@ -212,13 +306,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono { // 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, // and never reads or buffers the body. - const headers = new Headers(response.headers); - headers.set("Cache-Control", "no-store"); - return new Response(response.body, { - headers, - status: response.status, - statusText: response.statusText, - }); + return browserSafeAuthResponse(c.req.raw, response); } catch (error) { return sanitizedErrorResponse(c, error, 500); } diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 6f83bd4..bd5d5c9 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -1,8 +1,10 @@ import { randomBytes } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { request as httpRequest } from "node:http"; import net from "node:net"; -import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts"; import { bootstrapPlatformOwner, @@ -15,8 +17,12 @@ import { createPostgresMigrationDatabase, discoverAllMigrations, } from "@ekklesia/core/migrations"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { + bootstrapHost, + type EkklesiaHostConfig, + signInWithEmail, + signOutHost, +} from "@ekklesia/shell"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it, vi } from "vitest"; import type { ClientAddressConfigInput } from "../src/client-address.js"; @@ -26,7 +32,7 @@ import { MODULE_UI_CONTENT_TYPE, moduleUiArtifactUrl, } 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 coreMigrationsDirectory = path.join( @@ -602,6 +608,139 @@ function firstCookiePair(setCookieHeader: string): string { 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 { readonly response: Response; 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 setCookie = setCookieValue(response); + const browserVisibleBody = await response.clone().text(); + 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(cookiePair).toBeDefined(); sessionCookie = cookiePair as string; const cookieText = setCookie as string; + expect(cookieText).toContain("Path=/"); expect(cookieText).toContain("HttpOnly"); expect(cookieText).toContain("SameSite=Lax"); 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", () => { it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => { const database = await createDisposableDatabase("unmigrated"); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 8bc7e8c..97c27e6 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -11,22 +11,22 @@ import { type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, EMPTY_MODULE_REALTIME_REGISTRY, type ModuleUiPublicationRegistry, } from "@ekklesia/core/modules"; -import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; -import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { - createApiApp as createApiAppWithModules, type CreateApiAppAuthorizationOptions, type CreateApiAppOptions, + createApiApp as createApiAppWithModules, } from "../src/app.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 { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; const trustedOrigin = "https://dashboard.example.test"; @@ -96,6 +96,21 @@ function fakeAuthorization(overrides: { 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", () => { it("mounts Better Auth exactly under /api/auth/*", async () => { const runtime = fakeRuntime(); @@ -115,10 +130,222 @@ describe("Ekklesia API Hono application", () => { const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); expect(response.status).toBe(200); - expect(await response.text()).toBe("auth-ok"); + expect(await response.json()).toEqual({ ok: true }); 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: "owner@example.test", 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("owner@example.test"); + 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: "owner@example.test" }, + }), + ), + }); + 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 () => { const runtime = fakeRuntime(); const app = createApiApp({ @@ -289,7 +516,7 @@ describe("Ekklesia API Hono application", () => { const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); 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"); }); diff --git a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md index b531e72..0e88cbb 100644 --- a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md +++ b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md @@ -310,6 +310,11 @@ not exist yet. ## 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, 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 diff --git a/docs/architecture/decisions/0034-host-owned-browser-authentication.md b/docs/architecture/decisions/0034-host-owned-browser-authentication.md new file mode 100644 index 0000000..b6352fb --- /dev/null +++ b/docs/architecture/decisions/0034-host-owned-browser-authentication.md @@ -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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 2287e7a..0bc99d7 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 - [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 +- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index ca2d2e3..ed8a4ea 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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 [the API guide](../development/api.md), 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 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. 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 — -`moduleId`, `api`, `principal`, `permissions`. Module UI reaches its own backend through +dependencies, consumed by module authors and hosts alike. Its host context is five members — +`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 Ekklesia's `/api/v1/modules/` prefix, which is what keeps a standalone host possible. 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; 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 -only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a -stylesheet loading contract, the standalone host, and every feature module remain future work. +only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime +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.** [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. **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 -for the built shell, sign-in UI, general asset publication, and the standalone host remain future -work. +Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment +for the built shell, general asset publication, and the standalone host remain future work. Phase 7B +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 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, targeting, display inventory/management, device/public identity, a standalone host, shared CSS, 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. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index 8bd73ac..fcfb45b 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -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, 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. +- 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 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 diff --git a/docs/development/api.md b/docs/development/api.md index b6311ee..6b52e00 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -1,8 +1,9 @@ # API -Phase 4B adds `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 -Mobile Surface, and it does not add roles, permissions, or a platform-owner concept — see +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. Later phases added authorization, module +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 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, @@ -24,14 +25,14 @@ keyset listing — see ## What this is, and is not -After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia -principal, and sign out; public account creation is also possible once explicitly enabled (see -[Public registration policy](#public-registration-policy)). It is **not** possible to verify an -email address, recover a forgotten password, use any provider besides email/password, reach a -Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not +It is possible to start the API, sign in, resolve an Ekklesia principal, sign out, and drive the +Dashboard's authenticated shell. Public account creation is possible only when explicitly enabled +for development/test (see [Public registration policy](#public-registration-policy)). It is **not** +possible to verify an email address, recover a forgotten password, use another provider, or create +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 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 @@ -72,7 +73,9 @@ No real values are shown here — configure per environment and never commit the | 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\|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//*` | 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/` | 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/` | 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 -deterministic `404 {"code":"NOT_FOUND"}`. +Better Auth's handler is unreachable outside `/api/auth/*`. The four denied native +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 @@ -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, 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, -which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user -agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the -Ekklesia-owned contract every other Core/Surface consumer should use; it never contains email, -name, image, session id or expiry, session token, account/provider details, or any role, -permission, membership, or volunteer data. +`/api/v1/principal` is the only supported browser session read. Better Auth's native +`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and +`/api/auth/change-password` routes expose or mutate token-bearing native session/user shapes, so +Ekklesia denies them with the sanitized `404` above instead of allowing a richer parallel identity +contract. Password/account management is out of scope; the pinned change-password route can return +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 @@ -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 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 -access, 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 -slice to grant any of that, and no code path in this repository inspects how many accounts already -exist or treats an empty database as special. Baseline authenticated status is what will eventually -let a signed-in person use ownership-scoped personal features (watch history, continue watching, -saved content, preferences) — none of those features are implemented in this PR. +management authority, no module-management access, no staff/volunteer-management access, and no +media/event/form/roster/people-management permission. A signed-in identity can satisfy permissions +whose registered baseline is deliberately `authenticated` — including the Dashboard's +`core.modules.ui.read` — but signup itself creates no explicit grant and no code path inspects how many +accounts already exist or treats an empty database as special. Baseline authenticated status may +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 [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 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** 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: @@ -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` 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 -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 @@ -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 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 — -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 @@ -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 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 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 -[Public registration policy](#public-registration-policy)). Session tokens, cookie values, and -generated ids below are sanitized/truncated examples, not real credentials. +[Public registration policy](#public-registration-policy)). Cookie values and generated ids below +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 POST /api/auth/sign-up/email @@ -1313,7 +1381,7 @@ POST /api/auth/sign-in/email {"email":"alice@example.test","password":""} → 200 Set-Cookie: better-auth.session_token=; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure -{"redirect":false,"token":"","user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1", ...}} +{"ok":true} GET /api/v1/principal Cookie: better-auth.session_token= @@ -1338,5 +1406,7 @@ Cookie: better-auth.session_token= See the repository root [README](../../README.md#current-status) and [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 -tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve. +for what has landed across every phase and what remains. Surface work is underway; production +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. diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 1ee021f..f115c08 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -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 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 `core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated, diff --git a/docs/development/identity.md b/docs/development/identity.md index b3dc27d..4900c46 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -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 cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of what Core exposes. -- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**, - never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that - transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface exists yet. +- **Surfaces authenticate through Core-exposed HTTP/API contracts**, never by querying Better + Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that transport. The Dashboard + 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 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 @@ -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. 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) @@ -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 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 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): @@ -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 [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 -provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model -exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are** -operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why -public registration is intentional and grants no elevation. Core's authorization foundation -(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is -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. +Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow +are operational — see [API](api.md) and ADR 0034. Public registration remains a future product +policy, not a production capability: it is disabled by default, may be explicitly enabled only for +development/test, and production rejects it. No email verification, password recovery, invitation +flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or +roles/groups/teams/tenants model exists. + +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. diff --git a/docs/development/security.md b/docs/development/security.md index 3d8b681..b4f6720 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -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 -Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through -`GET /api/v1/module-ui` and mounts them with the generic loader +Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through the resolved +`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 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 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 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 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. 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. @@ -458,6 +498,13 @@ outstanding. 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 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 @@ -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 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. +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 diff --git a/docs/development/testing.md b/docs/development/testing.md index f10aab7..102d8a9 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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 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 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 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 -`HttpOnly`/`SameSite=Lax`/secure session cookie, `/api/v1/principal` for both the authenticated and -anonymous shapes, sign-out actually invalidating the session, a malformed or missing cookie +`HttpOnly`/`SameSite=Lax`/secure session cookie and fixed `{"ok":true}` body containing no token, +`/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 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 @@ -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 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 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 diff --git a/surfaces/shell/src/auth-protocol.ts b/surfaces/shell/src/auth-protocol.ts new file mode 100644 index 0000000..61ecdea --- /dev/null +++ b/surfaces/shell/src/auth-protocol.ts @@ -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> = Object.freeze({ + AUTH_CREDENTIALS_INVALID: "Email or password is incorrect.", + AUTH_REQUEST_FAILED: "Unable to sign in right now.", + AUTH_SIGN_OUT_FAILED: "Unable to sign out right now.", +}); + +/** + * A browser-safe authentication failure. It deliberately retains neither a response body nor the + * submitted credentials, a request URL, a Better Auth error, or a caught transport exception. + */ +export class HostAuthError extends Error { + readonly code: HostAuthErrorCode; + + constructor(code: HostAuthErrorCode) { + super(errorMessages[code]); + this.name = "HostAuthError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +async function postJson( + config: EkklesiaHostConfig, + path: string, + body: unknown, + failureCode: Exclude, + signal?: AbortSignal, +): Promise { + const performRequest = config.fetch ?? fetch; + try { + return await performRequest(resolveApiUrl(config, path), { + body: JSON.stringify(body), + cache: "no-store", + credentials: "include", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + method: "POST", + redirect: "error", + ...(signal ? { signal } : {}), + }); + } catch { + // The thrown value may contain a URL or implementation detail. More importantly, this helper + // must never attach the request body (and therefore the password) as an error cause. + throw new HostAuthError(failureCode); + } +} + +/** + * Uses Ekklesia's existing Better Auth transport without exposing Better Auth to browser packages. + * Success means only that the mutation succeeded; the shell must resolve `/api/v1/principal` before + * it renders authenticated content rather than trusting a response token or user object. + */ +export async function signInWithEmail( + config: EkklesiaHostConfig, + credentials: HostSignInCredentials, + signal?: AbortSignal, +): Promise { + const response = await postJson(config, SIGN_IN_PATH, credentials, "AUTH_REQUEST_FAILED", signal); + if (response.status === 400 || response.status === 401) { + throw new HostAuthError("AUTH_CREDENTIALS_INVALID"); + } + if (!response.ok) { + throw new HostAuthError("AUTH_REQUEST_FAILED"); + } +} + +/** Invalidates the server session. Hiding the shell without this successful mutation is not logout. */ +export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSignal): Promise { + const response = await postJson(config, SIGN_OUT_PATH, {}, "AUTH_SIGN_OUT_FAILED", signal); + if (!response.ok) { + throw new HostAuthError("AUTH_SIGN_OUT_FAILED"); + } +} diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index 5ce46ab..daf6709 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -1,19 +1,40 @@ 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 { bootstrapHost, type DashboardNavigationEntry, type EkklesiaHostConfig, type HostBootstrap, HostProtocolError, + loadHostPrincipal, } from "../host-protocol.js"; +import { LoginForm } from "./LoginForm.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js"; +type AuthenticatedPrincipal = Extract< + HostBootstrap["principal"], + { readonly kind: "authenticated" } +>; + type BootstrapState = | { readonly status: "loading" } | { readonly status: "ready"; readonly bootstrap: HostBootstrap } - | { readonly status: "signed-out" } - | { readonly status: "failed" }; + | { readonly status: "anonymous" } + | { 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 { /** 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 = { + 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" }, groupLabel: { color: "#78716c", @@ -34,11 +65,15 @@ const shellStyles = { textTransform: "uppercase", }, header: { + alignItems: "center", borderBottom: "1px solid #e7e5e4", + display: "flex", fontWeight: 600, gridColumn: "1 / -1", + justifyContent: "space-between", padding: "0.75rem 1rem", }, + heading: { font: "inherit", margin: 0 }, layout: { color: "#1c1917", display: "grid", @@ -47,11 +82,19 @@ const shellStyles = { minHeight: "100vh", }, 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; -/** Identity, not placement: a module keeps the same selection key wherever it is arranged, so moving - * it between groups never resets what is open or remounts what is showing. */ +/** Identity, not placement: moving a module between groups does not reset what is open. */ function moduleKey(entry: DashboardNavigationEntry): string { 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 ( +
+

{props.children}

+ {props.actions} +
+ ); +} + /** - * The Ekklesia browser shell: sign-in awareness, the Dashboard's configured navigation, and one - * mount target. + * Principal first, navigation second. A 403 after an authenticated principal is authorization, not + * 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 { + 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 - * - * Groups, their order, their labels, which module sits where, and the order within each group all - * arrive already resolved from the server (ADR 0027). This component reads no manifest, performs no - * sorting, and knows nothing about how a placement is decided — so changing the Dashboard's ordering - * 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. + * 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 + * simply remains the current URL through sign-in and no external/protocol-relative return value can + * become a navigation sink. Modules receive the same five-field context after authentication; they + * neither render platform login nor receive sign-in/sign-out controls. */ export function HostApp(props: HostAppProps): ReactElement { const [state, setState] = useState({ status: "loading" }); const [selectedKey, setSelectedKey] = useState(undefined); + const [bootstrapAttempt, setBootstrapAttempt] = useState(0); + const activeTransitionRef = useRef(undefined); + const revalidationRef = useRef(undefined); + const generationRef = useRef(0); + const mountedRef = useRef(true); 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(() => { - 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" }); - void bootstrapHost(config, abort.signal) - .then((bootstrap) => { - if (abort.signal.aborted) { - return; + setSelectedKey(undefined); + void resolveBootstrapState(config, controller.signal).then((resolved) => { + if (!isCurrent(controller, generation)) { + 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 => { + 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( - bootstrap.principal.kind === "anonymous" - ? { status: "signed-out" } - : { bootstrap, status: "ready" }, - ); - const first = allEntries(bootstrap)[0]; - setSelectedKey(first ? moduleKey(first) : undefined); - }) - .catch((error: unknown) => { - if (abort.signal.aborted) { - return; + if (resolved.status === "failed") { + console.error("[ekklesia] post-sign-in principal resolution failed"); } - console.error("[ekklesia] host bootstrap failed", error); - setState( - error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED" - ? { status: "signed-out" } - : { status: "failed" }, - ); - }); - return () => abort.abort(); - }, [config]); + commitResolvedState(resolved); + activeTransitionRef.current = undefined; + if (resolved.status === "anonymous" || resolved.status === "failed") { + throw new HostAuthError("AUTH_REQUEST_FAILED"); + } + } finally { + formSignal.removeEventListener("abort", abortFromForm); + if (activeTransitionRef.current === controller) { + activeTransitionRef.current = undefined; + } + } + }, + [beginTransition, commitResolvedState, config, isCurrent], + ); + + const signOut = useCallback(async (): Promise => { + 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") { - return

Loading Ekklesia…

; + return Loading Ekklesia…; } - if (state.status === "signed-out") { - return ( -

- Sign in to Ekklesia to use the module host. -

- ); + if (state.status === "anonymous") { + return ; } if (state.status === "failed") { return ( -

- Ekklesia could not be reached. Refresh to try again. -

+ + Retry + + } + testId="host-error" + > + Ekklesia could not be reached. + + ); + } + if (state.status === "signing-out") { + return Signing out…; + } + if (state.status === "sign-out-failed") { + return ( + + + + + } + testId="host-sign-out-error" + > + Ekklesia could not sign you out. Try again. + + ); + } + if (state.status === "access-denied") { + return ( + void signOut()} + style={shellStyles.action} + type="button" + > + Sign out + + } + testId="host-access-denied" + > + You are signed in, but do not have access to this Dashboard. + ); } @@ -171,7 +442,17 @@ export function HostApp(props: HostAppProps): ReactElement { return (
-
Ekklesia
+
+

Ekklesia

+ +
{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. ) : undefined} diff --git a/surfaces/shell/src/browser/LoginForm.tsx b/surfaces/shell/src/browser/LoginForm.tsx new file mode 100644 index 0000000..2d79d5b --- /dev/null +++ b/surfaces/shell/src/browser/LoginForm.tsx @@ -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; +} + +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(undefined); + const pendingRef = useRef(false); + const passwordRef = useRef(null); + const attemptRef = useRef(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): Promise => { + 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): 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 ( +
+
+

+ Sign in to Ekklesia +

+

Use your existing Ekklesia account.

+
+ + + {errorMessage ? ( + + ) : undefined} + +
+
+
+ ); +} diff --git a/surfaces/shell/src/browser/ModuleUiFrame.tsx b/surfaces/shell/src/browser/ModuleUiFrame.tsx index 10ef3c6..8ddcaf6 100644 --- a/surfaces/shell/src/browser/ModuleUiFrame.tsx +++ b/surfaces/shell/src/browser/ModuleUiFrame.tsx @@ -10,6 +10,8 @@ export interface ModuleUiFrameProps { readonly config: EkklesiaHostConfig; readonly descriptor: HostModuleUiDescriptor; 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 * the stale-load rule. Production composition leaves it unset and the loader's own * `(url) => import(url)` runs. */ @@ -49,7 +51,7 @@ export interface ModuleUiFrameProps { export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement { const containerRef = useRef(null); const [status, setStatus] = useState("loading"); - const { config, descriptor, importModule, principal } = props; + const { config, descriptor, importModule, onAuthorizationResponse, principal } = props; useEffect(() => { const container = containerRef.current; @@ -60,11 +62,27 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement { let mounted: Awaited> | undefined; 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 () => { try { const instance = await loadModuleUi({ container, - context: createModuleUiContext({ config, descriptor, principal }), + context: createModuleUiContext({ + config, + descriptor, + principal, + ...(onAuthorizationResponse + ? { onAuthorizationResponse: reportAuthorizationResponse } + : {}), + }), ...(importModule ? { importModule } : {}), source: { 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. void mounted?.unmount().catch(() => undefined); }; - }, [config, descriptor, importModule, principal]); + }, [config, descriptor, importModule, onAuthorizationResponse, principal]); return (
diff --git a/surfaces/shell/src/host-protocol.ts b/surfaces/shell/src/host-protocol.ts index 43884ac..8afe905 100644 --- a/surfaces/shell/src/host-protocol.ts +++ b/surfaces/shell/src/host-protocol.ts @@ -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; export type HostProtocolErrorCode = - /** The caller is not signed in, or lacks `core.modules.ui.read`. */ + /** The caller is not signed in. */ | "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. */ | "HOST_RESPONSE_INVALID" /** The API could not be reached, or failed. */ @@ -61,6 +63,7 @@ export type HostProtocolErrorCode = | "DISPLAY_ACCESS_DENIED"; const errorMessages: Readonly> = Object.freeze({ + HOST_ACCESS_DENIED: "You do not have access to the Ekklesia Dashboard.", HOST_REQUEST_FAILED: "Ekklesia could not be reached.", 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.", @@ -460,7 +463,9 @@ export async function loadDashboardNavigation( signal?: AbortSignal, ): Promise { return parseDashboardNavigation( - await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal), + await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal, { + forbidden: "HOST_ACCESS_DENIED", + }), ); } diff --git a/surfaces/shell/src/index.ts b/surfaces/shell/src/index.ts index 9a9204a..c2820b4 100644 --- a/surfaces/shell/src/index.ts +++ b/surfaces/shell/src/index.ts @@ -39,3 +39,10 @@ export { createModuleUiContext, moduleUiSourceUrl, } from "./module-context.js"; +export { + HostAuthError, + type HostAuthErrorCode, + type HostSignInCredentials, + signInWithEmail, + signOutHost, +} from "./auth-protocol.js"; diff --git a/surfaces/shell/src/module-context.ts b/surfaces/shell/src/module-context.ts index 4ce89fb..ee8cfb5 100644 --- a/surfaces/shell/src/module-context.ts +++ b/surfaces/shell/src/module-context.ts @@ -220,6 +220,7 @@ function createModuleRealtimeClient( function createModuleApiClient( config: EkklesiaHostConfig, descriptor: HostModuleUiDescriptor, + onAuthorizationResponse?: (status: 401 | 403) => void, ): ModuleUiApiClient { const performRequest = config.fetch ?? fetch; 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 // `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. - 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 descriptor: HostModuleUiDescriptor; 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 { const granted = new Set(options.descriptor.permissions); return Object.freeze({ - api: createModuleApiClient(options.config, options.descriptor), + api: createModuleApiClient(options.config, options.descriptor, options.onAuthorizationResponse), moduleId: options.descriptor.id, permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), principal: options.principal, diff --git a/surfaces/shell/tests/auth-protocol.test.ts b/surfaces/shell/tests/auth-protocol.test.ts new file mode 100644 index 0000000..7b009e6 --- /dev/null +++ b/surfaces/shell/tests/auth-protocol.test.ts @@ -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: "owner@example.test", 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: "owner@example.test", + 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: "owner@example.test" }), + { status }, + ); + const { implementation } = recordingFetch(response); + + const error = await signInWithEmail( + { apiBaseUrl, fetch: implementation }, + { email: "owner@example.test", 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("owner@example.test"); + 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: "owner@example.test", 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.", + }); + }); +}); diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx index 914f01b..82d7a8e 100644 --- a/surfaces/shell/tests/host-app.test.tsx +++ b/surfaces/shell/tests/host-app.test.tsx @@ -18,6 +18,7 @@ let consoleError: ReturnType; beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; + window.history.replaceState({}, "", "/"); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -134,6 +135,92 @@ function query(testId: string): Element | null { return container.querySelector(`[data-testid="${testId}"]`); } +async function setInput(testId: string, value: string): Promise { + 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 { + 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; + readonly onPrincipalRequest?: (authenticated: boolean) => Promise; + readonly onSignIn?: () => Promise; + readonly onSignOut?: () => Promise; + } = {}, +): 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 !== "owner@example.test" || 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", () => { it("renders the module list the navigation returned", async () => { await render({ @@ -147,17 +234,19 @@ describe("Ekklesia shell — bootstrap", () => { 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" } }) }); - expect(query("host-signed-out")).not.toBeNull(); + expect(query("host-login")).not.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 }) }); - 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 () => { @@ -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((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", "owner@example.test"); + 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("owner@example.test"); + 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((resolve) => { + releaseSignIn = resolve; + }); + const platform = statefulAuthPlatform({ onSignIn: () => signInPending }); + await render({ apiBaseUrl, fetch: platform.fetch }); + await setInput("login-email", "owner@example.test"); + 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((resolve) => { + releasePrincipal = resolve; + }); + const platform = statefulAuthPlatform({ + onPrincipalRequest: (authenticated) => (authenticated ? principalPending : Promise.resolve()), + }); + await render({ apiBaseUrl, fetch: platform.fetch }); + await setInput("login-email", "owner@example.test"); + 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: "owner@example.test", 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((resolve) => { + releaseSignIn = resolve; + }); + const platform = statefulAuthPlatform({ onSignIn: () => signInPending }); + await render({ apiBaseUrl, fetch: platform.fetch }); + await setInput("login-email", "owner@example.test"); + 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", "owner@example.test"); + 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", "owner@example.test"); + 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((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((resolve) => { + releaseModuleRequest = resolve; + }); + let releaseSignOut: (() => void) | undefined; + const signOutPending = new Promise((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", () => { const mediaGroup = () => 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); 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"]); }); diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts index 73f9ea3..27f0aff 100644 --- a/surfaces/shell/tests/host-protocol.test.ts +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -128,16 +128,20 @@ describe("host protocol — descriptor validation", () => { } }); - it("reports an unauthenticated caller distinctly from any other failure", async () => { - for (const status of [401, 403]) { - const { implementation } = stubFetch({ - "/api/v1/dashboard/navigation": () => - jsonResponse({ code: "AUTHORIZATION_DENIED" }, status), - }); - await expect( - loadDashboardNavigation({ apiBaseUrl, fetch: implementation }), - ).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" }); - } + it("distinguishes an authentication challenge from an authorization denial", async () => { + const unauthenticated = stubFetch({ + "/api/v1/dashboard/navigation": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, 401), + }); + await expect( + loadDashboardNavigation({ apiBaseUrl, fetch: unauthenticated.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({ "/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 () => { const { calls, implementation } = recordingFetch(); const api = contextFor(implementation).api; diff --git a/surfaces/shell/tests/modularity.test.ts b/surfaces/shell/tests/modularity.test.ts index 37cf953..238ea00 100644 --- a/surfaces/shell/tests/modularity.test.ts +++ b/surfaces/shell/tests/modularity.test.ts @@ -149,11 +149,21 @@ describe("the shell builds no module knowledge into its bundle", () => { // time from the navigation the server resolved. expect(bundle).toContain('"/api/v1/principal"'); 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 host-neutral inventory endpoint itself. 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", () => { // 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. @@ -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/module-displays"'); 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("Sign in to Ekklesia"); expect(bundle.toLowerCase()).not.toContain("socket.io");