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