Compare commits
19
Commits
v0.1.0
...
8ea4d4d2e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea4d4d2e4 | ||
|
|
55accd0e65 | ||
|
|
a56a853319 | ||
|
|
b642d8fe30 | ||
|
|
278e66049f | ||
|
|
c737ebb472 | ||
|
|
7cbe4e1b2c | ||
|
|
481e80118b | ||
|
|
167b4c4f14 | ||
|
|
b593abe7a5 | ||
|
|
e12ef10a65 | ||
|
|
4468335e8f | ||
|
|
756e9b821c | ||
|
|
ef1383d080 | ||
|
|
fc422b6d8d | ||
|
|
d5d4928b9f | ||
|
|
a12cbe9600 | ||
|
|
220b7b152a | ||
|
|
aaec9789c4 |
@@ -0,0 +1,25 @@
|
|||||||
|
# Build context for deploy/Dockerfile (ADR 0044). The image installs its own dependencies with
|
||||||
|
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
|
||||||
|
# host may enter the context, and no secret or environment file may enter any layer.
|
||||||
|
|
||||||
|
# Host-installed dependencies and build outputs: the image produces its own.
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/coverage
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment and secret material. Configuration reaches the container only through its runtime
|
||||||
|
# environment; `.env.example` is excluded too, since nothing in the image reads it.
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
|
||||||
|
# Repository and tooling metadata the image never reads.
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitea
|
||||||
|
.forgejo
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
**/*.log
|
||||||
|
**/.DS_Store
|
||||||
@@ -103,3 +103,60 @@ jobs:
|
|||||||
|
|
||||||
- name: Exercise transactional migrations against PostgreSQL
|
- name: Exercise transactional migrations against PostgreSQL
|
||||||
run: pnpm test:migrations:integration
|
run: pnpm test:migrations:integration
|
||||||
|
|
||||||
|
image-build:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
||||||
|
# has no buildx plugin.
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
|
||||||
|
# builds, validates, and assembles with zero modules. Build only — no registry login, no
|
||||||
|
# credentials, no push, no tag anywhere but this runner's local image store.
|
||||||
|
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
|
||||||
|
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
|
||||||
|
- name: Build the production image without modules
|
||||||
|
run: >-
|
||||||
|
docker build
|
||||||
|
--file deploy/Dockerfile
|
||||||
|
--build-arg REVISION="$GITHUB_SHA"
|
||||||
|
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Remove the image from the runner
|
||||||
|
if: always()
|
||||||
|
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
|
||||||
|
|
||||||
|
deploy-smoke:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
|
||||||
|
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
||||||
|
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
|
||||||
|
# database TLS through the proxy. Every secret is generated for this run and discarded with
|
||||||
|
# it; nothing is pushed, published, or logged in to.
|
||||||
|
- name: Smoke-test the reference deployment
|
||||||
|
run: bash scripts/smoke-deploy.sh
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ import type { Context, MiddlewareHandler } from "hono";
|
|||||||
import { Hono } 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 type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createDeviceRoutes } from "./device-routes.js";
|
import { createDeviceRoutes } from "./device-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 { createGrantsRoutes } from "./grants-routes.js";
|
||||||
|
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
import { createModuleConfigurationRoutes } from "./module-configuration-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 { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||||
@@ -137,6 +140,20 @@ export interface CreateApiAppOptions {
|
|||||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||||
* ordering, module startup, or any authorization decision. */
|
* ordering, module startup, or any authorization decision. */
|
||||||
readonly dashboard: DashboardStore;
|
readonly dashboard: DashboardStore;
|
||||||
|
/**
|
||||||
|
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||||
|
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||||
|
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
|
||||||
|
* `./dashboard-host-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly dashboardHostAssets: DashboardHostAssets;
|
||||||
|
/**
|
||||||
|
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
|
||||||
|
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||||
|
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
|
||||||
|
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly health: ApiHealthOptions;
|
||||||
readonly runtime: EkklesiaIdentityRuntime;
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||||
@@ -341,6 +358,16 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
|
||||||
|
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
|
||||||
|
// own entirely. See `./health-routes.js`.
|
||||||
|
app.route("/", createHealthRoutes(options.health));
|
||||||
|
|
||||||
|
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||||
|
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||||
|
// `./dashboard-host-routes.js`.
|
||||||
|
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||||
|
|
||||||
app.route(
|
app.route(
|
||||||
"/",
|
"/",
|
||||||
createGrantsRoutes({
|
createGrantsRoutes({
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned
|
||||||
|
* asset routes that answer `304` against a strong validator they computed from their own bytes.
|
||||||
|
*/
|
||||||
|
export function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||||
|
if (headerValue === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (headerValue.trim() === "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return headerValue.split(",").some((candidate) => {
|
||||||
|
const value = candidate.trim();
|
||||||
|
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||||
|
*
|
||||||
|
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||||
|
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||||
|
* package is never exposed as a static directory, and the source map beside the script is never
|
||||||
|
* read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value
|
||||||
|
* every response can reuse.
|
||||||
|
*/
|
||||||
|
export interface DashboardHostAssets {
|
||||||
|
readonly html: string;
|
||||||
|
readonly htmlEtag: string;
|
||||||
|
readonly script: string;
|
||||||
|
readonly scriptEtag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
|
* location, and machine path are intentionally not retained or surfaced. */
|
||||||
|
export class DashboardHostAssetError extends Error {
|
||||||
|
readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("The Dashboard host browser assets are unavailable.");
|
||||||
|
this.name = "DashboardHostAssetError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strongEtag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures one immutable, internally consistent Dashboard asset snapshot.
|
||||||
|
*
|
||||||
|
* Each strong validator is derived from the exact text in the same snapshot, so bytes from one build
|
||||||
|
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||||
|
* valid page that can never render anything.
|
||||||
|
*/
|
||||||
|
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
html,
|
||||||
|
htmlEtag: strongEtag(html),
|
||||||
|
script,
|
||||||
|
scriptEtag: strongEtag(script),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
||||||
|
* than leaving an API whose own origin answers `/` with a 404. */
|
||||||
|
export async function loadDashboardHostAssets(): Promise<DashboardHostAssets> {
|
||||||
|
try {
|
||||||
|
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
|
||||||
|
// rejected sibling read is left running after this function has already failed.
|
||||||
|
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||||
|
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||||
|
return createDashboardHostAssets(html, script);
|
||||||
|
} catch {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
|
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||||
|
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||||
|
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||||
|
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||||
|
* `/api/v1/modules/<module-id>`.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||||
|
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||||
|
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
|
||||||
|
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
|
||||||
|
*/
|
||||||
|
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
|
||||||
|
* entry never keeps an older set.
|
||||||
|
*
|
||||||
|
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
|
||||||
|
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
|
||||||
|
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
|
||||||
|
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
|
||||||
|
*/
|
||||||
|
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
|
||||||
|
* package location, or asset content. */
|
||||||
|
export class DashboardHostConfigurationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("The Dashboard host configuration is inconsistent.");
|
||||||
|
this.name = "DashboardHostConfigurationError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies and validates injected assets before registering any route, as the display host does, so
|
||||||
|
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||||
|
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||||
|
try {
|
||||||
|
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||||
|
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedAsset(
|
||||||
|
ifNoneMatch: string | undefined,
|
||||||
|
body: string,
|
||||||
|
etag: string,
|
||||||
|
contentType: string,
|
||||||
|
extraHeaders: Readonly<Record<string, string>>,
|
||||||
|
): Response {
|
||||||
|
if (etagMatches(ifNoneMatch, etag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
...extraHeaders,
|
||||||
|
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
|
||||||
|
"Content-Type": contentType,
|
||||||
|
ETag: etag,
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||||
|
* default same-origin shape.
|
||||||
|
*
|
||||||
|
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||||
|
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||||
|
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||||
|
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||||
|
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||||
|
*/
|
||||||
|
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||||
|
const snapshot = snapshotAssets(assets);
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||||
|
fixedAsset(
|
||||||
|
c.req.header("if-none-match"),
|
||||||
|
snapshot.html,
|
||||||
|
snapshot.htmlEtag,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||||
|
fixedAsset(
|
||||||
|
c.req.header("if-none-match"),
|
||||||
|
snapshot.script,
|
||||||
|
snapshot.scriptEtag,
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -26,11 +26,31 @@ export interface ApiDatabaseHandle {
|
|||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||||
|
/**
|
||||||
|
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
||||||
|
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
||||||
|
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
||||||
|
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
||||||
|
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
||||||
|
* the pool discards that connection instead of returning it, so a transaction left open by a
|
||||||
|
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
||||||
|
* caller may surface it.
|
||||||
|
*/
|
||||||
|
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
readonly close: () => Promise<void>;
|
readonly close: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pingStatement(statementTimeoutMs: number): string {
|
||||||
|
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
||||||
|
// parameters. Only a positive safe integer can ever reach the SQL text.
|
||||||
|
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
||||||
|
throw new RangeError("The ping statement timeout must be a positive integer.");
|
||||||
|
}
|
||||||
|
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
@@ -58,5 +78,8 @@ export function createApiDatabase(
|
|||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
moduleConfiguration: createModuleConfigurationView(pool),
|
moduleConfiguration: createModuleConfigurationView(pool),
|
||||||
moduleLifecycle: createModuleLifecycleView(pool),
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
|
ping: async (statementTimeoutMs: number) => {
|
||||||
|
await pool.query(pingStatement(statementTimeoutMs));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short
|
||||||
|
* enough for a Compose health check or an external monitor to treat a stall as "not ready" rather
|
||||||
|
* than as a hung probe, and well inside the shared pool's own 10s connection and operation
|
||||||
|
* timeouts, which bound the underlying query independently.
|
||||||
|
*/
|
||||||
|
export const READINESS_DATABASE_TIMEOUT_MS = 2_000;
|
||||||
|
|
||||||
|
export const HEALTH_LIVE_PATH = "/api/v1/health/live";
|
||||||
|
export const HEALTH_READY_PATH = "/api/v1/health/ready";
|
||||||
|
|
||||||
|
export interface ApiHealthOptions {
|
||||||
|
/**
|
||||||
|
* One trivial database round trip. Resolves when the database answered; rejects on any failure.
|
||||||
|
* The rejection is never read — not its message, code, or cause — so nothing a driver says can
|
||||||
|
* reach a response. The route bounds the wait and never overlaps two calls; this function only
|
||||||
|
* has to perform the query.
|
||||||
|
*/
|
||||||
|
readonly checkDatabase: () => Promise<void>;
|
||||||
|
/** Whether module startup has completed. `false` again from the moment shutdown begins, so a
|
||||||
|
* draining instance stops reporting ready while it finishes in-flight requests. */
|
||||||
|
readonly isModuleStartupComplete: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIVE_BODY = Object.freeze({ status: "live" });
|
||||||
|
const READY_BODY = Object.freeze({ status: "ready" });
|
||||||
|
const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than
|
||||||
|
* `timeoutMs`.
|
||||||
|
*
|
||||||
|
* Every probe arriving while a check is unsettled shares that check's outcome, including after its
|
||||||
|
* deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes
|
||||||
|
* answer immediately. A new check starts only once the previous call has actually settled. A timed-
|
||||||
|
* out query still occupies a pool connection until the database or the pool's own timeouts end it,
|
||||||
|
* so starting a fresh query on every probe would let a stalled database drain the pool one health
|
||||||
|
* check at a time.
|
||||||
|
*/
|
||||||
|
function createBoundedDatabaseCheck(
|
||||||
|
checkDatabase: () => Promise<void>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): () => Promise<boolean> {
|
||||||
|
let inFlight: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (inFlight !== undefined) {
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like
|
||||||
|
// any other rather than an exception escaping the route.
|
||||||
|
const settled = new Promise<void>((resolve) => resolve(checkDatabase())).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const outcome = Promise.race([
|
||||||
|
settled,
|
||||||
|
new Promise<false>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
inFlight = outcome;
|
||||||
|
void settled.then(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
inFlight = undefined;
|
||||||
|
});
|
||||||
|
return outcome;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD,
|
||||||
|
* and these routes accept GET alone. */
|
||||||
|
function methodNotAllowed(): Response {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
||||||
|
status: 405,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external
|
||||||
|
* monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies
|
||||||
|
* that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed
|
||||||
|
* operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that
|
||||||
|
* installed modules own entirely, so no module route can shadow them.
|
||||||
|
*/
|
||||||
|
export function createHealthRoutes(options: ApiHealthOptions): Hono {
|
||||||
|
const checkDatabase = createBoundedDatabaseCheck(
|
||||||
|
options.checkDatabase,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.all(HEALTH_LIVE_PATH, (c) => {
|
||||||
|
if (c.req.method !== "GET") {
|
||||||
|
return methodNotAllowed();
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json(LIVE_BODY, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.all(HEALTH_READY_PATH, async (c) => {
|
||||||
|
if (c.req.method !== "GET") {
|
||||||
|
return methodNotAllowed();
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
if (!options.isModuleStartupComplete()) {
|
||||||
|
return c.json(NOT_READY_BODY, 503);
|
||||||
|
}
|
||||||
|
const databaseReady = await checkDatabase();
|
||||||
|
// Checked again afterwards: shutdown may have begun while the query was in flight.
|
||||||
|
return databaseReady && options.isModuleStartupComplete()
|
||||||
|
? c.json(READY_BODY, 200)
|
||||||
|
: c.json(NOT_READY_BODY, 503);
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getAuthorizedPrincipal,
|
getAuthorizedPrincipal,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
export {
|
export {
|
||||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||||
@@ -100,20 +101,6 @@ function displayFailure(c: Context): Response {
|
|||||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
|
|
||||||
function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
|
||||||
if (headerValue === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (headerValue.trim() === "*") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return headerValue.split(",").some((candidate) => {
|
|
||||||
const value = candidate.trim();
|
|
||||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Copies and validates injected assets before registering any route. Production's loader already
|
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||||
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||||
* bytes with a stale validator. */
|
* bytes with a stale validator. */
|
||||||
|
|||||||
@@ -45,9 +45,11 @@ import {
|
|||||||
wrapFetchWithClientAddress,
|
wrapFetchWithClientAddress,
|
||||||
} from "./client-address.js";
|
} from "./client-address.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||||
|
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||||
import { withDevicePrincipals } from "./device-principal.js";
|
import { withDevicePrincipals } from "./device-principal.js";
|
||||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
|
||||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||||
|
|
||||||
const connectionTimeoutMs = 10_000;
|
const connectionTimeoutMs = 10_000;
|
||||||
@@ -542,9 +544,13 @@ export async function startApi(
|
|||||||
// starts and application composition succeeds.
|
// starts and application composition succeeds.
|
||||||
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||||
|
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
|
||||||
try {
|
try {
|
||||||
displayHostAssets =
|
displayHostAssets =
|
||||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||||
|
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||||
|
// so a missing shell build fails here, before any module code is imported.
|
||||||
|
dashboardHostAssets = await loadDashboardHostAssets();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
await safeCloseMail(mailProvider);
|
await safeCloseMail(mailProvider);
|
||||||
@@ -576,6 +582,10 @@ export async function startApi(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
|
||||||
|
// `503` while the instance still finishes in-flight requests.
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
let app: Hono;
|
let app: Hono;
|
||||||
try {
|
try {
|
||||||
app = createApiApp({
|
app = createApiApp({
|
||||||
@@ -620,6 +630,13 @@ export async function startApi(
|
|||||||
},
|
},
|
||||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
|
dashboardHostAssets,
|
||||||
|
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||||
|
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||||
|
health: {
|
||||||
|
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
|
||||||
|
isModuleStartupComplete: () => !shuttingDown,
|
||||||
|
},
|
||||||
devices: {
|
devices: {
|
||||||
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
||||||
// base URL rather than from any request. Production is https by configuration policy, so a
|
// base URL rather than from any request. Production is https by configuration policy, so a
|
||||||
@@ -677,6 +694,7 @@ export async function startApi(
|
|||||||
let shutdownPromise: Promise<void> | undefined;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = (): Promise<void> => {
|
const shutdown = (): Promise<void> => {
|
||||||
if (!shutdownPromise) {
|
if (!shutdownPromise) {
|
||||||
|
shuttingDown = true;
|
||||||
shutdownPromise = (async () => {
|
shutdownPromise = (async () => {
|
||||||
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
||||||
// completes: an in-flight module command may still commit and publish its resulting state.
|
// completes: an in-flight module command may still commit and publish its resulting state.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, 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 { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -26,7 +26,18 @@ import {
|
|||||||
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";
|
||||||
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { createApiDatabase } from "../src/database.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
|
import {
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
import {
|
import {
|
||||||
MODULE_UI_CACHE_CONTROL,
|
MODULE_UI_CACHE_CONTROL,
|
||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
@@ -2977,6 +2988,98 @@ describe.sequential("live API — published module UI artifact", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.sequential("live API — the built Dashboard on the API's own origin", () => {
|
||||||
|
it("serves the built shell's exact index.html at / and its exact script, and nothing beside them", async () => {
|
||||||
|
const database = await createDisposableDatabase("dashboard_host");
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
const running = await startMigratedApi(database.url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(running);
|
||||||
|
const builtHtml = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||||
|
const builtScript = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||||
|
|
||||||
|
const page = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`);
|
||||||
|
const html = await page.text();
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(page.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(page.headers.get("x-frame-options")).toBe("DENY");
|
||||||
|
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||||
|
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
|
||||||
|
expect(html).toBe(builtHtml);
|
||||||
|
expect(html).toContain('id="ekklesia-host"');
|
||||||
|
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
|
||||||
|
|
||||||
|
const revalidated = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`, {
|
||||||
|
headers: { "if-none-match": page.headers.get("etag") ?? "" },
|
||||||
|
});
|
||||||
|
expect(revalidated.status).toBe(304);
|
||||||
|
|
||||||
|
const script = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}`);
|
||||||
|
expect(script.status).toBe(200);
|
||||||
|
expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(await script.text()).toBe(builtScript);
|
||||||
|
|
||||||
|
// The source map sits beside the script on disk and is never published.
|
||||||
|
const sourceMap = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}.map`);
|
||||||
|
expect(sourceMap.status).toBe(404);
|
||||||
|
expect(await sourceMap.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
} finally {
|
||||||
|
await running.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("live API — health", () => {
|
||||||
|
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
|
||||||
|
const database = await createDisposableDatabase("health");
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
const running = await startMigratedApi(database.url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(running);
|
||||||
|
|
||||||
|
const live = await fetch(`${origin}${HEALTH_LIVE_PATH}`);
|
||||||
|
expect(live.status).toBe(200);
|
||||||
|
expect(live.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await live.json()).toEqual({ status: "live" });
|
||||||
|
|
||||||
|
const ready = await fetch(`${origin}${HEALTH_READY_PATH}`);
|
||||||
|
expect(ready.status).toBe(200);
|
||||||
|
expect(ready.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await ready.json()).toEqual({ status: "ready" });
|
||||||
|
} finally {
|
||||||
|
await running.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pings through the shared pool, and PostgreSQL itself cancels a statement that outlives the ping's transaction-local timeout", async () => {
|
||||||
|
const database = await createDisposableDatabase("health_ping");
|
||||||
|
const handle = createApiDatabase(database.url);
|
||||||
|
try {
|
||||||
|
await expect(handle.ping(READINESS_DATABASE_TIMEOUT_MS)).resolves.toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ping's exact statement shape, with a statement that cannot finish inside its timeout: the
|
||||||
|
// server cancels it (query_canceled) long before the sleep would end, on a client that has no
|
||||||
|
// client-side timeout of its own.
|
||||||
|
const client = new Client({ connectionString: database.url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await expect(
|
||||||
|
client.query(
|
||||||
|
"BEGIN READ ONLY; SET LOCAL statement_timeout = 50; SELECT pg_sleep(5); COMMIT",
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "57014" });
|
||||||
|
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe.sequential("live API — public signup is disabled by default", () => {
|
describe.sequential("live API — public signup is disabled by default", () => {
|
||||||
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
||||||
const database = await createDisposableDatabase("signup_disabled");
|
const database = await createDisposableDatabase("signup_disabled");
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
} 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.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 { 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";
|
||||||
@@ -104,6 +106,8 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -121,6 +125,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -355,6 +361,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -378,6 +386,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -403,6 +413,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -430,6 +442,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -457,6 +471,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -481,6 +497,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -507,6 +525,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -530,6 +550,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -555,6 +577,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -587,6 +611,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -620,6 +646,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -658,6 +686,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -678,6 +708,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -700,6 +732,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -721,6 +755,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -740,6 +776,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -766,6 +804,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -795,6 +835,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -827,6 +869,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
@@ -850,6 +894,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||||
|
dashboardHostAssetUrls: Object.freeze({
|
||||||
|
html: "file:///surface/browser/index.html",
|
||||||
|
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
DashboardHostAssetError,
|
||||||
|
loadDashboardHostAssets,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard host asset loading", () => {
|
||||||
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
|
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
|
const html = '<!doctype html><div id="ekklesia-host"></div>';
|
||||||
|
const script = "export const dashboardHost = true;\n";
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||||
|
|
||||||
|
const assets = await loadDashboardHostAssets();
|
||||||
|
|
||||||
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
|
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||||
|
]);
|
||||||
|
expect(assets).toEqual({
|
||||||
|
html,
|
||||||
|
htmlEtag: sha256Etag(html),
|
||||||
|
script,
|
||||||
|
scriptEtag: sha256Etag(script),
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||||
|
readFileMock.mockRejectedValueOnce(
|
||||||
|
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await loadDashboardHostAssets().catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||||
|
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||||
|
expect(publicText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the script is missing even though the page was read", async () => {
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce("<!doctype html>")
|
||||||
|
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||||
|
|
||||||
|
await expect(loadDashboardHostAssets()).rejects.toBeInstanceOf(DashboardHostAssetError);
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||||
|
for (const [html, script] of [
|
||||||
|
["", "export {};"],
|
||||||
|
["<html></html>", " \n"],
|
||||||
|
] as const) {
|
||||||
|
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
|
||||||
|
// while answering a request — the hallmark of a static directory — fails these tests.
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
DashboardHostConfigurationError,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
|
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("dashboard.page.read");
|
||||||
|
|
||||||
|
const PAGE_HTML =
|
||||||
|
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
|
||||||
|
const SCRIPT = "export const dashboard = 'built shell';\n";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||||
|
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
options: {
|
||||||
|
readonly assets?: DashboardHostAssets;
|
||||||
|
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||||
|
health: readyHealth(),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPageSecurityHeaders(response: Response): void {
|
||||||
|
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
||||||
|
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||||
|
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
|
||||||
|
expect(response.status, label).toBe(404);
|
||||||
|
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET / (the Dashboard page)", () => {
|
||||||
|
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
// The one CSP directive is the only one: the full policy is a separate decision.
|
||||||
|
expect(response.headers.get("content-security-policy")).not.toContain(";");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": ifNoneMatch },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status, ifNoneMatch).toBe(304);
|
||||||
|
expect(await response.text(), ifNoneMatch).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the full page again when the browser holds a different build's validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": sha256Etag("an older build") },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
|
||||||
|
const { app, evaluator, runtime } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
expect(response.headers.get("set-cookie")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||||
|
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(SCRIPT);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(304);
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept the page's validator as the script's", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": PAGE_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard host boundaries", () => {
|
||||||
|
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/index.html",
|
||||||
|
"/app.js",
|
||||||
|
"/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/",
|
||||||
|
"/assets/ekklesia-dashboard",
|
||||||
|
"/assets/ekklesia-dashboard/app.js/",
|
||||||
|
"/assets/ekklesia-dashboard/index.html",
|
||||||
|
"/assets/",
|
||||||
|
"/assets/ekklesia-dashboard/APP.JS",
|
||||||
|
"/dashboard",
|
||||||
|
"/login",
|
||||||
|
]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path), path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
|
||||||
|
"/%2e%2e/%2e%2e/etc/passwd",
|
||||||
|
"/../../etc/passwd",
|
||||||
|
"//assets/ekklesia-dashboard/app.js",
|
||||||
|
"/assets//ekklesia-dashboard/app.js",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%00.map",
|
||||||
|
]) {
|
||||||
|
const response = await app.request(path);
|
||||||
|
expect(await response.text(), path).not.toContain(SCRIPT);
|
||||||
|
expect(response.status, path).toBe(404);
|
||||||
|
}
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches the filesystem while answering, even across every served path", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
await app.request("/assets/ekklesia-dashboard/app.js.map");
|
||||||
|
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(await head.text()).toBe("");
|
||||||
|
|
||||||
|
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const { app } = buildApp({
|
||||||
|
moduleRoutes: [
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "dashboard",
|
||||||
|
path: "/",
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
|
||||||
|
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
|
||||||
|
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
|
||||||
|
// No display is published here, so the display host's script is absent rather than answered
|
||||||
|
// by the Dashboard's.
|
||||||
|
await expectSanitizedNotFound(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH),
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
);
|
||||||
|
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
|
||||||
|
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||||
|
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||||
|
|
||||||
|
for (const assets of [
|
||||||
|
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||||
|
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||||
|
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||||
|
]) {
|
||||||
|
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,8 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import {
|
import {
|
||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
moduleLifecycleSnapshotFor,
|
moduleLifecycleSnapshotFor,
|
||||||
@@ -139,6 +141,8 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
type ApiHealthOptions,
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("health.status.read");
|
||||||
|
|
||||||
|
/** What a real driver failure can carry, and what must therefore never reach a response. */
|
||||||
|
const DRIVER_FAILURE_DETAIL =
|
||||||
|
"connect ECONNREFUSED postgresql://ekklesia:[email protected]:5432/ekklesia SELECT 1";
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthOptions(overrides: Partial<ApiHealthOptions> = {}) {
|
||||||
|
return {
|
||||||
|
checkDatabase: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||||
|
isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
health: ApiHealthOptions,
|
||||||
|
moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]),
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes,
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health,
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only
|
||||||
|
* timer functions these tests fake are `setTimeout` and `clearTimeout`. */
|
||||||
|
function drainMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */
|
||||||
|
async function settlesWithoutWaiting(promise: Response | Promise<Response>): Promise<boolean> {
|
||||||
|
let settled = false;
|
||||||
|
void Promise.resolve(promise).then(
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await drainMicrotasks();
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNotReady(response: Response): Promise<void> {
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ code: "NOT_READY" });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/health/live", () => {
|
||||||
|
it("returns 200 with a fixed body and Cache-Control: no-store", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_LIVE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("content-type")).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json()).toEqual({ status: "live" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||||
|
isModuleStartupComplete: vi.fn().mockReturnValue(false),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_LIVE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/health/ready", () => {
|
||||||
|
it("returns 200 with a fixed body when the database answers and module startup completed", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_READY_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ status: "ready" });
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_READY_PATH);
|
||||||
|
const text = await response.clone().text();
|
||||||
|
|
||||||
|
await expectNotReady(response);
|
||||||
|
for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) {
|
||||||
|
expect(text).not.toContain(fragment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a check that throws synchronously exactly like one that rejects", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(() => {
|
||||||
|
throw new Error(DRIVER_FAILURE_DETAIL);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 without touching the database before module startup completed", async () => {
|
||||||
|
const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) });
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 when shutdown began while the database query was in flight", async () => {
|
||||||
|
let startupComplete = true;
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(async () => {
|
||||||
|
startupComplete = false;
|
||||||
|
}),
|
||||||
|
isModuleStartupComplete: () => startupComplete,
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a fresh check for each sequential probe rather than caching a success", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await app.request(HEALTH_READY_PATH);
|
||||||
|
await app.request(HEALTH_READY_PATH);
|
||||||
|
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ready for a slow query that still answers inside the bound", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
let answer: () => void = () => undefined;
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const pending = app.request(HEALTH_READY_PATH);
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1);
|
||||||
|
answer();
|
||||||
|
|
||||||
|
expect((await pending).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readiness never piles up database checks", () => {
|
||||||
|
it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(() => new Promise<void>(() => undefined)),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
// Probes arriving inside the first check's window share it and its single deadline.
|
||||||
|
const first = app.request(HEALTH_READY_PATH);
|
||||||
|
const concurrent = app.request(HEALTH_READY_PATH);
|
||||||
|
await drainMicrotasks();
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||||
|
await expectNotReady(await first);
|
||||||
|
await expectNotReady(await concurrent);
|
||||||
|
|
||||||
|
// After the deadline the query is still unsettled: no new check starts, and each probe answers
|
||||||
|
// without waiting out another bound.
|
||||||
|
for (let probe = 0; probe < 20; probe += 1) {
|
||||||
|
const pending = app.request(HEALTH_READY_PATH);
|
||||||
|
expect(await settlesWithoutWaiting(pending)).toBe(true);
|
||||||
|
await expectNotReady(await pending);
|
||||||
|
}
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new check only once the timed-out query has actually settled", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
let settleHungQuery: (error: Error) => void = () => undefined;
|
||||||
|
const checkDatabase = vi
|
||||||
|
.fn<() => Promise<void>>()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((_resolve, reject) => {
|
||||||
|
settleHungQuery = reject;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const { app } = buildApp(healthOptions({ checkDatabase }));
|
||||||
|
|
||||||
|
const first = app.request(HEALTH_READY_PATH);
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||||
|
await expectNotReady(await first);
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
expect(checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The database cancels the abandoned statement; the next probe may finally ask again.
|
||||||
|
settleHungQuery(new Error(DRIVER_FAILURE_DETAIL));
|
||||||
|
await drainMicrotasks();
|
||||||
|
|
||||||
|
const recovered = await app.request(HEALTH_READY_PATH);
|
||||||
|
expect(recovered.status).toBe(200);
|
||||||
|
expect(checkDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health route boundaries", () => {
|
||||||
|
it.each([
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
const response = await app.request(path, { method });
|
||||||
|
expect(response.status, method).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
}
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => {
|
||||||
|
const { app, evaluator, runtime } = buildApp(healthOptions());
|
||||||
|
|
||||||
|
const response = await app.request(path, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot be shadowed by a module, even one whose id is "health"', async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const moduleRoutes = ["/live", "/ready"].map(
|
||||||
|
(path) =>
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "health",
|
||||||
|
path,
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
);
|
||||||
|
const { app } = buildApp(healthOptions(), moduleRoutes);
|
||||||
|
|
||||||
|
expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" });
|
||||||
|
expect(moduleHandler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The module's own routes still live where every module route lives, and only there.
|
||||||
|
const moduleResponse = await app.request("/api/v1/modules/health/ready");
|
||||||
|
expect(await moduleResponse.text()).toBe("module");
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
|||||||
const serveMock = vi.hoisted(() => vi.fn());
|
const serveMock = vi.hoisted(() => vi.fn());
|
||||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||||
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||||
|
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
|
||||||
|
// so the loader answers a small consistent snapshot unless a test makes it fail.
|
||||||
|
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||||
|
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||||
|
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||||
|
);
|
||||||
|
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||||
|
});
|
||||||
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||||
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||||
@@ -35,7 +45,13 @@ import {
|
|||||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
|
||||||
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||||
|
import {
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||||
|
|
||||||
function validConfig() {
|
function validConfig() {
|
||||||
@@ -447,6 +463,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
|
||||||
|
const query = emptyQuery();
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig());
|
||||||
|
query.mockClear();
|
||||||
|
|
||||||
|
const ready = await running.app.request(HEALTH_READY_PATH);
|
||||||
|
expect(ready.status).toBe(200);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
|
||||||
|
// cancels a stalled ping rather than the route merely abandoning it.
|
||||||
|
expect(query).toHaveBeenCalledWith(
|
||||||
|
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shutdown = running.shutdown();
|
||||||
|
const draining = await running.app.request(HEALTH_READY_PATH);
|
||||||
|
expect(draining.status).toBe(503);
|
||||||
|
expect(await draining.json()).toEqual({ code: "NOT_READY" });
|
||||||
|
// Draining is not dead: liveness still answers while in-flight requests finish.
|
||||||
|
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
await shutdown;
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
@@ -1022,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
expect(serveMock).not.toHaveBeenCalled();
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
|
||||||
|
|
||||||
|
const failure = await startApi(validConfig(), {
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
}).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not read display-host assets when the eligible set declares no display", async () => {
|
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|||||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import {
|
import {
|
||||||
createFakeModuleSecretSource,
|
createFakeModuleSecretSource,
|
||||||
createInMemoryModuleConfigurationStore,
|
createInMemoryModuleConfigurationStore,
|
||||||
@@ -189,6 +191,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleConfiguration: {
|
moduleConfiguration: {
|
||||||
bootSettings,
|
bootSettings,
|
||||||
discoveredModules: modules,
|
discoveredModules: modules,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
|||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
import {
|
import {
|
||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
@@ -198,6 +200,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
store: fakeAuthorizationStore(),
|
store: fakeAuthorizationStore(),
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||||
? { displayHostAssets: options.assets ?? displayHostAssets }
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
canonicalizeVaryHeader,
|
canonicalizeVaryHeader,
|
||||||
@@ -123,6 +125,8 @@ function buildModuleApp(options: {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
@@ -171,6 +173,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
} from "@ekklesia/shell";
|
} from "@ekklesia/shell";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
@@ -288,6 +290,8 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: ["https://dashboard.example.test"],
|
trustedOrigins: ["https://dashboard.example.test"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||||
@@ -208,6 +210,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { Window } from "happy-dom";
|
|||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
@@ -255,6 +257,8 @@ async function install(
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
|
||||||
|
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
|
||||||
|
export function testDashboardHostAssets(): DashboardHostAssets {
|
||||||
|
return createDashboardHostAssets(
|
||||||
|
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||||
|
"export const dashboardHost = true;\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ApiHealthOptions } from "../../src/health-routes.js";
|
||||||
|
|
||||||
|
/** Readiness inputs for a composition whose tests do not exercise health: the database answers and
|
||||||
|
* module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */
|
||||||
|
export function readyHealth(): ApiHealthOptions {
|
||||||
|
return {
|
||||||
|
checkDatabase: async () => undefined,
|
||||||
|
isModuleStartupComplete: () => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Ekklesia reference proxy configuration (ADR 0044). Copy to deploy/Caddyfile, which the proxy
|
||||||
|
# overlay mounts read-only. Hostnames, the ACME contact, and the tunnel connector's address come from
|
||||||
|
# the proxy service's environment (deploy/.env), so this file normally needs no edits.
|
||||||
|
{
|
||||||
|
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
|
||||||
|
# the Ekklesia and storage hostnames to resolve publicly to this host with 80 and 443 reachable.
|
||||||
|
email {$EKKLESIA_ACME_EMAIL}
|
||||||
|
|
||||||
|
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
|
||||||
|
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
|
||||||
|
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
|
||||||
|
#
|
||||||
|
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
|
||||||
|
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
|
||||||
|
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
|
||||||
|
# enable:
|
||||||
|
#
|
||||||
|
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
|
||||||
|
servers {
|
||||||
|
# A client-address header is honored only from the tunnel connector's fixed address
|
||||||
|
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
|
||||||
|
# any such header is ignored. Without a tunnel, nothing has this address.
|
||||||
|
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
|
||||||
|
client_ip_headers CF-Connecting-IP
|
||||||
|
trusted_proxies_strict
|
||||||
|
}
|
||||||
|
|
||||||
|
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
|
||||||
|
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
|
||||||
|
}
|
||||||
|
|
||||||
|
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
|
||||||
|
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
|
||||||
|
(ekklesia_forwarding) {
|
||||||
|
header_up X-Forwarded-For {client_ip}
|
||||||
|
header_up -CF-Connecting-IP
|
||||||
|
header_up -X-Real-IP
|
||||||
|
header_up -Forwarded
|
||||||
|
header_up -True-Client-IP
|
||||||
|
}
|
||||||
|
|
||||||
|
{$EKKLESIA_HOSTNAME} {
|
||||||
|
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
||||||
|
# worker, and the Dashboard and display host scripts. Any other path is refused here.
|
||||||
|
@ekklesia path / /api/* /display/* /assets/*
|
||||||
|
|
||||||
|
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||||
|
@realtime path /api/v1/module-realtime/*
|
||||||
|
|
||||||
|
handle @realtime {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @ekklesia {
|
||||||
|
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
|
||||||
|
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||||
|
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||||
|
reverse_proxy https://object-storage:8443 {
|
||||||
|
# An S3 signature covers the Host header: pass the original through unchanged, or every signed
|
||||||
|
# URL fails verification.
|
||||||
|
header_up Host {host}
|
||||||
|
header_up -CF-Connecting-IP
|
||||||
|
header_up -X-Real-IP
|
||||||
|
header_up -Forwarded
|
||||||
|
header_up -True-Client-IP
|
||||||
|
transport http {
|
||||||
|
# object-storage presents a certificate from the installation-private CA.
|
||||||
|
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||||
|
tls_server_name object-storage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Ekklesia's one production image (ADR 0044): the `api` service and every operator-run CLI, from one
|
||||||
|
# build. See deploy/README.md for how to build and run it.
|
||||||
|
#
|
||||||
|
# The build context is an already-assembled workspace: the platform, zero or more module packages in
|
||||||
|
# `modules/<id>`, and the `pnpm-lock.yaml` that describes exactly that set. This file clones nothing
|
||||||
|
# and chooses no modules. With an empty or absent `modules/`, it builds the platform alone.
|
||||||
|
#
|
||||||
|
# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
|
||||||
|
# The built-in frontend supports everything below, including cache mounts.
|
||||||
|
|
||||||
|
# Node.js 24.18.0 (matching `.node-version`) on Debian 13 slim, pinned by its multi-architecture
|
||||||
|
# index digest. glibc rather than musl, because module dependencies such as sharp ship glibc
|
||||||
|
# prebuilt binaries. Every stage starts from this exact image.
|
||||||
|
FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS base
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# toolchain: pnpm through Corepack, at exactly the root `packageManager` version.
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM base AS toolchain
|
||||||
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
|
PNPM_HOME=/pnpm \
|
||||||
|
TURBO_TELEMETRY_DISABLED=1 \
|
||||||
|
DO_NOT_TRACK=1
|
||||||
|
WORKDIR /opt/ekklesia
|
||||||
|
COPY package.json ./
|
||||||
|
# Corepack reads `packageManager` (pnpm@<exact version>) from the root manifest, so the version is
|
||||||
|
# declared once, in the repository, and never restated here.
|
||||||
|
RUN corepack enable && corepack install
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# manifests: the lockfile, workspace configuration, and every workspace package.json, at their
|
||||||
|
# repository paths. Dependency installation keys on these alone, so a source-only change reuses the
|
||||||
|
# installed layers.
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM base AS manifests
|
||||||
|
WORKDIR /context
|
||||||
|
COPY . .
|
||||||
|
RUN set -eu; \
|
||||||
|
mkdir /manifests; \
|
||||||
|
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
|
||||||
|
for manifest in apps/*/package.json builders/*/package.json core/package.json \
|
||||||
|
modules/*/package.json packages/*/package.json surfaces/*/package.json \
|
||||||
|
templates/*/package.json; do \
|
||||||
|
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# build: full install, validation, build, then the exact runtime file set.
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM toolchain AS build
|
||||||
|
# Sources first, then one install: workspace packages publish `bin` commands from source files (the
|
||||||
|
# module UI builder's, which every module build invokes), and pnpm links a bin only when its target
|
||||||
|
# already exists. The store cache mount keeps a rebuild off the network all the same.
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
||||||
|
# The same gates `pnpm verify` runs for boundaries, modules, and migrations, against exactly the
|
||||||
|
# module set in this context. `pnpm boundaries` comes first and reads only sources: among its rules,
|
||||||
|
# a module that imports something at run time without declaring it a production dependency fails
|
||||||
|
# here, because the production-only install below would leave it unresolvable at API startup. Each
|
||||||
|
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit.
|
||||||
|
RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
|
||||||
|
# What the runtime resolves at startup, at the paths it resolves them from:
|
||||||
|
# - every workspace package's `dist/` (every package exports only from `dist/`), which includes
|
||||||
|
# the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
|
||||||
|
# - Core's migrations, `core/drizzle/` with its `meta/` journal;
|
||||||
|
# - each module directory whole, minus `node_modules`, because each manifest names its own
|
||||||
|
# entrypoints, UI artifacts, and migrations directory.
|
||||||
|
# `node_modules` comes from the production-only install below, never from this stage.
|
||||||
|
RUN set -eu; \
|
||||||
|
mkdir -p /runtime/core /runtime/modules; \
|
||||||
|
for package in apps/* core packages/* surfaces/*; do \
|
||||||
|
if [ -d "$package/dist" ]; then \
|
||||||
|
mkdir -p "/runtime/$package"; \
|
||||||
|
cp -a "$package/dist" "/runtime/$package/dist"; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
cp -a core/drizzle /runtime/core/drizzle; \
|
||||||
|
if [ -d modules ]; then \
|
||||||
|
tar -C modules --exclude='node_modules' -cf - . | tar -C /runtime/modules -xf -; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# prod-deps: the same lockfile, production dependencies only. No compiler, bundler, or test runner
|
||||||
|
# reaches the runtime image.
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM toolchain AS prod-deps
|
||||||
|
COPY --from=manifests /manifests/ ./
|
||||||
|
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --prod --store-dir /pnpm/store
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# runtime
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM base AS runtime
|
||||||
|
|
||||||
|
ARG VERSION=""
|
||||||
|
ARG REVISION=""
|
||||||
|
ARG SOURCE=""
|
||||||
|
LABEL org.opencontainers.image.title="Ekklesia" \
|
||||||
|
org.opencontainers.image.description="Ekklesia API, Dashboard, display host, and operator CLIs" \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.revision="${REVISION}" \
|
||||||
|
org.opencontainers.image.source="${SOURCE}" \
|
||||||
|
org.opencontainers.image.base.name="docker.io/library/node:24.18.0-trixie-slim" \
|
||||||
|
org.opencontainers.image.base.digest="sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573"
|
||||||
|
|
||||||
|
# Only the container's own listen address differs from the platform default: outside a container
|
||||||
|
# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
EKKLESIA_API_HOST=0.0.0.0 \
|
||||||
|
EKKLESIA_API_PORT=3000
|
||||||
|
|
||||||
|
# The repository layout, preserved: the API finds the repository root three directories above
|
||||||
|
# `apps/api/dist/`, and every CLI uses its working directory as the root.
|
||||||
|
WORKDIR /opt/ekklesia
|
||||||
|
COPY --from=prod-deps /opt/ekklesia/ ./
|
||||||
|
COPY --from=build /runtime/ ./
|
||||||
|
|
||||||
|
# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Readiness, not liveness: an instance whose database is unreachable or that is shutting down is
|
||||||
|
# unhealthy. Node's own fetch, so the image needs no curl.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.EKKLESIA_API_PORT || '3000') + '/api/v1/health/ready', { signal: AbortSignal.timeout(4000) }).then((response) => process.exit(response.status === 200 ? 0 : 1), () => process.exit(1))"]
|
||||||
|
|
||||||
|
# `pnpm api:start` without its build step and without `--env-file`: configuration comes only from
|
||||||
|
# the container environment. The same image runs every CLI: `node core/dist/cli/<command>.js`.
|
||||||
|
CMD ["node", "apps/api/dist/server.js"]
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
This directory holds Ekklesia's reference self-hosted deployment
|
||||||
|
([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)): one Linux
|
||||||
|
host, one Docker Compose project, and the production container image it runs. Nothing here is
|
||||||
|
specific to one organization.
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| [`compose.yaml`](compose.yaml) | The base: `api`, `postgres`, `object-storage`, and the operator-only `migrate`. Publishes no port. |
|
||||||
|
| [`compose.proxy.yaml`](compose.proxy.yaml) | Overlay: Caddy as the TLS proxy, the only service with published ports. |
|
||||||
|
| [`compose.tunnel.yaml`](compose.tunnel.yaml) | Overlay on top of the proxy: a Cloudflare Tunnel connector. |
|
||||||
|
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration; copy to `Caddyfile`. |
|
||||||
|
| [`postgres/pg_hba.conf`](postgres/pg_hba.conf) | PostgreSQL client authentication: TLS only, SCRAM passwords. |
|
||||||
|
| [`env/`](env/) | Commented examples of every env file. No values. |
|
||||||
|
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
||||||
|
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
||||||
|
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
||||||
|
|
||||||
|
## The reference deployment
|
||||||
|
|
||||||
|
### Shape
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd deploy
|
||||||
|
# Base and proxy: the reference.
|
||||||
|
docker compose -f compose.yaml -f compose.proxy.yaml up -d
|
||||||
|
# Base, proxy, and Cloudflare Tunnel.
|
||||||
|
docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
|
||||||
|
# Base only, behind a reverse proxy the installation already runs.
|
||||||
|
docker compose -f compose.yaml -f compose.myproxy.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass the same `-f` files to every later `up`, `down`, `ps`, and `logs`. `migrate` commands need only
|
||||||
|
the base: `docker compose run --rm migrate …` from this directory uses `compose.yaml` by default.
|
||||||
|
|
||||||
|
| Network | Kind | Members |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `data` | internal | `postgres`, `api`, `migrate` |
|
||||||
|
| `storage` | internal | `object-storage`, `api`, `migrate` |
|
||||||
|
| `egress` | bridge | `api`, `migrate` (outbound only, for example an SMTP relay) |
|
||||||
|
| `edge` | bridge, fixed subnet (proxy overlay) | `proxy` (fixed address), `api`, `object-storage`, `tunnel` (fixed address) |
|
||||||
|
|
||||||
|
PostgreSQL is only ever on `data`, so no proxy and no tunnel can reach it. `internal` networks have
|
||||||
|
no route off the host. Object storage joins `edge` only when the proxy overlay attaches it.
|
||||||
|
|
||||||
|
TLS is everywhere, without any exception in Core:
|
||||||
|
|
||||||
|
- **Browsers to the proxy:** publicly trusted ACME certificates.
|
||||||
|
- **API to PostgreSQL:** `verify-full` against the installation-private CA, passed as PEM text in
|
||||||
|
`EKKLESIA_DATABASE_CA`. PostgreSQL accepts only TLS 1.3 network connections.
|
||||||
|
- **API (and proxy) to object storage:** HTTPS on port 8443 with a certificate from the same CA.
|
||||||
|
Production storage must be https (ADR 0030). Core's S3 client uses Node's default trust store, so
|
||||||
|
Compose adds the CA with `NODE_EXTRA_CA_CERTS`.
|
||||||
|
|
||||||
|
### Where configuration and secrets live
|
||||||
|
|
||||||
|
| File (on the host) | Holds | Read by |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `deploy/.env` (from [`env/compose.env.example`](env/compose.env.example)) | Non-secret settings: the image, hostnames, paths, the edge subnet | Compose itself |
|
||||||
|
| `/etc/ekklesia/ekklesia.env` | Everything the API needs, including `DATABASE_URL` and the S3 keys | `api`, `migrate` |
|
||||||
|
| `/etc/ekklesia/postgres.env` | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | `postgres` |
|
||||||
|
| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` |
|
||||||
|
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
|
||||||
|
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file |
|
||||||
|
|
||||||
|
The secret files are owned by root with mode 600, one per service, so no container receives another
|
||||||
|
service's credentials. None is committed, and `deploy/.env` is ignored by Git. `EKKLESIA_ENV_DIR`
|
||||||
|
and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
|
||||||
|
|
||||||
|
### First boot
|
||||||
|
|
||||||
|
1. **Get the image.** Build it ([Building](#building)) or pull it, and set `EKKLESIA_IMAGE` in
|
||||||
|
`deploy/.env`.
|
||||||
|
2. **Create the TLS material**, as root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
|
||||||
|
```
|
||||||
|
|
||||||
|
It creates the CA once and a certificate for each of `postgres` and `object-storage`, gives each
|
||||||
|
key to its server's uid (PostgreSQL 70, SeaweedFS 1000) with mode 600, and prints the exact
|
||||||
|
`EKKLESIA_DATABASE_CA="…"` line. It never overwrites an existing key or certificate and never
|
||||||
|
prints a private key. `ca.key` is needed only to issue certificates: keep a copy offline.
|
||||||
|
3. **Generate secrets** with `sh deploy/scripts/gen-secrets.sh`, and paste each line into the file
|
||||||
|
named above it.
|
||||||
|
4. **Write the env files** from [`env/`](env/), then install each one owned by root with mode 600:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -m 755 /etc/ekklesia
|
||||||
|
sudo install -o root -g root -m 600 ekklesia.env /etc/ekklesia/ekklesia.env
|
||||||
|
```
|
||||||
|
|
||||||
|
`EKKLESIA_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
|
||||||
|
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname.
|
||||||
|
5. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
|
||||||
|
`EKKLESIA_STORAGE_HOSTNAME`, and `EKKLESIA_ACME_EMAIL` in `deploy/.env`. Both hostnames must
|
||||||
|
resolve to this host with ports 80 and 443 reachable for HTTP-01, or see [DNS-01](#dns-01).
|
||||||
|
6. **Start the data services, then migrate explicitly:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage
|
||||||
|
docker compose run --rm migrate # plan: read-only
|
||||||
|
docker compose run --rm migrate node core/dist/cli/apply-migrations.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
|
||||||
|
you run it.
|
||||||
|
7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||||
|
8. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
|
||||||
|
`docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
|
||||||
|
for anything optional you left unconfigured, such as email).
|
||||||
|
|
||||||
|
### The initial owner
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email [email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
It prompts for the password with hidden input and never accepts it on the command line. The owner
|
||||||
|
is created already verified, so they can sign in before any email relay exists
|
||||||
|
([ADR 0035](../docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)). To supply the
|
||||||
|
password from a secret manager instead, pipe it on standard input and use `-T` instead of `-it`.
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag.
|
||||||
|
2. **Back up** ([Backups](#backups)).
|
||||||
|
3. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
|
||||||
|
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
||||||
|
4. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||||
|
|
||||||
|
### Rolling back
|
||||||
|
|
||||||
|
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
||||||
|
the upgrade applied no migrations (the plan in step 3 showed none pending). Migrations are
|
||||||
|
forward-only. If any were applied, restore the backup taken in step 2 before starting the previous
|
||||||
|
image, because it was not built for the newer schema.
|
||||||
|
|
||||||
|
### Backups
|
||||||
|
|
||||||
|
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
|
||||||
|
take a `pg_dump` and a copy of every bucket before each upgrade by hand.
|
||||||
|
|
||||||
|
### Behind an existing reverse proxy
|
||||||
|
|
||||||
|
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
|
||||||
|
your own, for example `compose.myproxy.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
networks:
|
||||||
|
proxy: {}
|
||||||
|
environment:
|
||||||
|
# Exactly one trusted peer: your proxy, at an address that cannot change.
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: 10.0.100.254/32
|
||||||
|
object-storage:
|
||||||
|
networks:
|
||||||
|
proxy: {}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
name: your-proxy-network
|
||||||
|
```
|
||||||
|
|
||||||
|
Then make your proxy do what `Caddyfile.example` does:
|
||||||
|
|
||||||
|
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
|
||||||
|
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
||||||
|
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
||||||
|
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
||||||
|
- set no request-body limit below 32 MiB; the API enforces its own ceilings;
|
||||||
|
- forward the storage hostname to `https://object-storage:8443`, verifying it against
|
||||||
|
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
|
||||||
|
through unchanged**, or every signed URL fails (ADR 0031).
|
||||||
|
|
||||||
|
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
|
||||||
|
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
|
||||||
|
range, as `compose.proxy.yaml` does.
|
||||||
|
|
||||||
|
### Fixed addresses on `edge`
|
||||||
|
|
||||||
|
`compose.proxy.yaml` gives `edge` the subnet `10.249.250.0/24` and lets Docker assign dynamic
|
||||||
|
addresses only from its lower half (`10.249.250.0/25`). The proxy (`.254`) and the tunnel connector
|
||||||
|
(`.253`) take fixed addresses from the top of the upper half, which Docker never assigns to anything
|
||||||
|
else. That is what makes "only the proxy" and "only the connector" expressible as `/32` trust
|
||||||
|
entries. If the subnet collides with one on your host, change `EKKLESIA_EDGE_SUBNET`,
|
||||||
|
`EKKLESIA_EDGE_DYNAMIC_RANGE`, `EKKLESIA_PROXY_ADDRESS`, and `EKKLESIA_TUNNEL_ADDRESS` together,
|
||||||
|
keeping the fixed addresses at the top and outside the dynamic range.
|
||||||
|
|
||||||
|
### Cloudflare Tunnel
|
||||||
|
|
||||||
|
`compose.tunnel.yaml` runs `cloudflared` with a token, so the tunnel is managed in the Cloudflare
|
||||||
|
dashboard. No Cloudflare API is called from here.
|
||||||
|
|
||||||
|
1. In Cloudflare Zero Trust, under **Networks → Tunnels**, create a Cloudflared tunnel. Copy its
|
||||||
|
token into `/etc/ekklesia/tunnel.env` as `TUNNEL_TOKEN`.
|
||||||
|
2. Add a **public hostname** (shown as a *published application route* in newer dashboards) for
|
||||||
|
the Ekklesia hostname. Service type **HTTPS**, URL `proxy:443`.
|
||||||
|
3. Under that hostname's **TLS** settings, set **Origin Server Name** to the same Ekklesia hostname,
|
||||||
|
and leave **No TLS Verify** off. The connector then verifies the proxy's certificate for that name.
|
||||||
|
4. Add a second public hostname for the storage hostname, the same way, with its own origin server
|
||||||
|
name.
|
||||||
|
5. Start with all three files. The proxy honors `CF-Connecting-IP` only from the connector's fixed
|
||||||
|
address. From any other peer it is stripped.
|
||||||
|
|
||||||
|
The proxy's certificate must still be publicly trusted, and an HTTP-01 challenge cannot reach the
|
||||||
|
proxy through a tunnel, because Cloudflare terminates TLS at its edge. A tunnel-only installation
|
||||||
|
therefore uses [DNS-01](#dns-01). An installation that also serves local displays should give the
|
||||||
|
same hostnames a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
|
||||||
|
Internet link.
|
||||||
|
|
||||||
|
### DNS-01
|
||||||
|
|
||||||
|
For an installation off the public Internet, LAN-only, or tunnel-only. DNS-01 needs a DNS provider
|
||||||
|
module, which the stock Caddy image does not contain:
|
||||||
|
|
||||||
|
1. Build a Caddy image with the module (for example
|
||||||
|
`xcaddy build --with github.com/caddy-dns/cloudflare`) and pin it by digest.
|
||||||
|
2. Override the proxy's `image:` in an overlay of your own. Give it `CLOUDFLARE_API_TOKEN` (a token
|
||||||
|
scoped to DNS edits for the zone) through a root-owned env file.
|
||||||
|
3. Enable the commented `acme_dns` line in your `Caddyfile`.
|
||||||
|
|
||||||
|
### Launch checklist
|
||||||
|
|
||||||
|
The ten items in [Security § launch checklist](../docs/development/security.md) mapped to this
|
||||||
|
deployment:
|
||||||
|
|
||||||
|
| # | Item | Where it is met |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. |
|
||||||
|
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
||||||
|
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
||||||
|
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
||||||
|
| 5 | Rotating forwarding headers cannot rotate the rate-limit bucket | The same, plus stripping `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; the API overwrites its internal header. Proven by the smoke test. |
|
||||||
|
| 6 | The proxy peer is the only trusted proxy | `compose.proxy.yaml` sets `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` to the proxy's fixed `/32`. |
|
||||||
|
| 7 | Proxy logs record no cookies, credentials, bodies, or forwarding chains | Caddy access logs are off in `Caddyfile.example`. If you enable them, exclude request headers. |
|
||||||
|
| 8 | PostgreSQL only from the API, over verified TLS | `postgres` is only on internal `data`; `pg_hba.conf` rejects non-TLS; `verify-full` with `EKKLESIA_DATABASE_CA`. Proven by the smoke test. |
|
||||||
|
| 9 | The deployed login and logout flow keeps its contract | Unchanged by the proxy; the smoke test signs in and out through it. |
|
||||||
|
| 10 | A supported way to create the first production identity | `create-initial-owner.js` through `migrate` ([The initial owner](#the-initial-owner)). Exercised by the smoke test. |
|
||||||
|
|
||||||
|
### Testing this deployment
|
||||||
|
|
||||||
|
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
|
||||||
|
env files. CI runs it in `pnpm ci:verify`.
|
||||||
|
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against a
|
||||||
|
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
|
||||||
|
follows the first boot above and checks the following through the proxy:
|
||||||
|
- readiness and the Dashboard;
|
||||||
|
- a signed object URL fetched through the storage hostname;
|
||||||
|
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
|
||||||
|
- initial-owner sign-in and sign-out;
|
||||||
|
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
|
||||||
|
wrong host name;
|
||||||
|
- Doctor.
|
||||||
|
|
||||||
|
CI runs it on every change.
|
||||||
|
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
|
||||||
|
has no public hostname. Never use it in an installation; `Caddyfile.example` does not contain it.
|
||||||
|
|
||||||
|
The smoke test uses no module, so it does not exercise realtime streams or large uploads. Through
|
||||||
|
this same proxy configuration, a scratch module confirmed both:
|
||||||
|
|
||||||
|
- a Server-Sent Event crossed the proxy in milliseconds;
|
||||||
|
- a 32 MiB streamed upload passed with and without `Content-Length`;
|
||||||
|
- one byte more was refused by the API's own `413 REQUEST_BODY_TOO_LARGE`.
|
||||||
|
|
||||||
|
## The production image
|
||||||
|
|
||||||
|
[`Dockerfile`](Dockerfile) builds **one image** for two jobs: the long-running `api` service, and
|
||||||
|
every operator-run command (migrations, Doctor, initial-owner creation, module enablement). There
|
||||||
|
is no second image and no second build.
|
||||||
|
|
||||||
|
### What it builds
|
||||||
|
|
||||||
|
The build context is an **already-assembled workspace**: this repository, plus zero or more module
|
||||||
|
packages already present in `modules/<id>`, plus a `pnpm-lock.yaml` describing exactly that set.
|
||||||
|
The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no
|
||||||
|
`modules/` directory, it builds the platform alone; CI builds that case on every change.
|
||||||
|
|
||||||
|
Assembling the context (checking out the platform at a release tag, each module at a pinned tag or
|
||||||
|
commit, and the instance's own lockfile) belongs to the installation's instance composition, not to
|
||||||
|
this file.
|
||||||
|
|
||||||
|
| Stage | Does |
|
||||||
|
| --- | --- |
|
||||||
|
| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. |
|
||||||
|
| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. |
|
||||||
|
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and every workspace `package.json` at its repository path. |
|
||||||
|
| `build` | `pnpm install --frozen-lockfile`, then `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`, against exactly the modules in the context. Never uses host `node_modules`: `.dockerignore` excludes them. |
|
||||||
|
| `prod-deps` | The same lockfile with `--prod`: production dependencies only. |
|
||||||
|
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000). No pnpm, no compiler, no curl. |
|
||||||
|
|
||||||
|
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
|
||||||
|
finds its files:
|
||||||
|
|
||||||
|
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
|
||||||
|
`package.json`, `packages/module-sdk/package.json`, and `modules/` beneath it;
|
||||||
|
- every CLI takes its **working directory** as the root and also reads Core's migrations in
|
||||||
|
`core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`;
|
||||||
|
- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`;
|
||||||
|
- each module directory is copied whole, minus `node_modules`, because each manifest names its
|
||||||
|
own entrypoints, UI artifacts, and migrations directory.
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build \
|
||||||
|
--file deploy/Dockerfile \
|
||||||
|
--build-arg VERSION=0.1.1 \
|
||||||
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
|
--tag ekklesia:0.1.1 \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
The three build arguments become the OCI labels `org.opencontainers.image.version`, `.revision`,
|
||||||
|
and `.source`. They have no defaults: the repository cannot know an installation's Git host. The
|
||||||
|
image also records its base image name and digest. BuildKit is required (Docker's default builder);
|
||||||
|
set `DOCKER_BUILDKIT=1` if your Docker CLI has no buildx plugin.
|
||||||
|
|
||||||
|
No secret is a build argument, and none enters any layer. `.env` files are excluded from the build
|
||||||
|
context entirely, and no registry credentials are needed.
|
||||||
|
|
||||||
|
**A module missing a production dependency fails the build, not the API.** The runtime receives
|
||||||
|
production dependencies only, so a module that imports a package at run time but declares it only
|
||||||
|
as a development or peer dependency would build and then fail at startup. `pnpm boundaries` runs
|
||||||
|
in the build stage and rejects that module first
|
||||||
|
(`runtime-imports-are-production-dependencies`, see
|
||||||
|
[Repository boundaries](../docs/architecture/repository-boundaries.md)). The same applies to every
|
||||||
|
other boundary rule: a module the platform's own CI would reject cannot be built into an image.
|
||||||
|
|
||||||
|
**A platform-only image does not exercise native module dependencies.** With no modules in the
|
||||||
|
context, nothing like sharp is installed, so the CI image build proves nothing about them. An
|
||||||
|
instance build that includes such a module must smoke-test the built image itself: start it,
|
||||||
|
confirm `/api/v1/health/ready` answers `200`, and exercise the module's native code path (for
|
||||||
|
example, process one image).
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Configuration comes **only from the container environment**, never from a file inside the image.
|
||||||
|
The image sets exactly three variables itself:
|
||||||
|
|
||||||
|
| Variable | Image value | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. |
|
||||||
|
| `EKKLESIA_API_HOST` | `0.0.0.0` | Reachable from other containers. Outside a container the platform default stays `127.0.0.1`. |
|
||||||
|
| `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. |
|
||||||
|
|
||||||
|
Everything else is the ordinary host configuration in
|
||||||
|
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
|
||||||
|
reference deployment's `ekklesia.env`. `EKKLESIA_DATABASE_CA` is the CA's PEM text itself, on one
|
||||||
|
double-quoted line with `\n` escapes. Compose expands those escapes; plain `docker run --env-file`
|
||||||
|
does not, which is why every command here goes through Compose.
|
||||||
|
|
||||||
|
Never publish the API's port to the host or the Internet. Only the proxy publishes ports.
|
||||||
|
|
||||||
|
### Process, signals, and the read-only posture
|
||||||
|
|
||||||
|
`compose.yaml` runs `api` and `migrate` with `init: true`, `read_only: true`, `tmpfs: /tmp`,
|
||||||
|
`cap_drop: [ALL]`, and `no-new-privileges`. That is the supported production posture:
|
||||||
|
|
||||||
|
- **`init: true`** runs a minimal init as PID 1 that forwards signals and reaps children, so
|
||||||
|
`docker stop` reaches the API's own SIGTERM handler promptly. The API then drains in-flight
|
||||||
|
requests, stops modules in reverse order, and closes its pool.
|
||||||
|
- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and
|
||||||
|
the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom
|
||||||
|
for module code that needs scratch space. The installation, `/home/node` included, is not
|
||||||
|
writable, so anything that needs a writable path other than `/tmp` is a defect to report, not to
|
||||||
|
work around.
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
The image's `HEALTHCHECK` calls `GET /api/v1/health/ready` on `127.0.0.1` with Node's built-in
|
||||||
|
`fetch`, every 30 seconds after a 30-second start period, with three retries. Readiness means the
|
||||||
|
database answered within 2 seconds and the instance is not shutting down; see
|
||||||
|
[API § Health](../docs/development/api.md#health). Because it probes `127.0.0.1`, keep
|
||||||
|
`EKKLESIA_API_HOST` at `0.0.0.0` (or any address that includes loopback) inside the container. The
|
||||||
|
API trusts only the proxy's address for client-address headers, so the loopback probe is an
|
||||||
|
ordinary direct peer.
|
||||||
|
|
||||||
|
An external monitor can use the same endpoint through the proxy. Use `/api/v1/health/live` to ask
|
||||||
|
only whether the process is serving.
|
||||||
|
|
||||||
|
### Operator commands
|
||||||
|
|
||||||
|
The same image runs every CLI, directly with `node core/dist/cli/<command>.js`, through the
|
||||||
|
`migrate` service. The `pnpm` scripts of the same names rebuild first and read `.env`, so they are
|
||||||
|
for a development checkout, not for the image.
|
||||||
|
|
||||||
|
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
|
||||||
|
and its default command is the read-only plan. **Nothing applies migrations automatically.**
|
||||||
|
|
||||||
|
| Task | Command (from `deploy/`) |
|
||||||
|
| --- | --- |
|
||||||
|
| Plan migrations (read-only) | `docker compose run --rm migrate` |
|
||||||
|
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` |
|
||||||
|
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` |
|
||||||
|
| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email owner@example.org` |
|
||||||
|
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` (or `--disable`) |
|
||||||
|
|
||||||
|
The plan, apply, Doctor, and initial-owner commands are exercised by the smoke test; module
|
||||||
|
enablement is not.
|
||||||
|
|
||||||
|
### Size
|
||||||
|
|
||||||
|
The platform-only image is about 378 MB: the Debian slim Node base (about 230 MB), production
|
||||||
|
dependencies (about 141 MB), and built outputs (about 6 MB). A rebuild that reuses a warm pnpm store
|
||||||
|
cache can come out about 21 MB larger. When esbuild's install script runs, it hard-links its
|
||||||
|
`bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's
|
||||||
|
cached post-install state instead, that file is a second full copy. Both are esbuild, one of the
|
||||||
|
unused tools below.
|
||||||
|
|
||||||
|
About 57 MB of the production dependencies are development tools that no runtime code imports:
|
||||||
|
`vitest`, `vite`, `drizzle-kit`, `tsx`, `esbuild`, and their native bindings. They arrive as
|
||||||
|
optional peer dependencies of Better Auth (and `esbuild` through the module UI builder), which pnpm
|
||||||
|
resolves because the workspace already has them. Excluding them means changing dependency
|
||||||
|
resolution and the lockfile, which is separate work.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# The reference TLS proxy overlay (ADR 0044's `proxy` service). Use it on top of the base:
|
||||||
|
#
|
||||||
|
# docker compose -f compose.yaml -f compose.proxy.yaml up -d
|
||||||
|
#
|
||||||
|
# Caddy terminates TLS with publicly trusted ACME certificates and publishes the deployment's only
|
||||||
|
# ports. It reaches `api` and `object-storage` on the `edge` network, which this overlay creates and
|
||||||
|
# attaches them to; `postgres` is never on it.
|
||||||
|
#
|
||||||
|
# Client addresses. Caddy is the only peer the API trusts to say who the client is: this overlay
|
||||||
|
# puts the API in trusted-proxy mode for exactly the proxy's fixed address, and the Caddyfile
|
||||||
|
# overwrites X-Forwarded-For with the one address Caddy resolved. Because the address must be
|
||||||
|
# fixed for "only the proxy" to be expressible, `edge` has an explicit subnet whose dynamic range is
|
||||||
|
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
|
||||||
|
# which Docker never hands to another container.
|
||||||
|
|
||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- NET_BIND_SERVICE
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
ports:
|
||||||
|
# 80 answers ACME HTTP-01 challenges and redirects everything else to HTTPS. Set
|
||||||
|
# EKKLESIA_PUBLISH_ADDRESS to bind one host interface instead of all of them.
|
||||||
|
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTP_PORT:-80}:80"
|
||||||
|
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443"
|
||||||
|
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443/udp"
|
||||||
|
environment:
|
||||||
|
EKKLESIA_HOSTNAME: ${EKKLESIA_HOSTNAME:?EKKLESIA_HOSTNAME is required, for example ekklesia.example.org}
|
||||||
|
EKKLESIA_STORAGE_HOSTNAME: ${EKKLESIA_STORAGE_HOSTNAME:?EKKLESIA_STORAGE_HOSTNAME is required, for example storage.ekklesia.example.org}
|
||||||
|
EKKLESIA_ACME_EMAIL: ${EKKLESIA_ACME_EMAIL:?EKKLESIA_ACME_EMAIL is required for ACME account notices}
|
||||||
|
EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
|
||||||
|
volumes:
|
||||||
|
- ${EKKLESIA_CADDYFILE:-./Caddyfile}:/etc/caddy/Caddyfile:ro
|
||||||
|
# The installation-private CA, so Caddy can verify object-storage's certificate.
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/etc/caddy/ekklesia-ca.crt:ro
|
||||||
|
- proxy-data:/data
|
||||||
|
- proxy-config:/config
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
ipv4_address: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
object-storage:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
api:
|
||||||
|
networks:
|
||||||
|
edge: {}
|
||||||
|
environment:
|
||||||
|
# Exactly one trusted peer: this proxy. Set here from the same variable that fixes the proxy's
|
||||||
|
# address, so the two cannot drift apart. These override any value in ekklesia.env.
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
|
||||||
|
|
||||||
|
object-storage:
|
||||||
|
networks:
|
||||||
|
edge: {}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: ${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}
|
||||||
|
# Docker assigns dynamic addresses only from this range. Keep the fixed proxy and tunnel
|
||||||
|
# addresses outside it.
|
||||||
|
ip_range: ${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
proxy-data:
|
||||||
|
proxy-config:
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# The Cloudflare Tunnel overlay (ADR 0044, optional ingress). Use it on top of base and proxy:
|
||||||
|
#
|
||||||
|
# docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
|
||||||
|
#
|
||||||
|
# `cloudflared` makes an outbound connection to Cloudflare and forwards visitors to the proxy over
|
||||||
|
# HTTPS. The tunnel is token-managed, so its public hostnames and origin settings live in the
|
||||||
|
# Cloudflare dashboard: each hostname's service is `https://proxy:443` with the matching origin
|
||||||
|
# server name and TLS verification left on (deploy/README.md, "Cloudflare Tunnel").
|
||||||
|
#
|
||||||
|
# The connector has a fixed address on `edge`. The Caddyfile trusts a client-address header
|
||||||
|
# (CF-Connecting-IP) only from that address, so no other peer can choose its own client address.
|
||||||
|
|
||||||
|
services:
|
||||||
|
tunnel:
|
||||||
|
image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
# The image's entrypoint is `cloudflared --no-autoupdate`; the token comes from TUNNEL_TOKEN.
|
||||||
|
command: ["tunnel", "run"]
|
||||||
|
env_file:
|
||||||
|
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
ipv4_address: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
|
||||||
|
depends_on:
|
||||||
|
- proxy
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Ekklesia's reference self-hosted deployment: the base project (ADR 0044).
|
||||||
|
#
|
||||||
|
# `api`, `postgres`, and `object-storage`, plus the operator-only `migrate` service. Nothing here
|
||||||
|
# publishes a port. Add `compose.proxy.yaml` for the reference TLS proxy, and `compose.tunnel.yaml`
|
||||||
|
# on top of that for a Cloudflare Tunnel connector; or attach `api` and `object-storage` to an
|
||||||
|
# existing proxy's network yourself (deploy/README.md).
|
||||||
|
#
|
||||||
|
# Non-secret settings come from `deploy/.env` (copy `env/compose.env.example`). Secrets live in
|
||||||
|
# root-owned, mode-600 files under EKKLESIA_ENV_DIR, one per service, so no container receives
|
||||||
|
# another service's credentials. TLS material comes from `scripts/init-tls.sh`.
|
||||||
|
|
||||||
|
name: ekklesia
|
||||||
|
|
||||||
|
x-ekklesia-hardening: &ekklesia-hardening
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
x-ekklesia-app: &ekklesia-app
|
||||||
|
<<: *ekklesia-hardening
|
||||||
|
image: ${EKKLESIA_IMAGE:?EKKLESIA_IMAGE is required, for example registry.example.org/ekklesia:0.1.0}
|
||||||
|
env_file:
|
||||||
|
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/ekklesia.env
|
||||||
|
environment:
|
||||||
|
# Core's object-storage client trusts Node's default CA set. Adding the installation-private CA
|
||||||
|
# to it, from the file below, is what lets the API verify `object-storage`'s certificate
|
||||||
|
# without any storage TLS exception. The database CA travels separately, as PEM text in
|
||||||
|
# EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
|
||||||
|
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt
|
||||||
|
volumes:
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
|
||||||
|
networks:
|
||||||
|
data: {}
|
||||||
|
storage: {}
|
||||||
|
egress: {}
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
<<: *ekklesia-app
|
||||||
|
restart: unless-stopped
|
||||||
|
# The image's own HEALTHCHECK (GET /api/v1/health/ready) applies unchanged.
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
object-storage:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# Operator commands only. `profiles` keeps `docker compose up` from ever starting it; it runs only
|
||||||
|
# through `docker compose run --rm migrate …`. The default command is the read-only plan.
|
||||||
|
migrate:
|
||||||
|
<<: *ekklesia-app
|
||||||
|
profiles:
|
||||||
|
- ops
|
||||||
|
restart: "no"
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
command: ["node", "core/dist/cli/plan-migrations.js"]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
object-storage:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
env_file:
|
||||||
|
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/postgres.env
|
||||||
|
environment:
|
||||||
|
POSTGRES_INITDB_ARGS: --data-checksums --auth-host=scram-sha-256 --auth-local=trust
|
||||||
|
TZ: UTC
|
||||||
|
# TLS with the installation-private CA's server certificate for the name `postgres`, TLS 1.3
|
||||||
|
# only, and a host-based authentication file that rejects every non-TLS network connection.
|
||||||
|
# The API connects with verify-full; there is no plaintext path to fall back to.
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
- -c
|
||||||
|
- ssl=on
|
||||||
|
- -c
|
||||||
|
- ssl_cert_file=/run/ekklesia/tls/postgres.crt
|
||||||
|
- -c
|
||||||
|
- ssl_key_file=/run/ekklesia/tls/postgres.key
|
||||||
|
- -c
|
||||||
|
- ssl_min_protocol_version=TLSv1.3
|
||||||
|
- -c
|
||||||
|
- hba_file=/etc/ekklesia/pg_hba.conf
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.crt:/run/ekklesia/tls/postgres.crt:ro
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.key:/run/ekklesia/tls/postgres.key:ro
|
||||||
|
- ./postgres/pg_hba.conf:/etc/ekklesia/pg_hba.conf:ro
|
||||||
|
# Only `data`: never a proxy-facing network, and no route out of the host.
|
||||||
|
networks:
|
||||||
|
data: {}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
object-storage:
|
||||||
|
image: chrislusf/seaweedfs:4.28@sha256:d071979843142451d41566bc09f6adaa36902c112683d900a417247c82e9c3c1
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
env_file:
|
||||||
|
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/object-storage.env
|
||||||
|
environment:
|
||||||
|
TZ: UTC
|
||||||
|
# The S3 API over HTTPS on 8443, with the installation-private CA's certificate for the name
|
||||||
|
# `object-storage`: production storage must be https (ADR 0030), for the API and for the proxy.
|
||||||
|
command:
|
||||||
|
- mini
|
||||||
|
- -dir=/data
|
||||||
|
- -s3.port.https=8443
|
||||||
|
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||||
|
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||||
|
volumes:
|
||||||
|
- object-storage-data:/data
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.crt:/run/ekklesia/tls/object-storage.crt:ro
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.key:/run/ekklesia/tls/object-storage.key:ro
|
||||||
|
networks:
|
||||||
|
storage: {}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 18
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# The database network: postgres, api, and migrate. Internal, so nothing on it reaches beyond the
|
||||||
|
# host and no proxy is ever attached to it.
|
||||||
|
data:
|
||||||
|
internal: true
|
||||||
|
# The storage network: object-storage, api, and migrate. Internal for the same reason; browsers
|
||||||
|
# reach storage only through a proxy that joins it on `edge`.
|
||||||
|
storage:
|
||||||
|
internal: true
|
||||||
|
# Outbound access for api and migrate (an SMTP relay, for example). Nothing listens on it.
|
||||||
|
egress: {}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
object-storage-data:
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Non-secret settings for the reference deployment's Compose project. Copy to deploy/.env, which
|
||||||
|
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
|
||||||
|
# per-service files under EKKLESIA_ENV_DIR.
|
||||||
|
|
||||||
|
# The image to run, by registry, name, and tag (or digest). Required.
|
||||||
|
EKKLESIA_IMAGE=
|
||||||
|
|
||||||
|
# Where the root-owned, mode-600 env files and the TLS directory live on the host.
|
||||||
|
EKKLESIA_ENV_DIR=/etc/ekklesia
|
||||||
|
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
|
||||||
|
|
||||||
|
# --- compose.proxy.yaml ---------------------------------------------------------------------------
|
||||||
|
# The Ekklesia hostname (Dashboard, API, displays) and the storage hostname (signed object URLs).
|
||||||
|
EKKLESIA_HOSTNAME=
|
||||||
|
EKKLESIA_STORAGE_HOSTNAME=
|
||||||
|
# Contact address for the ACME account (certificate expiry notices).
|
||||||
|
EKKLESIA_ACME_EMAIL=
|
||||||
|
# EKKLESIA_CADDYFILE=./Caddyfile
|
||||||
|
# EKKLESIA_PUBLISH_ADDRESS=0.0.0.0
|
||||||
|
# EKKLESIA_HTTP_PORT=80
|
||||||
|
# EKKLESIA_HTTPS_PORT=443
|
||||||
|
|
||||||
|
# The edge network. Change the subnet only if it collides with one already on the host. Docker
|
||||||
|
# assigns dynamic addresses only from EKKLESIA_EDGE_DYNAMIC_RANGE (the lower half); the proxy and
|
||||||
|
# the tunnel connector take fixed addresses from the upper half, so nothing else can ever hold them.
|
||||||
|
# EKKLESIA_EDGE_SUBNET=10.249.250.0/24
|
||||||
|
# EKKLESIA_EDGE_DYNAMIC_RANGE=10.249.250.0/25
|
||||||
|
# EKKLESIA_PROXY_ADDRESS=10.249.250.254
|
||||||
|
# EKKLESIA_TUNNEL_ADDRESS=10.249.250.253
|
||||||
Vendored
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# Ekklesia API and operator commands (`api` and `migrate`). Reference deployment, ADR 0044.
|
||||||
|
#
|
||||||
|
# Install as EKKLESIA_ENV_DIR/ekklesia.env (default /etc/ekklesia/ekklesia.env), owned by root with
|
||||||
|
# mode 600:
|
||||||
|
# install -o root -g root -m 600 env/ekklesia.env.example /etc/ekklesia/ekklesia.env
|
||||||
|
# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
|
||||||
|
# line with scripts/init-tls.sh.
|
||||||
|
#
|
||||||
|
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, and EKKLESIA_API_PORT=3000.
|
||||||
|
# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_*
|
||||||
|
# variables. None of those belong here.
|
||||||
|
#
|
||||||
|
# Values that are part of the reference topology are filled in; everything installation-specific is
|
||||||
|
# blank. Full reference: docs/development/api.md#required-configuration.
|
||||||
|
|
||||||
|
# --- Database (ADR 0018) ------------------------------------------------------------------------
|
||||||
|
# The host must be exactly `postgres`: the server certificate is issued for that name and
|
||||||
|
# verify-full checks it. User, password, and database must match postgres.env. Percent-encode any
|
||||||
|
# reserved character in the password. Never add sslmode or another TLS parameter to this URL.
|
||||||
|
DATABASE_URL=postgresql://<user>:<password>@postgres:5432/<database>
|
||||||
|
EKKLESIA_DATABASE_TLS_MODE=verify-full
|
||||||
|
# The installation-private CA as PEM text (not a path), on one double-quoted line with \n escapes.
|
||||||
|
# scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts.
|
||||||
|
EKKLESIA_DATABASE_CA=
|
||||||
|
|
||||||
|
# --- Identity (ADR 0011, ADR 0018, ADR 0034) ----------------------------------------------------
|
||||||
|
# The public https origin of the Ekklesia hostname, with no path. Production rejects http and
|
||||||
|
# loopback origins.
|
||||||
|
EKKLESIA_IDENTITY_BASE_URL=
|
||||||
|
# Comma-separated exact origins allowed to make credentialed requests. Normally just the base URL.
|
||||||
|
EKKLESIA_IDENTITY_TRUSTED_ORIGINS=
|
||||||
|
# At least 32 random characters. Rotating it signs every user out.
|
||||||
|
EKKLESIA_IDENTITY_SECRET=
|
||||||
|
# Leave unset: the Dashboard is served from the base URL (ADR 0044).
|
||||||
|
# EKKLESIA_DASHBOARD_ORIGIN=
|
||||||
|
# Production refuses public sign-up. Leave unset.
|
||||||
|
# EKKLESIA_PUBLIC_SIGNUP_ENABLED=
|
||||||
|
|
||||||
|
# --- Client address (ADR 0017) ------------------------------------------------------------------
|
||||||
|
# Set by compose.proxy.yaml. Only a base-only deployment behind its own proxy sets these here, with
|
||||||
|
# that proxy's fixed address as the only trusted entry (deploy/README.md):
|
||||||
|
# EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy
|
||||||
|
# EKKLESIA_CLIENT_ADDRESS_HEADER=x-forwarded-for
|
||||||
|
# EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES=<proxy address>/32
|
||||||
|
|
||||||
|
# --- Object storage (ADR 0030, ADR 0031) --------------------------------------------------------
|
||||||
|
# The API reaches SeaweedFS over HTTPS on the private network; production storage must be https.
|
||||||
|
S3_ENDPOINT=https://object-storage:8443
|
||||||
|
# The https origin browsers fetch signed object URLs from: the storage hostname on the proxy.
|
||||||
|
S3_DELIVERY_ENDPOINT=
|
||||||
|
# Must match object-storage.env.
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
S3_BUCKET_PREFIX=ekklesia
|
||||||
|
|
||||||
|
# --- Outbound email (ADR 0037), optional --------------------------------------------------------
|
||||||
|
# Without a relay, email verification and password recovery cannot deliver. See
|
||||||
|
# docs/development/email.md.
|
||||||
|
# EKKLESIA_SMTP_HOST=
|
||||||
|
# EKKLESIA_SMTP_PORT=587
|
||||||
|
# EKKLESIA_SMTP_SECURITY=starttls
|
||||||
|
# EKKLESIA_SMTP_USERNAME=
|
||||||
|
# EKKLESIA_SMTP_PASSWORD=
|
||||||
|
# EKKLESIA_SMTP_FROM_ADDRESS=
|
||||||
|
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||||
|
# EKKLESIA_SMTP_HELO_NAME=
|
||||||
|
|
||||||
|
# --- Module secrets (ADR 0029), as installed modules declare them --------------------------------
|
||||||
|
# EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
# SeaweedFS S3 credentials (`object-storage` only). Install as EKKLESIA_ENV_DIR/object-storage.env,
|
||||||
|
# owned by root with mode 600. Must match S3_ACCESS_KEY and S3_SECRET_KEY in ekklesia.env.
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# PostgreSQL superuser for the reference deployment (`postgres` only). Install as
|
||||||
|
# EKKLESIA_ENV_DIR/postgres.env, owned by root with mode 600. Read only on the database's first
|
||||||
|
# start; changing it later does not change the stored password.
|
||||||
|
#
|
||||||
|
# Must match the user, password, and database in ekklesia.env's DATABASE_URL.
|
||||||
|
POSTGRES_USER=ekklesia
|
||||||
|
POSTGRES_DB=ekklesia
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
# Cloudflare Tunnel connector token (`tunnel` only, compose.tunnel.yaml). Install as
|
||||||
|
# EKKLESIA_ENV_DIR/tunnel.env, owned by root with mode 600. Copy the token from the tunnel's page in
|
||||||
|
# the Cloudflare dashboard; it is the tunnel's credential.
|
||||||
|
TUNNEL_TOKEN=
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Host-based authentication for the reference deployment's PostgreSQL (ADR 0018, ADR 0044).
|
||||||
|
#
|
||||||
|
# Mounted read-only and selected with `-c hba_file=...` (deploy/compose.yaml), so it replaces the
|
||||||
|
# image's generated file entirely. Rules are matched top to bottom; the first match decides.
|
||||||
|
|
||||||
|
# TYPE DATABASE USER ADDRESS METHOD
|
||||||
|
|
||||||
|
# The container's own Unix socket: the image's first-start initialization and the health check.
|
||||||
|
# Nothing outside the container can reach it.
|
||||||
|
local all all trust
|
||||||
|
|
||||||
|
# Every network connection must be TLS (the server enforces TLS 1.3) and authenticate with SCRAM.
|
||||||
|
hostssl all all all scram-sha-256
|
||||||
|
|
||||||
|
# A network connection without TLS is refused outright, before authentication.
|
||||||
|
hostnossl all all all reject
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Prints freshly generated secrets for the reference deployment's env files (ADR 0044). Writes
|
||||||
|
# nothing: paste each line into the file named above it, then install that file root-owned with
|
||||||
|
# mode 600.
|
||||||
|
#
|
||||||
|
# sh deploy/scripts/gen-secrets.sh
|
||||||
|
#
|
||||||
|
# Every value is URL-safe (hexadecimal), so the database password needs no percent-encoding in
|
||||||
|
# DATABASE_URL.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
command -v openssl >/dev/null 2>&1 || {
|
||||||
|
echo "gen-secrets: openssl is required." >&2
|
||||||
|
exit 69
|
||||||
|
}
|
||||||
|
|
||||||
|
hex() {
|
||||||
|
openssl rand -hex "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
postgres_password=$(hex 24)
|
||||||
|
s3_access_key=$(hex 10)
|
||||||
|
s3_secret_key=$(hex 32)
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
# ekklesia.env
|
||||||
|
EKKLESIA_IDENTITY_SECRET=$(hex 32)
|
||||||
|
DATABASE_URL=postgresql://ekklesia:$postgres_password@postgres:5432/ekklesia
|
||||||
|
S3_ACCESS_KEY=$s3_access_key
|
||||||
|
S3_SECRET_KEY=$s3_secret_key
|
||||||
|
|
||||||
|
# postgres.env
|
||||||
|
POSTGRES_PASSWORD=$postgres_password
|
||||||
|
|
||||||
|
# object-storage.env
|
||||||
|
AWS_ACCESS_KEY_ID=$s3_access_key
|
||||||
|
AWS_SECRET_ACCESS_KEY=$s3_secret_key
|
||||||
|
EOF
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Creates the installation-private CA (once) and the two server certificates the reference
|
||||||
|
# deployment needs (ADR 0044): `postgres` for the database and `object-storage` for SeaweedFS.
|
||||||
|
#
|
||||||
|
# sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
|
||||||
|
#
|
||||||
|
# Idempotent. An existing key or certificate is never overwritten: delete a file deliberately to
|
||||||
|
# reissue it. Ownership and modes are re-applied on every run, which needs root; a non-root run
|
||||||
|
# creates what is missing and then stops before that step, saying so. No private key is ever
|
||||||
|
# printed.
|
||||||
|
#
|
||||||
|
# Output (in the directory given):
|
||||||
|
# ca.key the CA's private key. Root only. Needed only to issue certificates; keep a
|
||||||
|
# copy offline and consider removing it from the server afterwards.
|
||||||
|
# ca.crt the CA certificate. Public. Mounted into api, migrate, and proxy.
|
||||||
|
# postgres.crt/.key for the name `postgres`; the key is owned by PostgreSQL's uid (70).
|
||||||
|
# object-storage.crt/.key for the name `object-storage`; the key is owned by SeaweedFS's uid (1000).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: sh init-tls.sh <output directory>" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$#" -eq 1 ] || usage
|
||||||
|
out=$1
|
||||||
|
case "$out" in
|
||||||
|
-*) usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The uids the two images run their servers as: postgres:18.4-alpine3.24 (postgres) and
|
||||||
|
# chrislusf/seaweedfs:4.28 (seaweed). Each server refuses or fails to read a key it cannot own.
|
||||||
|
POSTGRES_UID=${EKKLESIA_POSTGRES_UID:-70}
|
||||||
|
SEAWEED_UID=${EKKLESIA_SEAWEED_UID:-1000}
|
||||||
|
|
||||||
|
CA_DAYS=3650
|
||||||
|
SERVER_DAYS=825
|
||||||
|
|
||||||
|
mkdir -p "$out"
|
||||||
|
chmod 700 "$out"
|
||||||
|
|
||||||
|
created=""
|
||||||
|
|
||||||
|
# Only generating needs openssl. A later root-only pass that applies ownership to files that already
|
||||||
|
# exist runs without it, in any image that has a POSIX shell.
|
||||||
|
require_openssl() {
|
||||||
|
command -v openssl >/dev/null 2>&1 || {
|
||||||
|
echo "init-tls: openssl is required to create keys and certificates." >&2
|
||||||
|
exit 69
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
note_created() {
|
||||||
|
created="$created $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A file this script would create already exists: keep it, and say so.
|
||||||
|
exists() {
|
||||||
|
if [ -e "$out/$1" ]; then
|
||||||
|
echo "init-tls: keeping existing $out/$1"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! exists ca.key; then
|
||||||
|
require_openssl
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$out/ca.key"
|
||||||
|
note_created ca.key
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! exists ca.crt; then
|
||||||
|
require_openssl
|
||||||
|
openssl req -x509 -new -sha256 -days "$CA_DAYS" -key "$out/ca.key" -out "$out/ca.crt" \
|
||||||
|
-subj "/CN=Ekklesia installation CA" \
|
||||||
|
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||||
|
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||||
|
-addext "subjectKeyIdentifier=hash"
|
||||||
|
note_created ca.crt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# issue <name>: a key and a certificate whose only subject alternative name is <name>, the Compose
|
||||||
|
# service name the client verifies against.
|
||||||
|
issue() {
|
||||||
|
name=$1
|
||||||
|
if ! exists "$name.key"; then
|
||||||
|
require_openssl
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$out/$name.key"
|
||||||
|
note_created "$name.key"
|
||||||
|
fi
|
||||||
|
if ! exists "$name.crt"; then
|
||||||
|
require_openssl
|
||||||
|
extensions=$(mktemp)
|
||||||
|
request=$(mktemp)
|
||||||
|
trap 'rm -f "$extensions" "$request"' EXIT
|
||||||
|
cat >"$extensions" <<EOF
|
||||||
|
basicConstraints=critical,CA:FALSE
|
||||||
|
keyUsage=critical,digitalSignature
|
||||||
|
extendedKeyUsage=serverAuth
|
||||||
|
subjectAltName=DNS:$name
|
||||||
|
subjectKeyIdentifier=hash
|
||||||
|
authorityKeyIdentifier=keyid
|
||||||
|
EOF
|
||||||
|
openssl req -new -sha256 -key "$out/$name.key" -subj "/CN=$name" -out "$request"
|
||||||
|
openssl x509 -req -sha256 -days "$SERVER_DAYS" -in "$request" \
|
||||||
|
-CA "$out/ca.crt" -CAkey "$out/ca.key" -CAcreateserial -CAserial "$out/ca.srl" \
|
||||||
|
-extfile "$extensions" -out "$out/$name.crt" 2>/dev/null
|
||||||
|
rm -f "$extensions" "$request"
|
||||||
|
trap - EXIT
|
||||||
|
note_created "$name.crt"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
issue postgres
|
||||||
|
issue object-storage
|
||||||
|
|
||||||
|
# Public material is world-readable; private keys are readable only by the process that needs them.
|
||||||
|
chmod 644 "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
|
||||||
|
chmod 600 "$out/ca.key" "$out/postgres.key" "$out/object-storage.key"
|
||||||
|
[ -e "$out/ca.srl" ] && chmod 600 "$out/ca.srl"
|
||||||
|
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
echo "init-tls: created$created in $out"
|
||||||
|
else
|
||||||
|
echo "init-tls: nothing to create in $out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "init-tls: ownership not applied: re-run as root to give postgres.key to uid $POSTGRES_UID and object-storage.key to uid $SEAWEED_UID." >&2
|
||||||
|
exit 77
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown root:root "$out" "$out/ca.key" "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
|
||||||
|
[ -e "$out/ca.srl" ] && chown root:root "$out/ca.srl"
|
||||||
|
chown "$POSTGRES_UID:$POSTGRES_UID" "$out/postgres.key"
|
||||||
|
chown "$SEAWEED_UID:$SEAWEED_UID" "$out/object-storage.key"
|
||||||
|
# The directory itself must be traversable by the two server uids for their bind mounts.
|
||||||
|
chmod 755 "$out"
|
||||||
|
|
||||||
|
echo "init-tls: postgres.key is owned by uid $POSTGRES_UID and object-storage.key by uid $SEAWEED_UID (mode 600)."
|
||||||
|
echo
|
||||||
|
echo "Set EKKLESIA_TLS_DIR=$out in deploy/.env, and put this line in ekklesia.env (the CA"
|
||||||
|
echo "certificate is public; this is not a secret):"
|
||||||
|
echo
|
||||||
|
printf 'EKKLESIA_DATABASE_CA="%s"\n' "$(awk 'BEGIN { ORS = "\\n" } { print }' "$out/ca.crt")"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources
|
# 0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Accepted
|
||||||
- Date: 2026-10-01
|
- Date: 2026-10-01
|
||||||
- Supersedes: none
|
- Supersedes: none
|
||||||
- Related: [ADR 0003](0003-development-infrastructure.md),
|
- Related: [ADR 0003](0003-development-infrastructure.md),
|
||||||
@@ -48,7 +48,7 @@ One host, one Compose project, four long-running services on a private network:
|
|||||||
|
|
||||||
| Service | Role |
|
| Service | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `proxy` | Caddy. Terminates TLS, serves the only published ports (443, 80 for redirect), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
|
| `proxy` | Caddy. Terminates TLS, serves the only published ports (443; and 80, which answers ACME HTTP-01 challenges and redirects everything else to HTTPS), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
|
||||||
| `api` | The `apps/api` image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only. |
|
| `api` | The `apps/api` image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only. |
|
||||||
| `postgres` | PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA. |
|
| `postgres` | PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA. |
|
||||||
| `object-storage` | SeaweedFS, the same image family as development, reachable by browsers only through the proxy. |
|
| `object-storage` | SeaweedFS, the same image family as development, reachable by browsers only through the proxy. |
|
||||||
@@ -65,21 +65,31 @@ server. The proxy never serves files from disk.
|
|||||||
### TLS everywhere, without weakening ADR 0018
|
### TLS everywhere, without weakening ADR 0018
|
||||||
|
|
||||||
- **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference
|
- **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference
|
||||||
configuration uses the HTTP-01 challenge; the DNS-01 challenge is documented for installations
|
configuration uses the HTTP-01 challenge on port 80; the DNS-01 challenge is documented for
|
||||||
that keep Ekklesia off the public Internet. A LAN-only installation can still hold a real
|
installations that keep Ekklesia off the public Internet. A LAN-only installation can still hold a
|
||||||
certificate for an internal hostname this way.
|
real certificate for an internal hostname this way. DNS-01 requires a Caddy build that includes a
|
||||||
|
DNS provider module, which the stock Caddy image does not; such an installation builds and pins
|
||||||
|
its own proxy image. A tunnel-only installation needs DNS-01 too: Cloudflare terminates TLS at its
|
||||||
|
edge, so an HTTP-01 challenge cannot reach the proxy through the tunnel.
|
||||||
- **Tunnel ingress (optional):** an installation may publish the proxy through an outbound tunnel
|
- **Tunnel ingress (optional):** an installation may publish the proxy through an outbound tunnel
|
||||||
(the reference documents `cloudflared`) instead of opening inbound ports. The tunnel connector
|
(the reference documents `cloudflared`) instead of opening inbound ports. The tunnel connector
|
||||||
is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's
|
is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's
|
||||||
certificate. The proxy accepts a tunnel-supplied client-address header **only** from the
|
certificate. With a token-managed tunnel, those origin settings (the service `https://proxy:443`,
|
||||||
connector's network identity. It overwrites the single forwarding header it sends to `api`, and
|
the origin server name, and TLS verification left on) live in the Cloudflare dashboard, not in the
|
||||||
`api` trusts only the proxy. An installation that serves local displays should give the same
|
repository; the deployment README documents them. The proxy accepts a tunnel-supplied
|
||||||
hostname a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
|
client-address header **only** from the connector's network identity. It overwrites the single
|
||||||
Internet link.
|
forwarding header it sends to `api`, and `api` trusts only the proxy. An installation that serves
|
||||||
|
local displays should give the same hostname a LAN path to the proxy (split-horizon DNS), so
|
||||||
|
displays do not depend on the Internet link.
|
||||||
- **Database:** the deployment's setup script creates an installation-private CA once, issues a
|
- **Database:** the deployment's setup script creates an installation-private CA once, issues a
|
||||||
server certificate for the `postgres` service name, and gives the API only the CA certificate
|
server certificate for the `postgres` service name, and gives the API only the CA certificate
|
||||||
(`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only
|
(`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only
|
||||||
exception is added to Core.
|
exception is added to Core.
|
||||||
|
- **Object storage:** production storage must be `https` (ADR 0030), so the API reaches SeaweedFS
|
||||||
|
over HTTPS on the private network as well. The same setup script issues a server certificate for
|
||||||
|
the `object-storage` service name from the same CA. Core's storage client trusts Node's default CA
|
||||||
|
set, so the API adds the installation CA to it with `NODE_EXTRA_CA_CERTS`. No storage TLS
|
||||||
|
exception is added to Core either.
|
||||||
|
|
||||||
### Object storage
|
### Object storage
|
||||||
|
|
||||||
@@ -134,8 +144,9 @@ an installation is treated as in production.
|
|||||||
|
|
||||||
### Secrets
|
### Secrets
|
||||||
|
|
||||||
Secrets are supplied to Compose from a root-owned environment file on the host, never committed.
|
Secrets are supplied to Compose from root-owned environment files on the host, never committed:
|
||||||
This includes `EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
|
one per service, so no container receives another service's credentials. They include
|
||||||
|
`EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
|
||||||
`EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required.
|
`EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 0045: The Dashboard and the API may live on separate origins
|
||||||
|
|
||||||
|
- Status: Proposed
|
||||||
|
- Date: 2026-10-03
|
||||||
|
- Amends: [0034](0034-host-owned-browser-authentication.md)
|
||||||
|
- Extends: [0044](0044-self-hosted-reference-deployment.md)
|
||||||
|
- Related: [ADR 0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||||
|
[ADR 0012](0012-identity-origin-csrf-protection-under-test.md),
|
||||||
|
[ADR 0018](0018-identity-session-resolution-and-database-transport.md),
|
||||||
|
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||||
|
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||||
|
[ADR 0038](0038-email-verification-and-password-recovery.md),
|
||||||
|
[ADR 0043](0043-display-offline-code-fallback.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0044's reference deployment serves everything a browser needs from one origin: the Dashboard at
|
||||||
|
`/`, the API at `/api/*`, and the display host at `/display/*`. ADR 0034 kept a deliberately split
|
||||||
|
browser/API origin possible through the exact trusted-origin allowlist and credentialed CORS of
|
||||||
|
ADR 0011, ADR 0012, and ADR 0018, but left that topology undesigned: "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."
|
||||||
|
|
||||||
|
The first production installation needs exactly that: the Dashboard and the API on sibling
|
||||||
|
hostnames (for example `dash.example.org` and `api.example.org`), plus the existing storage
|
||||||
|
hostname. The choice has to be made before the first deployment, because a display's bookmark and a
|
||||||
|
paired device's cookies are bound to a hostname: moving them later re-pairs every screen.
|
||||||
|
|
||||||
|
Most of the platform was already written for this. The shell sends every platform and module request
|
||||||
|
with `credentials: "include"` and opens realtime with `withCredentials: true`; CORS on `/api/auth/*`
|
||||||
|
and `/api/v1/*` reflects only exact trusted origins with credentials; module artifacts are anonymous
|
||||||
|
by design so that cross-origin `import()` works (ADR 0025); emailed links already pass through the
|
||||||
|
API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038); and display page URLs are resolved against the
|
||||||
|
API's origin. Four things were not:
|
||||||
|
|
||||||
|
- the Dashboard learns its API origin at **build** time (`EKKLESIA_API_BASE_URL`), so one image
|
||||||
|
cannot serve both topologies;
|
||||||
|
- the API's own set of trusted browser origins is exactly `EKKLESIA_IDENTITY_TRUSTED_ORIGINS`, while
|
||||||
|
Better Auth implicitly adds the identity base URL to its own: a page served by the API itself would
|
||||||
|
fail the API's origin check unless an operator listed the API's own origin too;
|
||||||
|
- the reference proxy has no routing for two browser-facing hostnames;
|
||||||
|
- nothing proves a split deployment end to end, and a Node.js client cannot prove what matters most
|
||||||
|
here: what a real browser sends across the two origins.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Two topologies, one image
|
||||||
|
|
||||||
|
An installation runs in exactly one of two shapes, chosen by configuration alone:
|
||||||
|
|
||||||
|
| Topology | Dashboard | API and display host | Storage |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Same-origin** (default) | `https://ekklesia.example.org/` | `https://ekklesia.example.org/api/*`, `/display/*` | its own hostname |
|
||||||
|
| **Split** | `https://dash.example.org/` | `https://api.example.org/api/*`, `/display/*` | its own hostname |
|
||||||
|
|
||||||
|
Same-origin remains the default and keeps working unchanged for every installation that does not
|
||||||
|
opt in. The same image and the same built Dashboard serve both.
|
||||||
|
|
||||||
|
In both, the API process serves the Dashboard's bytes (ADR 0044 is unchanged in that). In the split
|
||||||
|
topology the proxy forwards the Dashboard hostname's two Dashboard paths to the same `api` service;
|
||||||
|
the API does not inspect `Host`, and which hostname serves which path is the proxy's job, as every
|
||||||
|
other perimeter rule already is.
|
||||||
|
|
||||||
|
### The Dashboard learns its API origin at run time
|
||||||
|
|
||||||
|
The API writes its own origin into the Dashboard page it serves, as one meta element:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="ekklesia-api-origin" content="https://api.example.org" />
|
||||||
|
```
|
||||||
|
|
||||||
|
The value is the identity base URL's origin (`EKKLESIA_IDENTITY_BASE_URL`), which is by definition
|
||||||
|
where `/api` lives; no new setting is introduced. The built page carries the element with an empty
|
||||||
|
value; the API fills it once at startup, before computing the page's strong `ETag`, and refuses to
|
||||||
|
start if the built page does not contain exactly one such element. The shell reads it before
|
||||||
|
anything renders, accepts only an absolute `http(s)` origin with no path, and otherwise fails closed
|
||||||
|
with its existing sanitized bootstrap failure. An empty value (a static development server with no
|
||||||
|
API) means the page's own origin.
|
||||||
|
|
||||||
|
A meta element rather than an inline script keeps the page free of inline script, so a future strict
|
||||||
|
`script-src` needs no exception. The build-time `EKKLESIA_API_BASE_URL` is removed; the development
|
||||||
|
server injects the same element instead. The display host needs neither: it is always served from
|
||||||
|
the API's origin (below) and uses its own.
|
||||||
|
|
||||||
|
### The display host lives on the API's origin
|
||||||
|
|
||||||
|
In the split topology the display host stays where it is: `https://api.example.org/display/<module>`,
|
||||||
|
with its script under `/assets/ekklesia-display/` and its service worker at `/display/sw.js`. A
|
||||||
|
screen's bookmark names the API hostname.
|
||||||
|
|
||||||
|
- **Device cookies are already bound to the API's hostname.** They are host-only, `HttpOnly`, and
|
||||||
|
`SameSite=Strict` (ADR 0036), and they must accompany device requests to the API, so they live on
|
||||||
|
whichever hostname answers those requests. Serving the display page from that same origin makes it
|
||||||
|
the only hostname a screen depends on; serving it anywhere else adds a second one.
|
||||||
|
- **Offline code fallback is unchanged.** The display's service worker never caches a cross-origin
|
||||||
|
response (ADR 0043), which is what keeps signed storage URLs, bearer capabilities, out of the
|
||||||
|
cache. On the API's origin, every artifact it must cache is same-origin. On any other origin, every
|
||||||
|
artifact would be cross-origin, and the offline guarantee would need a weaker rule.
|
||||||
|
- **No device request crosses an origin.** Pairing, heartbeats, descriptors, module routes, and
|
||||||
|
realtime from an unattended screen stay same-origin, with no CORS dependence.
|
||||||
|
|
||||||
|
The Dashboard keeps linking to display pages exactly as now: their URLs are resolved against the API
|
||||||
|
origin.
|
||||||
|
|
||||||
|
### The API trusts its own origin
|
||||||
|
|
||||||
|
The API's set of trusted browser origins, used for CORS on `/api/auth/*` and `/api/v1/*` and by every
|
||||||
|
mutation route's origin check, becomes `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` **plus the identity base
|
||||||
|
URL's origin**, matching what Better Auth already does for its own checks. In the split topology the
|
||||||
|
display page on the API's origin therefore passes the API's origin check without an operator having
|
||||||
|
to list it. Trusted origins remain exact: no wildcard, no pattern, and no parent domain.
|
||||||
|
|
||||||
|
### Cookies and identity: unchanged, with one stated requirement
|
||||||
|
|
||||||
|
The session cookie keeps every property ADR 0034 gave it: `HttpOnly`, `SameSite=Lax`, `Secure` under
|
||||||
|
HTTPS, `Path=/`, and **host-only on the API's hostname**. No `Domain` attribute is set, and the
|
||||||
|
Dashboard's hostname never holds a session credential. The Dashboard reaches the API with credentialed
|
||||||
|
CORS, and browsers send the cookie because the two hostnames are different origins of the **same
|
||||||
|
site**.
|
||||||
|
|
||||||
|
That is a requirement of the split topology: the Dashboard and API hostnames must share a registrable
|
||||||
|
domain (`dash.example.org` and `api.example.org` do; `dash.example.org` and `api.example.net` do not).
|
||||||
|
Across sites, browsers withhold `SameSite=Lax` cookies from cross-origin requests and may block or
|
||||||
|
partition third-party cookies altogether. The documentation states it, and Doctor reports the
|
||||||
|
topology and warns when the two hostnames are not siblings under one parent domain, because Doctor
|
||||||
|
cannot see the Public Suffix List and says so.
|
||||||
|
|
||||||
|
In the split topology `EKKLESIA_IDENTITY_BASE_URL` is the API's origin, `EKKLESIA_DASHBOARD_ORIGIN` is
|
||||||
|
the Dashboard's, and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` lists the Dashboard's. Email verification and
|
||||||
|
password reset links already point at the API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038);
|
||||||
|
nothing about them changes.
|
||||||
|
|
||||||
|
### CORS stays narrow, and that limit is part of the module contract
|
||||||
|
|
||||||
|
Credentialed CORS keeps its current shape: exact reflected origin, `Access-Control-Allow-Credentials:
|
||||||
|
true`, the existing method list, `Content-Type` as the only allowed request header beyond the
|
||||||
|
CORS-safelisted ones, and no exposed response headers beyond the safelisted ones. The API does not
|
||||||
|
reflect arbitrary requested headers.
|
||||||
|
|
||||||
|
A module UI's requests go through the host's `api.request` and inherit this policy. The module UI
|
||||||
|
contract therefore states it: portable module UI code uses only CORS-safelisted request headers plus
|
||||||
|
`Content-Type`, and reads only CORS-safelisted response headers. That is already true of every
|
||||||
|
existing module, and it keeps one module artifact working under either topology. Widening it for a
|
||||||
|
real module need is a later, explicit change.
|
||||||
|
|
||||||
|
### Routing per hostname
|
||||||
|
|
||||||
|
The reference proxy gains a second configuration for the split topology, beside the existing one:
|
||||||
|
|
||||||
|
| Hostname | Forwarded to `api` | Refused with `404` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dashboard | `/` (emailed links add only a query) and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||||
|
| API | `/api/*` (realtime flushed per event, as now), `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` and `/assets/ekklesia-dashboard/*` |
|
||||||
|
| Storage | unchanged (ADR 0031) | unchanged |
|
||||||
|
|
||||||
|
The Dashboard hostname refuses `/api/*` so that the session cookie has exactly one home: a Dashboard
|
||||||
|
cannot fall back to a same-origin API on its own hostname. The API hostname answers nothing at `/`.
|
||||||
|
Both hostnames keep every forwarding-header rule ADR 0044 established (one overwritten
|
||||||
|
`X-Forwarded-For`, the stripped client-chosen headers, and tunnel trust only from the connector's
|
||||||
|
address). The proxy overlay takes the Dashboard hostname from one more setting, which the split
|
||||||
|
configuration requires and the same-origin configuration ignores.
|
||||||
|
|
||||||
|
### What a future Content-Security-Policy must allow
|
||||||
|
|
||||||
|
This decision does not add a Content-Security-Policy; the Dashboard page keeps `frame-ancestors
|
||||||
|
'none'` and its other headers. When a full policy is designed, the split topology requires the
|
||||||
|
Dashboard's to allow the API origin in `script-src` (module artifacts and contributions are imported
|
||||||
|
from it) and `connect-src` (platform and module requests, realtime), and the storage origin in
|
||||||
|
`img-src`. The display page, being same-origin with the API, needs only its own origin and the
|
||||||
|
storage origin.
|
||||||
|
|
||||||
|
### Both topologies are proven end to end
|
||||||
|
|
||||||
|
The deployment smoke test runs in either topology, and CI runs both. The split run proves, through
|
||||||
|
the proxy, the routing table above on every hostname, the API origin in the Dashboard page, exact
|
||||||
|
CORS reflection with credentials for the Dashboard origin and none for an untrusted origin, and the
|
||||||
|
host-only session cookie.
|
||||||
|
|
||||||
|
Because those are browser behaviors, the split run also drives a **real browser**: a headless
|
||||||
|
Chromium from an image pinned by digest, controlled over the Chrome DevTools Protocol by the
|
||||||
|
smoke-test client with no added dependency. In it, a person signs in through the real form on the
|
||||||
|
Dashboard hostname, the session cookie is present only for the API hostname, the Dashboard imports a
|
||||||
|
module's UI from the API hostname and the module's own credentialed requests succeed under CORS, no
|
||||||
|
request is refused, the module's display page mounts on the API hostname with its service worker, and
|
||||||
|
sign-out ends the session. The split run installs the repository's module template as a test-only
|
||||||
|
module so these paths exist; installations' own smoke tests cover their real modules. Contribution
|
||||||
|
artifacts are imported exactly as UI artifacts are, and the API's tests cover their CORS headers and
|
||||||
|
those of realtime streams.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- One image serves both topologies; an installation chooses with configuration and the matching proxy
|
||||||
|
configuration.
|
||||||
|
- Same-origin installations change nothing.
|
||||||
|
- A split installation depends on exactly two browser-facing hostnames plus storage. Screens and
|
||||||
|
paired devices depend only on the API hostname, which therefore must not change once screens are
|
||||||
|
paired.
|
||||||
|
- No credential is broadened: the session cookie stays host-only on the API hostname, device cookies
|
||||||
|
stay host-only and strict, and trusted origins stay exact.
|
||||||
|
- Module UI code is held to CORS-safe headers in both topologies, so a module that works in one
|
||||||
|
works in the other.
|
||||||
|
- CI gains a second deployment smoke run and a browser in it.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **A parent-domain session cookie (`Domain=example.org`).** Rejected, as ADR 0034 already rejected
|
||||||
|
it: it sends the credential to every sibling host and is unnecessary between same-site origins.
|
||||||
|
- **The display host on the Dashboard hostname, or on its own hostname.** Rejected: device cookies
|
||||||
|
are bound to the API's hostname regardless, so either adds a second hostname every screen depends
|
||||||
|
on, puts every device request behind CORS, and breaks ADR 0043's same-origin-only offline cache.
|
||||||
|
- **The API serving the display host and the device API on a second hostname.** Rejected: two API
|
||||||
|
origins for one identity base URL, with the same cookies split between them.
|
||||||
|
- **Inject the API origin with an inline script, or a configuration request.** Rejected: an inline
|
||||||
|
script needs a CSP exception, and a request is a round trip and a failure mode before the first
|
||||||
|
render; a meta element is static and inert.
|
||||||
|
- **Keep the API origin a build-time setting.** Rejected: the image would have to be built per
|
||||||
|
installation and per topology.
|
||||||
|
- **Require operators to list the API's own origin in the trusted origins.** Rejected: an omission
|
||||||
|
silently breaks every display mutation; Better Auth already trusts its own base URL.
|
||||||
|
- **Reflect any requested CORS header.** Rejected for now: no module needs it, and a module that
|
||||||
|
relied on it would be tied to its host's CORS policy.
|
||||||
|
- **Prove split mode with HTTP checks only.** Rejected: whether a browser sends the cookie, imports
|
||||||
|
a cross-origin module, and opens a credentialed stream is exactly what is at stake.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- A full Content-Security-Policy, with the requirements above.
|
||||||
|
- A broader CORS header policy, when a module needs one.
|
||||||
|
- More than one Dashboard origin per installation.
|
||||||
|
- Moving an installation between topologies, and the re-pairing that moving the API hostname
|
||||||
|
implies.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# 0046: Ekklesia's branding is never forced on an installation
|
||||||
|
|
||||||
|
- Status: Proposed
|
||||||
|
- Date: 2026-10-03
|
||||||
|
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0034](0034-host-owned-browser-authentication.md),
|
||||||
|
[ADR 0037](0037-host-provided-outbound-email.md),
|
||||||
|
[ADR 0038](0038-email-verification-and-password-recovery.md),
|
||||||
|
[ADR 0039](0039-shared-design-tokens-and-elements.md),
|
||||||
|
[ADR 0043](0043-display-offline-code-fallback.md),
|
||||||
|
[ADR 0045](0045-split-dashboard-and-api-origins.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Ekklesia is software a church runs for its own people. The people who use an installation (members,
|
||||||
|
visitors, volunteers running a screen) know the church, not the software behind it, and the church
|
||||||
|
decides how it presents itself to them. Today the platform names itself in places those people see:
|
||||||
|
the sign-in screen asks them to "Sign in to Ekklesia", the Dashboard's heading and page title read
|
||||||
|
"Ekklesia", emails ask them to verify "your Ekklesia email address", and an offline screen in the
|
||||||
|
sanctuary says it is "waiting for Ekklesia".
|
||||||
|
|
||||||
|
None of those were decided; they accumulated. This records the rule they should have followed, so
|
||||||
|
every later screen, message, and module is held to it.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### The principle
|
||||||
|
|
||||||
|
**Ekklesia's branding is never forced on an installation.** Anything a member, visitor, or volunteer
|
||||||
|
can see is configurable by the installation and removable entirely. That includes, and is not
|
||||||
|
limited to:
|
||||||
|
|
||||||
|
- page titles, headings, and the name of the application in a browser tab, an installed web app, or
|
||||||
|
a bookmark;
|
||||||
|
- sign-in, verification, recovery, and every other authentication screen;
|
||||||
|
- emails: the sender's display name, the subject, and the body;
|
||||||
|
- display screens, pairing screens, and offline and failure screens on a display;
|
||||||
|
- error pages and error messages a person reads;
|
||||||
|
- application names and icons, and any "powered by" or attribution text.
|
||||||
|
|
||||||
|
"Removable entirely" means an installation can show its own name, or no product name at all, and in
|
||||||
|
either case no screen or message a person sees names Ekklesia.
|
||||||
|
|
||||||
|
### What is exempt
|
||||||
|
|
||||||
|
Internal identifiers are not branding and stay as they are: package names, module ids, URL paths
|
||||||
|
(`/api/v1/…`, `/assets/ekklesia-dashboard/…`), HTTP header names, environment variable names, cookie
|
||||||
|
names, CSS custom property names, element ids and `data-` attributes, storage, cache, and database
|
||||||
|
names, and log field names. A person can find them only by opening developer tools or reading
|
||||||
|
configuration, and renaming them would break installations for no one's benefit.
|
||||||
|
|
||||||
|
Tooling an operator runs to administer the installation (the command-line tools, Doctor, server logs,
|
||||||
|
and startup messages) names the software being operated, as a package name does, and is exempt too.
|
||||||
|
So are messages written only for developers: thrown errors that reach a console and never a page, and
|
||||||
|
build-time diagnostics for module authors.
|
||||||
|
|
||||||
|
### How installations configure it
|
||||||
|
|
||||||
|
The platform takes the installation's name from configuration, once, and uses it everywhere a
|
||||||
|
person-facing string would otherwise have said "Ekklesia". When no name is configured, the wording is
|
||||||
|
neutral ("Sign in", "Verify your email address", "Waiting for the server") rather than falling back to
|
||||||
|
"Ekklesia". The default installation therefore names nothing, which is what makes the principle
|
||||||
|
checkable: a test can render every person-facing screen and message with no name configured and fail
|
||||||
|
on any occurrence of "Ekklesia".
|
||||||
|
|
||||||
|
Browser pages receive the name the way they receive the API origin (ADR 0045): the API renders it
|
||||||
|
into the page it serves at startup, so the image stays generic and nothing is decided at build time.
|
||||||
|
An email's sender display name is the existing `EKKLESIA_SMTP_FROM_NAME` (ADR 0037); its subject and
|
||||||
|
body use the same installation name.
|
||||||
|
|
||||||
|
Visual identity follows the same rule. The platform ships no logo, favicon, or icon today; any it
|
||||||
|
ships later must be replaceable by the installation and removable entirely, and must not appear by
|
||||||
|
default. Colours and type are the installation's theme (ADR 0039), not a mark.
|
||||||
|
|
||||||
|
### It applies to modules
|
||||||
|
|
||||||
|
A module's user-visible text follows the same principle. A module that names its host ("Open the
|
||||||
|
Ekklesia Dashboard") also ties itself to one host, which ADR 0021 already asks modules not to do.
|
||||||
|
Modules refer to the host generically ("the Dashboard") or not at all.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every person-facing string that names the platform today becomes either the configured name or
|
||||||
|
neutral wording. An implementation slice makes that change and adds the test that keeps it true.
|
||||||
|
- New screens, emails, and modules are reviewed against this rule; "Ekklesia" in a person-facing
|
||||||
|
string is a defect.
|
||||||
|
- Internal identifiers, operator tooling, and developer diagnostics keep the name, so nothing
|
||||||
|
operational is renamed.
|
||||||
|
- An installation that wants to credit Ekklesia can do so in its own words; the platform never
|
||||||
|
inserts that credit.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep "Ekklesia" as the default and let installations override it.** Rejected: an installation
|
||||||
|
that never thought to configure it would show the software's name to its people, which is forcing
|
||||||
|
it by default, and "no occurrence of Ekklesia" could not be tested in the default state.
|
||||||
|
- **Rename internal identifiers too.** Rejected: invisible to the people this protects, and costly
|
||||||
|
to every installation and module.
|
||||||
|
- **A configurable "powered by" line, on by default.** Rejected for the same reason as the first
|
||||||
|
alternative.
|
||||||
|
- **Per-string customization of every message.** Not needed for this principle: one installation
|
||||||
|
name and neutral wording remove the product's name everywhere. Richer copy customization, if
|
||||||
|
wanted, is separate work.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- The implementation: the setting, its delivery to pages and emails, the rewording, and the
|
||||||
|
regression test.
|
||||||
|
- Installation-supplied logos, icons, and favicons, and a web app manifest.
|
||||||
|
- Translation and per-string copy customization.
|
||||||
@@ -52,4 +52,6 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
||||||
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
||||||
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
||||||
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Proposed
|
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
|
||||||
|
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Proposed
|
||||||
|
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
|
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
|
||||||
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
|
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
|
||||||
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
|
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
|
||||||
it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
|
it and React — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
|
||||||
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
|
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
|
||||||
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
|
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
|
||||||
layer, an API/auth client, or the backend contracts barrel, because a design package that could
|
layer, an API/auth client, or the backend contracts barrel, because a design package that could
|
||||||
@@ -134,6 +134,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
- Workspace package consumers use declared `exports`, not private subpaths.
|
- Workspace package consumers use declared `exports`, not private subpaths.
|
||||||
- Every internal dependency uses a `workspace:` range and every imported workspace package is
|
- Every internal dependency uses a `workspace:` range and every imported workspace package is
|
||||||
declared by the consumer.
|
declared by the consumer.
|
||||||
|
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
|
||||||
|
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
|
||||||
|
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
|
||||||
|
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
|
||||||
|
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
|
||||||
|
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
|
||||||
|
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
||||||
|
(`runtime-imports-are-production-dependencies`).
|
||||||
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
||||||
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
||||||
- Circular source and workspace-package dependencies are rejected.
|
- Circular source and workspace-package dependencies are rejected.
|
||||||
|
|||||||
+112
-3
@@ -77,6 +77,10 @@ No real values are shown here — configure per environment and never commit the
|
|||||||
| `/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. |
|
| `/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`. |
|
| `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 /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), served from the same origin as `/api`. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||||||
|
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||||
|
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||||
|
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||||
| `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). |
|
||||||
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
||||||
@@ -134,6 +138,65 @@ a new raw token when asked to revoke other sessions. The principal response neve
|
|||||||
contains email, name, image, session id or expiry, session token, account/provider details, or any
|
contains email, name, image, session id or expiry, session token, account/provider details, or any
|
||||||
role, permission, membership, or volunteer data.
|
role, permission, membership, or volunteer data.
|
||||||
|
|
||||||
|
## Dashboard host
|
||||||
|
|
||||||
|
`apps/api` serves the built Dashboard itself, from the same origin as `/api`
|
||||||
|
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). That keeps the
|
||||||
|
identity system's default same-origin shape: `EKKLESIA_DASHBOARD_ORIGIN` stays unset, the session
|
||||||
|
cookie stays host-only, and no second static server or proxy file root is needed. It works exactly
|
||||||
|
like the display host:
|
||||||
|
|
||||||
|
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
|
||||||
|
exact URLs of two built files, `dist/browser/index.html` and
|
||||||
|
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
|
||||||
|
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
|
||||||
|
strong `ETag` for each.
|
||||||
|
- **A missing build fails startup.** An absent, unreadable, or empty file stops `startApi()` with a
|
||||||
|
fixed `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||||||
|
already opened. It never carries a path or the filesystem error. Build the shell first;
|
||||||
|
`pnpm api:start` already runs the full build.
|
||||||
|
- **Two exact routes, never a directory.** `GET /` answers the page and
|
||||||
|
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
|
||||||
|
nothing touches the filesystem while answering. Every other path, including `/index.html`,
|
||||||
|
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
|
||||||
|
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
|
||||||
|
router, and emailed links land on `/` with a query (see
|
||||||
|
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
|
||||||
|
implicit `HEAD`) are also the ordinary `404`.
|
||||||
|
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
|
||||||
|
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||||||
|
a `404` for it.
|
||||||
|
|
||||||
|
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
|
||||||
|
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
|
||||||
|
`/api/v1/principal` says otherwise.
|
||||||
|
|
||||||
|
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
|
||||||
|
| `Cache-Control` | `no-cache` | `no-cache` |
|
||||||
|
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
|
||||||
|
| `X-Frame-Options` | `DENY` | — |
|
||||||
|
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
|
||||||
|
| `Referrer-Policy` | `no-referrer` | — |
|
||||||
|
|
||||||
|
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
|
||||||
|
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
|
||||||
|
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
|
||||||
|
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
|
||||||
|
keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load.
|
||||||
|
|
||||||
|
**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed,
|
||||||
|
through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries:
|
||||||
|
the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of
|
||||||
|
every request the page makes, which matters while an emailed reset token is briefly in it. The shell
|
||||||
|
strips that token from the address bar before it renders anything.
|
||||||
|
|
||||||
|
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
|
||||||
|
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
|
||||||
|
that path absolutely, so the page never depends on the URL that served it.
|
||||||
|
|
||||||
## Dashboard browser authentication
|
## Dashboard browser authentication
|
||||||
|
|
||||||
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
|
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
|
||||||
@@ -1420,6 +1483,51 @@ TLS protects the *connection*, not the exposure: it does not make an internet-re
|
|||||||
safe, and the network boundary remains the deployment's own responsibility. See
|
safe, and the network boundary remains the deployment's own responsibility. 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).
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
Two unauthenticated endpoints exist for Compose health checks and external monitoring
|
||||||
|
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). They are
|
||||||
|
deliberately uninformative: neither names a module, version, dependency, error, or connection
|
||||||
|
detail, and `pnpm doctor` remains the detailed operator report.
|
||||||
|
|
||||||
|
| Request | Answer |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/v1/health/live` | Always `200 {"status":"live"}`. Liveness consults nothing; if the process can answer, it is live. |
|
||||||
|
| `GET /api/v1/health/ready`, ready | `200 {"status":"ready"}` |
|
||||||
|
| `GET /api/v1/health/ready`, not ready | `503 {"code":"NOT_READY"}` |
|
||||||
|
| Any other method on either path, including `HEAD` | `405` with `Allow: GET` and no body |
|
||||||
|
|
||||||
|
Every answer sets `Cache-Control: no-store`. Neither route resolves a principal, reads a cookie, or
|
||||||
|
evaluates authorization. Both live under `/api/v1/health/`, a sibling of the `/api/v1/modules/`
|
||||||
|
prefix, so no module route can shadow them, including a module whose id is `health`. As for every
|
||||||
|
`/api/v1/*` route, an `OPTIONS` request is answered by the platform's CORS preflight and never
|
||||||
|
reaches the handlers.
|
||||||
|
|
||||||
|
Readiness is `200` only when **both** of the following hold:
|
||||||
|
|
||||||
|
- **Module startup completed, and shutdown has not begun.** `startApi()` binds the HTTP listener
|
||||||
|
only after the module runtime has started, so this holds whenever the server is answering. The
|
||||||
|
first `shutdown()` call clears it before anything drains, so a draining instance reports `503`
|
||||||
|
while liveness still reports `200`. It is checked again after the database ping, in case shutdown
|
||||||
|
began meanwhile.
|
||||||
|
- **The database answered a trivial query within 2 seconds** (`READINESS_DATABASE_TIMEOUT_MS`).
|
||||||
|
The ping runs on the one shared pool as a single message,
|
||||||
|
`BEGIN READ ONLY; SET LOCAL statement_timeout = 2000; SELECT 1; COMMIT`. The transaction-local
|
||||||
|
timeout means PostgreSQL cancels a stalled statement itself rather than leaving it running. On
|
||||||
|
any failure the pool discards that connection instead of reusing it. The pool's own 10-second
|
||||||
|
connection, query, and statement timeouts remain the outer bound, for example for a database
|
||||||
|
that cannot be reached at all.
|
||||||
|
|
||||||
|
**Pings never pile up.** At most one ping is ever unsettled. Probes that arrive while it runs share
|
||||||
|
its result and its single 2-second deadline. Once the deadline passes they answer `503` immediately,
|
||||||
|
without starting another query, until that ping has actually settled. A stalled database therefore
|
||||||
|
holds at most one pool connection on behalf of health checks, however often a monitor polls. A
|
||||||
|
successful result is never cached: every probe after a settled ping runs a new one.
|
||||||
|
|
||||||
|
A failed ping is never inspected. The response is the same fixed `503` whether the database
|
||||||
|
refused the connection, timed out, rejected the credentials, or was mid-restart, and nothing about
|
||||||
|
the failure is logged by the route.
|
||||||
|
|
||||||
## Graceful shutdown
|
## Graceful shutdown
|
||||||
|
|
||||||
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||||||
@@ -1504,7 +1612,8 @@ 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. Surface work is underway; production
|
for what has landed across every phase and what remains. Surface work is underway; the API now
|
||||||
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
|
serves the built Dashboard ([Dashboard host](#dashboard-host)), while its production proxy, TLS,
|
||||||
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
full Content-Security-Policy, 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.
|
database-composition contracts as they evolve.
|
||||||
|
|||||||
@@ -463,10 +463,13 @@ Until that exists, the trusted-code model above is the security model.
|
|||||||
|
|
||||||
## Current deployment state
|
## Current deployment state
|
||||||
|
|
||||||
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
|
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no running
|
||||||
proxy, no public listener, and no deployment automation of any kind in this repository — no deploy
|
reverse proxy, no public listener, and no deployment automation in this repository — no deploy
|
||||||
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever
|
script, no CI deployment step, no container publish. The repository provides a **reference deployment**
|
||||||
been committed; only `.env.example`.
|
([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which
|
||||||
|
CI builds and smoke-tests end to end on every change against disposable services. Nothing pushes,
|
||||||
|
publishes, or runs it against a real installation. No `.env` has ever been committed; only
|
||||||
|
`.env.example`, which the image build context excludes.
|
||||||
|
|
||||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||||
|
|
||||||
@@ -486,9 +489,11 @@ 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 serves the built Dashboard assets and `/api` on the intended origin, or an
|
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
|
||||||
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
|
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
|
||||||
repository currently supplies neither deployment.
|
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
|
||||||
|
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
|
||||||
|
maps every item here to its configuration).
|
||||||
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.
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Open, modular church technology platform",
|
"description": "Open, modular church technology platform",
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
|
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
|
||||||
"boundaries": "node scripts/check-boundaries.ts",
|
"boundaries": "node scripts/check-boundaries.ts",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"ci:verify": "pnpm verify && pnpm infrastructure:config",
|
"ci:verify": "pnpm verify && pnpm infrastructure:config && pnpm deploy:config",
|
||||||
|
"deploy:config": "bash scripts/check-deploy-config.sh",
|
||||||
|
"deploy:smoke": "bash scripts/smoke-deploy.sh",
|
||||||
"doctor": "node scripts/run-doctor.ts",
|
"doctor": "node scripts/run-doctor.ts",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format .",
|
"format:check": "biome format .",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/design-tokens",
|
"name": "@ekklesia/design-tokens",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-sdk",
|
"name": "@ekklesia/module-sdk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
@@ -19,12 +19,12 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/contracts": "workspace:*"
|
"@ekklesia/contracts": "workspace:*",
|
||||||
|
"drizzle-orm": "0.45.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"drizzle-orm": "0.45.2",
|
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-build",
|
"name": "@ekklesia/module-ui-build",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"bin",
|
"bin",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-host",
|
"name": "@ekklesia/module-ui-host",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-sdk",
|
"name": "@ekklesia/module-ui-sdk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/typescript-config",
|
"name": "@ekklesia/typescript-config",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/ui",
|
"name": "@ekklesia/ui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/design-tokens": "workspace:*"
|
"@ekklesia/design-tokens": "workspace:*",
|
||||||
|
"react": "19.2.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "19.2.8"
|
"react": "19.2.8"
|
||||||
@@ -31,7 +32,6 @@
|
|||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.2.4",
|
||||||
"happy-dom": "20.0.11",
|
"happy-dom": "20.0.11",
|
||||||
"react": "19.2.8",
|
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ describe("the shared elements consume design tokens, never hardcoded theme value
|
|||||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||||
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
||||||
|
|
||||||
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens"]);
|
// React is both a production dependency and a peer: the peer states that the consumer's React
|
||||||
|
// must be the same one, and the production declaration is what makes the package's own imports
|
||||||
|
// resolve, since a consumer never satisfies a linked workspace package's peer
|
||||||
|
// (`runtime-imports-are-production-dependencies`).
|
||||||
|
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens", "react"]);
|
||||||
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]);
|
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Generated
+6
-6
@@ -201,6 +201,9 @@ importers:
|
|||||||
'@ekklesia/contracts':
|
'@ekklesia/contracts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../contracts
|
version: link:../contracts
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: 0.45.2
|
||||||
|
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.3
|
specifier: 2.5.3
|
||||||
@@ -208,9 +211,6 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.13.3
|
specifier: 24.13.3
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
drizzle-orm:
|
|
||||||
specifier: 0.45.2
|
|
||||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 7.0.2
|
specifier: 7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
@@ -287,6 +287,9 @@ importers:
|
|||||||
'@ekklesia/design-tokens':
|
'@ekklesia/design-tokens':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../design-tokens
|
version: link:../design-tokens
|
||||||
|
react:
|
||||||
|
specifier: 19.2.8
|
||||||
|
version: 19.2.8
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.3
|
specifier: 2.5.3
|
||||||
@@ -306,9 +309,6 @@ importers:
|
|||||||
happy-dom:
|
happy-dom:
|
||||||
specifier: 20.0.11
|
specifier: 20.0.11
|
||||||
version: 20.0.11
|
version: 20.0.11
|
||||||
react:
|
|
||||||
specifier: 19.2.8
|
|
||||||
version: 19.2.8
|
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.8
|
specifier: 19.2.8
|
||||||
version: 19.2.8([email protected])
|
version: 19.2.8([email protected])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readdir, readFile, stat } from "node:fs/promises";
|
import { readdir, readFile, stat } from "node:fs/promises";
|
||||||
|
import { builtinModules } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
@@ -506,6 +507,82 @@ function collectImportSpecifiers(sourceText: string): string[] {
|
|||||||
return [...specifiers];
|
return [...specifiers];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a file is code a workspace package ships and executes: anything under its `src/` or
|
||||||
|
* `bin/`, except tests and declaration files. Build scripts, configuration files, and tests run with
|
||||||
|
* development dependencies installed, so they are outside this rule.
|
||||||
|
*/
|
||||||
|
function isRuntimeSourceFile(packageRelativePath: string): boolean {
|
||||||
|
const normalized = normalizePath(packageRelativePath);
|
||||||
|
if (!normalized.startsWith("src/") && !normalized.startsWith("bin/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const segments = normalized.split("/");
|
||||||
|
if (segments.some((segment) => ["__tests__", "test", "tests"].includes(segment))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const fileName = segments.at(-1) ?? "";
|
||||||
|
return !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(fileName) && !/\.d\.[cm]?ts$/u.test(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `import { type A, type B } from "x"` has no runtime binding: without `verbatimModuleSyntax`, the
|
||||||
|
* compiler erases it exactly like `import type`. A default or namespace binding, or any specifier
|
||||||
|
* without `type`, is a runtime import. */
|
||||||
|
function importClauseIsTypeOnly(clause: string): boolean {
|
||||||
|
const trimmed = clause.trim();
|
||||||
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const specifiers = trimmed
|
||||||
|
.slice(1, -1)
|
||||||
|
.split(",")
|
||||||
|
.map((specifier) => specifier.trim())
|
||||||
|
.filter((specifier) => specifier.length > 0);
|
||||||
|
return specifiers.length > 0 && specifiers.every((specifier) => /^type\s/u.test(specifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare specifiers a source file needs at run time: every static, side-effect, and dynamic import
|
||||||
|
* except type-only ones. A `.tsx`/`.jsx` file also needs `react`, because the platform compiles JSX
|
||||||
|
* with the automatic runtime (`"jsx": "react-jsx"`), which imports `react/jsx-runtime` without that
|
||||||
|
* import appearing in the source.
|
||||||
|
*/
|
||||||
|
function collectRuntimeImportSpecifiers(sourceText: string, extension: string): string[] {
|
||||||
|
const specifiers = new Set<string>();
|
||||||
|
const staticImport = /\b(?:import|export)\s+(type\s+)?([^;]*?)\bfrom\s*["']([^"'\n]+)["']/gu;
|
||||||
|
for (const match of sourceText.matchAll(staticImport)) {
|
||||||
|
const [, typeKeyword, clause = "", specifier] = match;
|
||||||
|
if (specifier && !typeKeyword && !importClauseIsTypeOnly(clause)) {
|
||||||
|
specifiers.add(specifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sideEffectImport = /\bimport\s*["']([^"'\n]+)["']/gu;
|
||||||
|
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu;
|
||||||
|
for (const pattern of [sideEffectImport, dynamicImport]) {
|
||||||
|
for (const match of sourceText.matchAll(pattern)) {
|
||||||
|
if (match[1]) {
|
||||||
|
specifiers.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extension === ".tsx" || extension === ".jsx") {
|
||||||
|
specifiers.add("react/jsx-runtime");
|
||||||
|
}
|
||||||
|
return [...specifiers];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_BUILTIN_MODULES = new Set(builtinModules);
|
||||||
|
|
||||||
|
function isBareSpecifier(specifier: string): boolean {
|
||||||
|
return !(
|
||||||
|
specifier.startsWith(".") ||
|
||||||
|
specifier.startsWith("/") ||
|
||||||
|
specifier.startsWith("#") ||
|
||||||
|
specifier.startsWith("node:") ||
|
||||||
|
/^[a-z][a-z0-9+.-]*:/u.test(specifier)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveSourceImport(
|
async function resolveSourceImport(
|
||||||
fromFile: string,
|
fromFile: string,
|
||||||
specifier: string,
|
specifier: string,
|
||||||
@@ -906,6 +983,49 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
|||||||
sourceGraph.set(relativeFile, new Set());
|
sourceGraph.set(relativeFile, new Set());
|
||||||
|
|
||||||
const sourceText = await readFile(codeFile, "utf8");
|
const sourceText = await readFile(codeFile, "utf8");
|
||||||
|
|
||||||
|
// A workspace package is linked into its consumers as a directory, so Node resolves the
|
||||||
|
// package's own imports from the package's own `node_modules`. A peer dependency is never
|
||||||
|
// satisfied there by the consumer, and a production install (`pnpm install --prod`, as the
|
||||||
|
// production image runs) omits development dependencies. Code a package ships must therefore
|
||||||
|
// declare every runtime import as a production dependency. A peer declaration may stay beside it.
|
||||||
|
const runtimePackage = findContainingPackage(codeFile, packagesByDepth);
|
||||||
|
if (runtimePackage && isRuntimeSourceFile(path.relative(runtimePackage.directory, codeFile))) {
|
||||||
|
const reported = new Set<string>();
|
||||||
|
for (const specifier of collectRuntimeImportSpecifiers(
|
||||||
|
sourceText,
|
||||||
|
path.extname(codeFile).toLowerCase(),
|
||||||
|
)) {
|
||||||
|
if (!isBareSpecifier(specifier)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dependencyName = packageNameFromSpecifier(specifier);
|
||||||
|
const manifest = runtimePackage.manifest;
|
||||||
|
if (
|
||||||
|
reported.has(dependencyName) ||
|
||||||
|
dependencyName === runtimePackage.name ||
|
||||||
|
NODE_BUILTIN_MODULES.has(dependencyName) ||
|
||||||
|
manifest.dependencies?.[dependencyName] !== undefined ||
|
||||||
|
manifest.optionalDependencies?.[dependencyName] !== undefined
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
reported.add(dependencyName);
|
||||||
|
const declaredAs =
|
||||||
|
manifest.peerDependencies?.[dependencyName] !== undefined
|
||||||
|
? "only as a peer dependency"
|
||||||
|
: manifest.devDependencies?.[dependencyName] !== undefined
|
||||||
|
? "only as a development dependency"
|
||||||
|
: "not at all";
|
||||||
|
violations.push({
|
||||||
|
from: relativeFile,
|
||||||
|
message: `${runtimePackage.name} imports ${dependencyName} at run time but declares it ${declaredAs}; a runtime import must be in dependencies.`,
|
||||||
|
rule: "runtime-imports-are-production-dependencies",
|
||||||
|
to: dependencyName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const specifier of collectImportSpecifiers(sourceText)) {
|
for (const specifier of collectImportSpecifiers(sourceText)) {
|
||||||
const dependencyName = packageNameFromSpecifier(specifier);
|
const dependencyName = packageNameFromSpecifier(specifier);
|
||||||
if (from.layer === "module" && isModuleUiPackage(dependencyName)) {
|
if (from.layer === "module" && isModuleUiPackage(dependencyName)) {
|
||||||
|
|||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Validates the reference deployment's Compose files (ADR 0044) in each supported combination:
|
||||||
|
# base, base + proxy, and base + proxy + tunnel. Uses only the committed example env files, copied
|
||||||
|
# to a temporary directory, so it needs no secret and starts nothing.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repository_root/deploy"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
for name in ekklesia postgres object-storage tunnel; do
|
||||||
|
cp "env/${name}.env.example" "$work/${name}.env"
|
||||||
|
done
|
||||||
|
cp env/compose.env.example "$work/compose.env"
|
||||||
|
cat >>"$work/compose.env" <<EOF_ENV
|
||||||
|
EKKLESIA_IMAGE=registry.example.org/ekklesia:0.0.0
|
||||||
|
EKKLESIA_ENV_DIR=${work}
|
||||||
|
EKKLESIA_HOSTNAME=ekklesia.example.org
|
||||||
|
EKKLESIA_STORAGE_HOSTNAME=storage.ekklesia.example.org
|
||||||
|
[email protected]
|
||||||
|
EOF_ENV
|
||||||
|
|
||||||
|
combinations=(
|
||||||
|
"compose.yaml"
|
||||||
|
"compose.yaml compose.proxy.yaml"
|
||||||
|
"compose.yaml compose.proxy.yaml compose.tunnel.yaml"
|
||||||
|
)
|
||||||
|
for combination in "${combinations[@]}"; do
|
||||||
|
arguments=()
|
||||||
|
for file in $combination; do
|
||||||
|
arguments+=(--file "$file")
|
||||||
|
done
|
||||||
|
if ! docker compose --env-file "$work/compose.env" "${arguments[@]}" --profile ops config --quiet; then
|
||||||
|
echo "Reference deployment Compose configuration is invalid: ${combination}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "valid: ${combination}"
|
||||||
|
done
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Why a disposable Compose project did not become ready: `docker compose ps` and the last log lines
|
||||||
|
# of every service, so a CI failure says what went wrong (a port already allocated, a container that
|
||||||
|
# exited, a health check that never passed) instead of only that something did.
|
||||||
|
#
|
||||||
|
# source scripts/lib/compose-diagnostics.sh
|
||||||
|
# COMPOSE_DIAGNOSTICS_SECRETS=("$password" "$secret_key") # literal values to mask, optional
|
||||||
|
# compose_report_not_ready "$up_output" "${compose[@]}"
|
||||||
|
#
|
||||||
|
# Everything printed passes through `compose_diagnostics_redact` first. The projects these scripts
|
||||||
|
# start hold only disposable, generated test credentials, but output is redacted anyway: every
|
||||||
|
# literal in COMPOSE_DIAGNOSTICS_SECRETS, the value of any `NAME=value` or `NAME: value` whose name
|
||||||
|
# looks like a credential, the user-information part of any URL, and the rest of any Authorization
|
||||||
|
# header. Terminal control sequences are stripped. Output goes to standard error.
|
||||||
|
|
||||||
|
COMPOSE_DIAGNOSTICS_SECRETS=()
|
||||||
|
COMPOSE_DIAGNOSTICS_TAIL_LINES=40
|
||||||
|
|
||||||
|
# compose_diagnostics_redact: standard input to standard output, with credentials masked.
|
||||||
|
compose_diagnostics_redact() {
|
||||||
|
local secrets=""
|
||||||
|
if ((${#COMPOSE_DIAGNOSTICS_SECRETS[@]} > 0)); then
|
||||||
|
secrets="$(printf '%s\n' "${COMPOSE_DIAGNOSTICS_SECRETS[@]}")"
|
||||||
|
fi
|
||||||
|
# Literal values first, by plain substring rather than as patterns. Very short values are skipped:
|
||||||
|
# masking every occurrence of a two-letter word would only make the output unreadable.
|
||||||
|
COMPOSE_DIAGNOSTICS_LITERALS="$secrets" awk '
|
||||||
|
BEGIN { count = split(ENVIRON["COMPOSE_DIAGNOSTICS_LITERALS"], secret, "\n") }
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
for (i = 1; i <= count; i++) {
|
||||||
|
if (length(secret[i]) < 6) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
masked = ""
|
||||||
|
while ((at = index(line, secret[i])) > 0) {
|
||||||
|
masked = masked substr(line, 1, at - 1) "[redacted]"
|
||||||
|
line = substr(line, at + length(secret[i]))
|
||||||
|
}
|
||||||
|
line = masked line
|
||||||
|
}
|
||||||
|
print line
|
||||||
|
}
|
||||||
|
' | sed -E \
|
||||||
|
-e 's/\x1b\[[0-9;?]*[A-Za-z]//g' \
|
||||||
|
-e 's#([Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn][[:space:]]*[:=]).*#\1 [redacted]#' \
|
||||||
|
-e 's#([A-Za-z0-9_.-]*([Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Aa][Ss][Ss][Ww][Dd]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[_.-][Kk][Ee][Yy])[A-Za-z0-9_.-]*"?[[:space:]]*[=:][[:space:]]*)("[^"]*"|[^[:space:],;"]+)#\1[redacted]#g' \
|
||||||
|
-e 's#([A-Za-z][A-Za-z0-9+.-]*://)[^/@[:space:]]+@#\1[redacted]@#g'
|
||||||
|
}
|
||||||
|
|
||||||
|
# compose_report_not_ready <up output> <compose command...>: the failed command's own output, then
|
||||||
|
# the project's containers and the tail of each service's logs, all redacted.
|
||||||
|
compose_report_not_ready() {
|
||||||
|
local up_output="$1"
|
||||||
|
shift
|
||||||
|
local service services
|
||||||
|
{
|
||||||
|
if [[ -n "$up_output" ]]; then
|
||||||
|
echo "--- docker compose up (last 20 lines)"
|
||||||
|
printf '%s\n' "$up_output" | tail -n 20 | compose_diagnostics_redact
|
||||||
|
fi
|
||||||
|
echo "--- docker compose ps"
|
||||||
|
"$@" ps --all 2>&1 | compose_diagnostics_redact
|
||||||
|
services="$("$@" config --services 2>/dev/null || true)"
|
||||||
|
for service in $services; do
|
||||||
|
echo "--- docker compose logs ${service} (last ${COMPOSE_DIAGNOSTICS_TAIL_LINES} lines)"
|
||||||
|
"$@" logs --no-color --tail "$COMPOSE_DIAGNOSTICS_TAIL_LINES" "$service" 2>&1 |
|
||||||
|
compose_diagnostics_redact
|
||||||
|
done
|
||||||
|
echo "--- end of diagnostics"
|
||||||
|
} >&2
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// HTTP assertions for scripts/smoke-deploy.sh, run inside a disposable container on the reference
|
||||||
|
// deployment's `edge` network: every request goes through the proxy by its public hostname, exactly
|
||||||
|
// as a browser's would. Not a test of the API in isolation, and never run against a real
|
||||||
|
// installation.
|
||||||
|
//
|
||||||
|
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
|
||||||
|
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
|
||||||
|
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
|
||||||
|
//
|
||||||
|
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
|
||||||
|
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
const hostname = required("SMOKE_HOSTNAME");
|
||||||
|
const origin = `https://${hostname}`;
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
function check(condition, description) {
|
||||||
|
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
|
||||||
|
if (!condition) {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sign-in attempt that can only fail authentication; what matters is whether the API's
|
||||||
|
* per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */
|
||||||
|
async function signInAttempt(headers = {}) {
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
|
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
|
||||||
|
headers: { "content-type": "application/json", origin, ...headers },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
await response.arrayBuffer();
|
||||||
|
return response.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edgeChecks() {
|
||||||
|
const ready = await fetch(`${origin}/api/v1/health/ready`);
|
||||||
|
check(
|
||||||
|
ready.status === 200 && (await ready.text()) === '{"status":"ready"}',
|
||||||
|
"GET /api/v1/health/ready through the proxy is 200 ready",
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = await fetch(`${origin}/`);
|
||||||
|
const html = await page.text();
|
||||||
|
check(page.status === 200, "GET / through the proxy is 200");
|
||||||
|
check(
|
||||||
|
page.headers.get("content-type") === "text/html; charset=utf-8" &&
|
||||||
|
html.includes('id="ekklesia-host"') &&
|
||||||
|
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
|
||||||
|
"GET / is the Dashboard HTML",
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
page.headers.get("x-frame-options") === "DENY" &&
|
||||||
|
page.headers.get("content-security-policy") === "frame-ancestors 'none'" &&
|
||||||
|
page.headers.get("referrer-policy") === "no-referrer",
|
||||||
|
"the Dashboard page keeps its security headers through the proxy",
|
||||||
|
);
|
||||||
|
|
||||||
|
const script = await fetch(`${origin}/assets/ekklesia-dashboard/app.js`);
|
||||||
|
await script.arrayBuffer();
|
||||||
|
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
||||||
|
|
||||||
|
const outside = await fetch(`${origin}/favicon.ico`);
|
||||||
|
check(
|
||||||
|
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
|
||||||
|
"a path the API does not serve is refused by the proxy, never forwarded",
|
||||||
|
);
|
||||||
|
|
||||||
|
const unknownApi = await fetch(`${origin}/api/v1/does-not-exist`);
|
||||||
|
check(
|
||||||
|
unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}',
|
||||||
|
"an unknown /api/* path reaches the API and gets its sanitized 404",
|
||||||
|
);
|
||||||
|
|
||||||
|
await storageDeliveryCheck();
|
||||||
|
|
||||||
|
// Every caller-supplied forwarding header rotates on every attempt. If any of them reached the
|
||||||
|
// API's client-address resolution, each attempt would land in a fresh limiter bucket and the
|
||||||
|
// fourth would not be limited.
|
||||||
|
const statuses = [];
|
||||||
|
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||||
|
statuses.push(
|
||||||
|
await signInAttempt({
|
||||||
|
"cf-connecting-ip": `198.51.100.${attempt}`,
|
||||||
|
forwarded: `for=198.51.100.${attempt + 10}`,
|
||||||
|
"true-client-ip": `198.51.100.${attempt + 20}`,
|
||||||
|
"x-ekklesia-client-address": `198.51.100.${attempt + 50}`,
|
||||||
|
"x-forwarded-for": `198.51.100.${attempt + 30}`,
|
||||||
|
"x-real-ip": `198.51.100.${attempt + 40}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
statuses.slice(0, 3).every((status) => status !== 429 && status < 500) && statuses[3] === 429,
|
||||||
|
`spoofed forwarding headers do not choose the client address (statuses ${statuses.join(",")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storageDeliveryCheck() {
|
||||||
|
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
|
||||||
|
// over HTTPS on the private network and signs read URLs for the storage hostname.
|
||||||
|
const { createS3StorageProvider, createStorageProviderConfig } = await import(
|
||||||
|
"/opt/ekklesia/core/dist/storage/index.js"
|
||||||
|
);
|
||||||
|
const config = createStorageProviderConfig(
|
||||||
|
{
|
||||||
|
accessKey: required("S3_ACCESS_KEY"),
|
||||||
|
bucketPrefix: "ekklesia-smoke",
|
||||||
|
deliveryEndpoint: `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
|
||||||
|
endpoint: "https://object-storage:8443",
|
||||||
|
forcePathStyle: true,
|
||||||
|
region: "us-east-1",
|
||||||
|
secretKey: required("S3_SECRET_KEY"),
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
const provider = createS3StorageProvider(config);
|
||||||
|
try {
|
||||||
|
const bucket = "ekklesia-smoke-delivery";
|
||||||
|
const body = new TextEncoder().encode("delivered through the proxy\n");
|
||||||
|
await provider.ensureBucket(bucket);
|
||||||
|
await provider.put(bucket, "smoke/object.txt", body, {
|
||||||
|
contentLength: body.byteLength,
|
||||||
|
contentType: "text/plain",
|
||||||
|
});
|
||||||
|
const readUrl = await provider.createReadUrl(bucket, "smoke/object.txt", {
|
||||||
|
expiresInSeconds: 300,
|
||||||
|
});
|
||||||
|
const url = new URL(readUrl.url);
|
||||||
|
check(
|
||||||
|
url.origin === `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
|
||||||
|
"a signed read URL names the storage hostname",
|
||||||
|
);
|
||||||
|
const delivered = await fetch(url);
|
||||||
|
const text = await delivered.text();
|
||||||
|
check(
|
||||||
|
delivered.status === 200 && text === "delivered through the proxy\n",
|
||||||
|
"a signed read URL verifies through the proxy (original Host preserved)",
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
(delivered.headers.get("cache-control") ?? "").startsWith("private"),
|
||||||
|
"a delivered object is Cache-Control: private",
|
||||||
|
);
|
||||||
|
await provider.delete(bucket, "smoke/object.txt");
|
||||||
|
} finally {
|
||||||
|
await provider.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tunnelChecks() {
|
||||||
|
// From the connector's fixed address, CF-Connecting-IP is the client: one visitor is limited on
|
||||||
|
// its fourth attempt, and a different visitor is not limited by the first one's attempts.
|
||||||
|
const first = [];
|
||||||
|
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||||
|
first.push(await signInAttempt({ "cf-connecting-ip": "203.0.113.10" }));
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
first.slice(0, 3).every((status) => status !== 429 && status < 500) && first[3] === 429,
|
||||||
|
`the connector's CF-Connecting-IP identifies the visitor (statuses ${first.join(",")})`,
|
||||||
|
);
|
||||||
|
const second = await signInAttempt({ "cf-connecting-ip": "203.0.113.11" });
|
||||||
|
check(
|
||||||
|
second !== 429 && second < 500,
|
||||||
|
`a second visitor behind the connector has its own limiter bucket (status ${second})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The deployed browser authentication contract (launch checklist item 9), as the initial owner
|
||||||
|
* created by the image's own CLI (item 10). */
|
||||||
|
async function sessionChecks() {
|
||||||
|
for (const path of [
|
||||||
|
"/api/auth/get-session",
|
||||||
|
"/api/auth/list-sessions",
|
||||||
|
"/api/auth/update-session",
|
||||||
|
"/api/auth/change-password",
|
||||||
|
]) {
|
||||||
|
const response = await fetch(`${origin}${path}`, {
|
||||||
|
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
|
||||||
|
headers: { origin },
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
|
||||||
|
`${path} is unavailable through the proxy`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: required("SMOKE_OWNER_EMAIL"),
|
||||||
|
password: required("SMOKE_OWNER_PASSWORD"),
|
||||||
|
}),
|
||||||
|
headers: { "content-type": "application/json", origin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const signInBody = await signIn.text();
|
||||||
|
const setCookie = signIn.headers.get("set-cookie") ?? "";
|
||||||
|
check(
|
||||||
|
signIn.status === 200 && signInBody === '{"ok":true}',
|
||||||
|
'sign-in returns the fixed {"ok":true}',
|
||||||
|
);
|
||||||
|
const attributes = setCookie.toLowerCase();
|
||||||
|
check(
|
||||||
|
attributes.includes("httponly") &&
|
||||||
|
attributes.includes("secure") &&
|
||||||
|
attributes.includes("samesite=lax") &&
|
||||||
|
!attributes.includes("domain="),
|
||||||
|
"the session cookie is host-only, HttpOnly, Secure, and SameSite=Lax",
|
||||||
|
);
|
||||||
|
const cookie = setCookie.split(";")[0] ?? "";
|
||||||
|
|
||||||
|
const principal = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
|
||||||
|
const principalBody = await principal.json();
|
||||||
|
check(
|
||||||
|
principal.status === 200 && principalBody.kind === "authenticated",
|
||||||
|
"the session resolves an authenticated principal through the proxy",
|
||||||
|
);
|
||||||
|
|
||||||
|
const signOut = await fetch(`${origin}/api/auth/sign-out`, {
|
||||||
|
body: "{}",
|
||||||
|
headers: { "content-type": "application/json", cookie, origin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
await signOut.arrayBuffer();
|
||||||
|
const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
|
||||||
|
check(
|
||||||
|
signOut.status === 200 && (await after.json()).kind === "anonymous",
|
||||||
|
"sign-out ends the session",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "edge") {
|
||||||
|
await edgeChecks();
|
||||||
|
} else if (mode === "tunnel") {
|
||||||
|
await tunnelChecks();
|
||||||
|
} else if (mode === "session") {
|
||||||
|
await sessionChecks();
|
||||||
|
} else {
|
||||||
|
throw new Error('Mode must be "edge", "tunnel", or "session".');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.log(`${failures} check(s) failed.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Executable
+394
@@ -0,0 +1,394 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end smoke test of the reference deployment (ADR 0044): deploy/compose.yaml with
|
||||||
|
# deploy/compose.proxy.yaml, a platform-only image, and a disposable PostgreSQL and SeaweedFS that
|
||||||
|
# exist only for this run. CI runs it; it is safe to run on a workstation.
|
||||||
|
#
|
||||||
|
# bash scripts/smoke-deploy.sh builds the image first
|
||||||
|
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||||
|
#
|
||||||
|
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
|
||||||
|
# run has no public hostname. Never use that option in an installation.
|
||||||
|
#
|
||||||
|
# Follows the documented first boot: TLS material, secrets, data services, an explicit migration
|
||||||
|
# apply against this run's own fresh database, then the API and proxy. Everything it starts, and
|
||||||
|
# every file it writes, is removed on exit.
|
||||||
|
#
|
||||||
|
# Bind mounts are resolved by the Docker daemon, on its host. When this script runs inside a CI job
|
||||||
|
# container that drives the host's Docker, its own files are not at those paths, so every file a
|
||||||
|
# container mounts is staged under an identical path in the Docker host's /tmp first.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repository_root"
|
||||||
|
|
||||||
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
|
||||||
|
for command in docker openssl awk; do
|
||||||
|
command -v "$command" >/dev/null || {
|
||||||
|
echo "smoke-deploy: $command is required." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
docker compose version >/dev/null 2>&1 || {
|
||||||
|
echo "smoke-deploy: Docker Compose is required." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither
|
||||||
|
# root nor sudo on the host.
|
||||||
|
postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15"
|
||||||
|
|
||||||
|
run_id="$(openssl rand -hex 4)"
|
||||||
|
project="ekklesia-deploy-smoke-${run_id}"
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
image="${EKKLESIA_SMOKE_IMAGE:-}"
|
||||||
|
built_image=""
|
||||||
|
hostname="ekklesia.smoke.test"
|
||||||
|
storage_hostname="storage.smoke.test"
|
||||||
|
|
||||||
|
# choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
|
||||||
|
# concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
|
||||||
|
# subnet at the same moment; the first `up` below detects that and chooses again. Fixed addresses come
|
||||||
|
# from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||||
|
choose_edge_network() {
|
||||||
|
local in_use candidate attempt
|
||||||
|
in_use="$(
|
||||||
|
docker network ls --quiet |
|
||||||
|
xargs -r docker network inspect --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' 2>/dev/null |
|
||||||
|
sed -n 's#^10\.249\.\([0-9]*\)\.0/24$#\1#p'
|
||||||
|
)" || in_use=""
|
||||||
|
for attempt in $(seq 1 100); do
|
||||||
|
candidate=$((RANDOM % 200 + 20))
|
||||||
|
if ! grep -qx "$candidate" <<<"$in_use"; then
|
||||||
|
octet="$candidate"
|
||||||
|
edge_subnet="10.249.${octet}.0/24"
|
||||||
|
edge_range="10.249.${octet}.0/25"
|
||||||
|
proxy_address="10.249.${octet}.254"
|
||||||
|
tunnel_address="10.249.${octet}.253"
|
||||||
|
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||||
|
session_client_address="10.249.${octet}.252"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "smoke-deploy: no free 10.249.x.0/24 edge subnet on this Docker daemon." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
choose_edge_network
|
||||||
|
|
||||||
|
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work`
|
||||||
|
# on a workstation; a staged copy on the Docker host when this script runs in a job container.
|
||||||
|
if [[ -f /.dockerenv ]]; then
|
||||||
|
daemon_work="/tmp/${project}"
|
||||||
|
docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$postgres_image" -p "/host-tmp/${project}"
|
||||||
|
# stage <path relative to work>: copy it to the same relative path under daemon_work, keeping
|
||||||
|
# numeric ownership and modes.
|
||||||
|
stage() {
|
||||||
|
tar -C "$work" -cf - "$1" |
|
||||||
|
docker run --rm -i --entrypoint tar -v "$daemon_work:/w" "$postgres_image" -xpf - -C /w
|
||||||
|
}
|
||||||
|
else
|
||||||
|
daemon_work="$work"
|
||||||
|
stage() { :; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "$project"
|
||||||
|
--project-directory "$daemon_work/deploy"
|
||||||
|
--env-file "$work/compose.env"
|
||||||
|
--file deploy/compose.yaml
|
||||||
|
--file deploy/compose.proxy.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
if [[ -f "$work/compose.env" ]]; then
|
||||||
|
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
|
||||||
|
fi
|
||||||
|
# Some files are owned by the PostgreSQL and SeaweedFS uids by now; remove them as root, in the
|
||||||
|
# same image that set that ownership. In a job container the daemon cannot see `work` (and the
|
||||||
|
# job itself runs as root), so only the staged copy on the Docker host needs this.
|
||||||
|
if [[ "$daemon_work" == "$work" ]]; then
|
||||||
|
docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$work" || true
|
||||||
|
if [[ -n "$built_image" ]]; then
|
||||||
|
docker image rm --force "$built_image" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
step() {
|
||||||
|
echo "==> $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "smoke-deploy: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$image" ]]; then
|
||||||
|
step "Building the platform-only image"
|
||||||
|
built_image="ekklesia-deploy-smoke:${run_id}"
|
||||||
|
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null
|
||||||
|
image="$built_image"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
|
||||||
|
cp -R deploy "$work/deploy"
|
||||||
|
stage deploy
|
||||||
|
|
||||||
|
step "Creating the installation-private CA and server certificates"
|
||||||
|
init_status=0
|
||||||
|
sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$?
|
||||||
|
if ((init_status != 0 && init_status != 77)); then
|
||||||
|
fail "init-tls.sh failed to create TLS material."
|
||||||
|
fi
|
||||||
|
stage tls
|
||||||
|
# The root-only ownership pass, exactly as an operator would re-run the script with root.
|
||||||
|
ownership_output="$(
|
||||||
|
docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \
|
||||||
|
-v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls
|
||||||
|
)" || fail "init-tls.sh could not apply ownership."
|
||||||
|
database_ca_line="$(grep '^EKKLESIA_DATABASE_CA=' <<<"$ownership_output")" ||
|
||||||
|
fail "init-tls.sh did not print the EKKLESIA_DATABASE_CA line."
|
||||||
|
# A second run must create nothing and still succeed.
|
||||||
|
rerun_output="$(
|
||||||
|
docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \
|
||||||
|
-v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls
|
||||||
|
)" || fail "init-tls.sh failed on a second run."
|
||||||
|
grep -q 'nothing to create' <<<"$rerun_output" || fail "init-tls.sh is not idempotent."
|
||||||
|
|
||||||
|
step "Writing env files from the examples, with freshly generated secrets"
|
||||||
|
secrets="$(sh deploy/scripts/gen-secrets.sh)"
|
||||||
|
secret() {
|
||||||
|
awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' <<<"$secrets"
|
||||||
|
}
|
||||||
|
postgres_password="$(secret POSTGRES_PASSWORD)"
|
||||||
|
s3_access_key="$(secret S3_ACCESS_KEY)"
|
||||||
|
s3_secret_key="$(secret S3_SECRET_KEY)"
|
||||||
|
identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)"
|
||||||
|
# Masked in any diagnostics below, along with anything else that looks like a credential.
|
||||||
|
COMPOSE_DIAGNOSTICS_SECRETS=("$postgres_password" "$s3_access_key" "$s3_secret_key" "$identity_secret")
|
||||||
|
|
||||||
|
mkdir -p "$work/env"
|
||||||
|
# set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact.
|
||||||
|
set_env() {
|
||||||
|
KEY="$2" VALUE="$3" awk '
|
||||||
|
BEGIN { found = 0 }
|
||||||
|
index($0, ENVIRON["KEY"] "=") == 1 { print ENVIRON["KEY"] "=" ENVIRON["VALUE"]; found = 1; next }
|
||||||
|
{ print }
|
||||||
|
END { if (!found) print ENVIRON["KEY"] "=" ENVIRON["VALUE"] }
|
||||||
|
' "$1" >"$1.next"
|
||||||
|
mv "$1.next" "$1"
|
||||||
|
}
|
||||||
|
for name in ekklesia postgres object-storage; do
|
||||||
|
cp "deploy/env/${name}.env.example" "$work/env/${name}.env"
|
||||||
|
done
|
||||||
|
ekklesia_env="$work/env/ekklesia.env"
|
||||||
|
set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia"
|
||||||
|
set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}"
|
||||||
|
set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}"
|
||||||
|
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "https://${hostname}"
|
||||||
|
set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret"
|
||||||
|
set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}"
|
||||||
|
set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key"
|
||||||
|
set_env "$ekklesia_env" S3_SECRET_KEY "$s3_secret_key"
|
||||||
|
set_env "$work/env/postgres.env" POSTGRES_PASSWORD "$postgres_password"
|
||||||
|
set_env "$work/env/object-storage.env" AWS_ACCESS_KEY_ID "$s3_access_key"
|
||||||
|
set_env "$work/env/object-storage.env" AWS_SECRET_ACCESS_KEY "$s3_secret_key"
|
||||||
|
chmod 600 "$work"/env/*.env
|
||||||
|
|
||||||
|
# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option.
|
||||||
|
awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \
|
||||||
|
deploy/Caddyfile.example >"$work/Caddyfile"
|
||||||
|
grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy."
|
||||||
|
stage Caddyfile
|
||||||
|
|
||||||
|
cat >"$work/compose.env" <<EOF
|
||||||
|
EKKLESIA_IMAGE=${image}
|
||||||
|
EKKLESIA_ENV_DIR=${work}/env
|
||||||
|
EKKLESIA_TLS_DIR=${daemon_work}/tls
|
||||||
|
EKKLESIA_HOSTNAME=${hostname}
|
||||||
|
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
|
||||||
|
[email protected]
|
||||||
|
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
|
||||||
|
EKKLESIA_PUBLISH_ADDRESS=127.0.0.1
|
||||||
|
EKKLESIA_HTTP_PORT=0
|
||||||
|
EKKLESIA_HTTPS_PORT=0
|
||||||
|
EKKLESIA_EDGE_SUBNET=${edge_subnet}
|
||||||
|
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
|
||||||
|
EKKLESIA_PROXY_ADDRESS=${proxy_address}
|
||||||
|
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
step "Starting PostgreSQL and object storage"
|
||||||
|
# This `up` creates the edge network. If another run claimed the same subnet since it was chosen,
|
||||||
|
# Docker refuses with an address-pool overlap: choose another and start again, a bounded number of
|
||||||
|
# times. Any other failure is reported with the project's containers and logs.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if up_output="$(
|
||||||
|
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
|
||||||
|
)"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "$up_output" == *"overlap"* ]] && ((attempt < 5)); then
|
||||||
|
echo " edge subnet ${edge_subnet} was taken by a concurrent run; choosing another"
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || true
|
||||||
|
choose_edge_network
|
||||||
|
set_env "$work/compose.env" EKKLESIA_EDGE_SUBNET "$edge_subnet"
|
||||||
|
set_env "$work/compose.env" EKKLESIA_EDGE_DYNAMIC_RANGE "$edge_range"
|
||||||
|
set_env "$work/compose.env" EKKLESIA_PROXY_ADDRESS "$proxy_address"
|
||||||
|
set_env "$work/compose.env" EKKLESIA_TUNNEL_ADDRESS "$tunnel_address"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||||
|
fail "PostgreSQL and object storage did not become healthy."
|
||||||
|
done
|
||||||
|
|
||||||
|
step "Planning, then explicitly applying, migrations against this run's fresh database"
|
||||||
|
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed."
|
||||||
|
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||||
|
fail "migrate (apply) failed."
|
||||||
|
|
||||||
|
step "Starting the API and the proxy"
|
||||||
|
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 2>&1)"; then
|
||||||
|
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||||
|
fail "the API and proxy did not become healthy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA
|
||||||
|
# for their direct connection to object-storage.
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if "${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/root.crt \
|
||||||
|
>"$work/caddy-root.crt" 2>/dev/null && [[ -s "$work/caddy-root.crt" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[[ -s "$work/caddy-root.crt" ]] || fail "the test proxy's internal CA did not appear."
|
||||||
|
cat "$work/caddy-root.crt" "$work/tls/ca.crt" >"$work/client-ca.pem"
|
||||||
|
cp scripts/lib/deploy-smoke-client.mjs "$work/client.mjs"
|
||||||
|
stage client-ca.pem
|
||||||
|
stage client.mjs
|
||||||
|
|
||||||
|
edge_network="${project}_edge"
|
||||||
|
client() {
|
||||||
|
local mode="$1"
|
||||||
|
shift
|
||||||
|
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
--network "$edge_network" "$@" \
|
||||||
|
--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}" \
|
||||||
|
-e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \
|
||||||
|
-e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \
|
||||||
|
-e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \
|
||||||
|
-v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \
|
||||||
|
-v "$daemon_work/client-ca.pem:/smoke/client-ca.pem:ro" \
|
||||||
|
--entrypoint node "$image" /smoke/client.mjs "$mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
step "Checking the API, Dashboard, storage delivery, and client-address chain through the proxy"
|
||||||
|
client edge || fail "edge checks failed."
|
||||||
|
|
||||||
|
step "Checking the tunnel connector's client-address path from its fixed address"
|
||||||
|
client tunnel --ip "$tunnel_address" || fail "tunnel checks failed."
|
||||||
|
|
||||||
|
step "Creating the initial owner with the image's own CLI, then signing in and out through the proxy"
|
||||||
|
owner_email="[email protected]"
|
||||||
|
owner_password="$(openssl rand -hex 16)"
|
||||||
|
printf '%s' "$owner_password" |
|
||||||
|
"${compose[@]}" run --rm --no-deps -T migrate node core/dist/cli/create-initial-owner.js \
|
||||||
|
--email "$owner_email" >/dev/null || fail "creating the initial owner failed."
|
||||||
|
echo "ok the initial owner was created from the image (password on standard input)"
|
||||||
|
client session --ip "$session_client_address" \
|
||||||
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
|
||||||
|
fail "session checks failed."
|
||||||
|
|
||||||
|
step "Proving the API's database connection is certificate-verified TLS"
|
||||||
|
api_container="$("${compose[@]}" ps -q api)"
|
||||||
|
api_data_address="$(
|
||||||
|
docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \
|
||||||
|
"$api_container"
|
||||||
|
)"
|
||||||
|
[[ -n "$api_data_address" ]] || fail "could not find the API's database-network address."
|
||||||
|
psql_as_superuser() {
|
||||||
|
"${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1"
|
||||||
|
}
|
||||||
|
tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")"
|
||||||
|
plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")"
|
||||||
|
((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection."
|
||||||
|
((plain_connections == 0)) || fail "the API holds a database connection without TLS."
|
||||||
|
echo "ok the API's database connections are TLS 1.3 (${tls_connections}), none plaintext"
|
||||||
|
|
||||||
|
# PostgreSQL itself refuses a network connection without TLS.
|
||||||
|
if "${compose[@]}" exec -T -e PGPASSWORD="$postgres_password" postgres \
|
||||||
|
psql "host=postgres user=ekklesia dbname=ekklesia sslmode=disable" -Atc 'SELECT 1' >/dev/null 2>&1; then
|
||||||
|
fail "PostgreSQL accepted a plaintext network connection."
|
||||||
|
fi
|
||||||
|
echo "ok PostgreSQL refuses a plaintext network connection"
|
||||||
|
|
||||||
|
# The server cannot see whether a client verified it, so verification is proven by refusal: the
|
||||||
|
# same command fails against a CA that did not issue the certificate, and against a host the
|
||||||
|
# certificate does not name.
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$work/other-ca.key" 2>/dev/null
|
||||||
|
openssl req -x509 -new -sha256 -days 1 -key "$work/other-ca.key" -out "$work/other-ca.crt" \
|
||||||
|
-subj "/CN=Unrelated CA" 2>/dev/null
|
||||||
|
# expect_connection_refused <description> <compose run arguments...>: the command must fail, and fail
|
||||||
|
# at the connection, not on a configuration error that would prove nothing about verification.
|
||||||
|
expect_connection_refused() {
|
||||||
|
local description="$1"
|
||||||
|
shift
|
||||||
|
local output
|
||||||
|
if output="$("${compose[@]}" run --rm --no-deps "$@" 2>&1)"; then
|
||||||
|
fail "the database connection accepted ${description}."
|
||||||
|
fi
|
||||||
|
grep -q 'MIGRATION_CONNECTION_FAILED' <<<"$output" ||
|
||||||
|
fail "rejecting ${description} failed for a reason other than the connection."
|
||||||
|
}
|
||||||
|
expect_connection_refused "a certificate from a CA it was not given" \
|
||||||
|
-e EKKLESIA_DATABASE_CA="$(cat "$work/other-ca.crt")" migrate
|
||||||
|
echo "ok the database connection rejects a certificate its CA did not issue"
|
||||||
|
postgres_address="$(
|
||||||
|
docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \
|
||||||
|
"$("${compose[@]}" ps -q postgres)"
|
||||||
|
)"
|
||||||
|
expect_connection_refused "a certificate for a different host name" \
|
||||||
|
-e DATABASE_URL="postgresql://ekklesia:${postgres_password}@${postgres_address}:5432/ekklesia" migrate
|
||||||
|
echo "ok the database connection rejects a certificate that does not name the host"
|
||||||
|
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "the control connection failed."
|
||||||
|
echo "ok the same connection succeeds with the installation CA and the name postgres"
|
||||||
|
|
||||||
|
step "Running Doctor from the image"
|
||||||
|
doctor_output="$("${compose[@]}" run --rm --no-deps migrate node core/dist/cli/doctor.js 2>&1)" ||
|
||||||
|
fail "Doctor reported the installation not ready."
|
||||||
|
grep -q 'Result: READY' <<<"$doctor_output" || fail "Doctor did not report READY."
|
||||||
|
grep -q 'TLS required with certificate and hostname verification (explicit private CA supplied)' \
|
||||||
|
<<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA."
|
||||||
|
grep -q '✓ Object storage reachable' <<<"$doctor_output" ||
|
||||||
|
fail "Doctor could not reach object storage over HTTPS."
|
||||||
|
for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do
|
||||||
|
if grep -qF -- "$value" <<<"$doctor_output"; then
|
||||||
|
fail "Doctor output contains a secret."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "ok Doctor: READY, verify-full with the private CA, storage reachable, no secret printed"
|
||||||
|
|
||||||
|
api_logs="$("${compose[@]}" logs --no-color api 2>&1)"
|
||||||
|
for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do
|
||||||
|
if grep -qF -- "$value" <<<"$api_logs"; then
|
||||||
|
fail "the API's logs contain a secret."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
restarts="$(docker inspect --format '{{.RestartCount}}' "$api_container")"
|
||||||
|
((restarts == 0)) || fail "the API restarted ${restarts} time(s)."
|
||||||
|
echo "ok the API logged no secret and never restarted"
|
||||||
|
|
||||||
|
echo "Reference deployment smoke test passed."
|
||||||
@@ -7,6 +7,8 @@ cd "$repository_root"
|
|||||||
|
|
||||||
# shellcheck source=scripts/lib/compose-job-network.sh
|
# shellcheck source=scripts/lib/compose-job-network.sh
|
||||||
source "$repository_root/scripts/lib/compose-job-network.sh"
|
source "$repository_root/scripts/lib/compose-job-network.sh"
|
||||||
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
|
||||||
command -v docker >/dev/null || {
|
command -v docker >/dev/null || {
|
||||||
echo "Docker is required for the infrastructure smoke test." >&2
|
echo "Docker is required for the infrastructure smoke test." >&2
|
||||||
@@ -28,7 +30,9 @@ if [[ "$curl_help" != *"--aws-sigv4"* ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$"
|
# Unique per invocation, not only per run: several runs can share a runner's Docker daemon at once,
|
||||||
|
# and the shell's PID is often identical across job containers.
|
||||||
|
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$-${RANDOM}${RANDOM}"
|
||||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
||||||
project="${project,,}"
|
project="${project,,}"
|
||||||
temporary_directory="$(mktemp -d)"
|
temporary_directory="$(mktemp -d)"
|
||||||
@@ -46,6 +50,8 @@ export S3_SECRET_KEY="ekklesia-smoke-secret-key"
|
|||||||
export S3_BUCKET="ekklesia-smoke"
|
export S3_BUCKET="ekklesia-smoke"
|
||||||
export S3_REGION="us-east-1"
|
export S3_REGION="us-east-1"
|
||||||
export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"
|
export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"
|
||||||
|
COMPOSE_DIAGNOSTICS_SECRETS=("$POSTGRES_PASSWORD" "$S3_ACCESS_KEY" "$S3_SECRET_KEY")
|
||||||
|
up_output=""
|
||||||
|
|
||||||
compose=(
|
compose=(
|
||||||
docker compose
|
docker compose
|
||||||
@@ -63,7 +69,7 @@ cleanup() {
|
|||||||
local cleanup_output
|
local cleanup_output
|
||||||
trap - EXIT INT TERM
|
trap - EXIT INT TERM
|
||||||
if ((exit_status != 0)); then
|
if ((exit_status != 0)); then
|
||||||
"${compose[@]}" logs --no-color >&2 || true
|
compose_report_not_ready "$up_output" "${compose[@]}" || true
|
||||||
fi
|
fi
|
||||||
if ! compose_job_network_disconnect; then
|
if ! compose_job_network_disconnect; then
|
||||||
echo "Infrastructure smoke network cleanup failed." >&2
|
echo "Infrastructure smoke network cleanup failed." >&2
|
||||||
@@ -88,8 +94,15 @@ trap 'exit 130' INT
|
|||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
"${compose[@]}" config --quiet
|
"${compose[@]}" config --quiet
|
||||||
"${compose[@]}" up --detach --wait --wait-timeout 120
|
# Started twice: the second `up` proves the project is idempotent. A failure's own output is kept for
|
||||||
"${compose[@]}" up --detach --wait --wait-timeout 120
|
# the cleanup's diagnostics, which also print every container and the tail of its logs.
|
||||||
|
for _ in 1 2; do
|
||||||
|
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 120 2>&1)"; then
|
||||||
|
echo "Disposable PostgreSQL and object storage did not become ready." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
up_output=""
|
||||||
|
|
||||||
database_result="$(
|
database_result="$(
|
||||||
"${compose[@]}" exec -T \
|
"${compose[@]}" exec -T \
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ cd "$repository_root"
|
|||||||
|
|
||||||
# shellcheck source=scripts/lib/compose-job-network.sh
|
# shellcheck source=scripts/lib/compose-job-network.sh
|
||||||
source "$repository_root/scripts/lib/compose-job-network.sh"
|
source "$repository_root/scripts/lib/compose-job-network.sh"
|
||||||
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
|
||||||
command -v docker >/dev/null || {
|
command -v docker >/dev/null || {
|
||||||
echo "Docker is required for the PostgreSQL migration integration suite." >&2
|
echo "Docker is required for the PostgreSQL migration integration suite." >&2
|
||||||
@@ -25,14 +27,20 @@ if ! compose_job_network_initialize; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$"
|
# Unique per invocation, not only per run: several runs (and several jobs of one run) can share a
|
||||||
|
# runner's Docker daemon at once, and the shell's PID is often identical across job containers.
|
||||||
|
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$-${RANDOM}${RANDOM}"
|
||||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
||||||
project="${project,,}"
|
project="${project,,}"
|
||||||
|
|
||||||
export POSTGRES_DB="ekklesia_migration_test"
|
export POSTGRES_DB="ekklesia_migration_test"
|
||||||
export POSTGRES_USER="ekklesia_migration_test"
|
export POSTGRES_USER="ekklesia_migration_test"
|
||||||
export POSTGRES_PASSWORD="temporary-$project-${RANDOM}-${RANDOM}"
|
export POSTGRES_PASSWORD="temporary-$project-${RANDOM}-${RANDOM}"
|
||||||
|
# Both host ports are ephemeral: concurrent runs on one Docker daemon must never contend for a
|
||||||
|
# fixed one. `.env.example` pins the storage port for local development, so it is overridden here
|
||||||
|
# exactly like the database's. The bound ports are discovered below.
|
||||||
export POSTGRES_PORT="0"
|
export POSTGRES_PORT="0"
|
||||||
|
export S3_PORT="0"
|
||||||
|
|
||||||
compose=(
|
compose=(
|
||||||
docker compose
|
docker compose
|
||||||
@@ -72,8 +80,17 @@ fi
|
|||||||
# and CI already knows how to provision. SeaweedFS is the intended deployment target (ADR 0004), and
|
# and CI already knows how to provision. SeaweedFS is the intended deployment target (ADR 0004), and
|
||||||
# proving the storage contract against a mock instead would have hidden the provider quirks this
|
# proving the storage contract against a mock instead would have hidden the provider quirks this
|
||||||
# suite exists to catch.
|
# suite exists to catch.
|
||||||
if ! "${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1; then
|
# Masked in any diagnostics below, along with anything else that looks like a credential.
|
||||||
|
COMPOSE_DIAGNOSTICS_SECRETS=(
|
||||||
|
"$POSTGRES_PASSWORD"
|
||||||
|
"$(grep -E '^S3_ACCESS_KEY=' .env.example | cut -d= -f2-)"
|
||||||
|
"$(grep -E '^S3_SECRET_KEY=' .env.example | cut -d= -f2-)"
|
||||||
|
)
|
||||||
|
if ! up_output="$(
|
||||||
|
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
|
||||||
|
)"; then
|
||||||
echo "Disposable PostgreSQL and object storage did not become ready." >&2
|
echo "Disposable PostgreSQL and object storage did not become ready." >&2
|
||||||
|
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
<div id="ekklesia-host"></div>
|
<div id="ekklesia-host"></div>
|
||||||
<!-- A real module script, from the same origin, with no inline script anywhere on the page:
|
<!-- A real module script, from the same origin, with no inline script anywhere on the page:
|
||||||
a deployment can therefore apply a strict script-src without exceptions, and the runtime
|
a deployment can therefore apply a strict script-src without exceptions, and the runtime
|
||||||
`import()` of a published module artifact stays same-origin under it. -->
|
`import()` of a published module artifact stays same-origin under it. The path is absolute
|
||||||
<script src="./app.js" type="module"></script>
|
and fixed, exactly like the display host's, so it names one host-owned route rather than a
|
||||||
|
file relative to whatever URL served the page. -->
|
||||||
|
<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/shell",
|
"name": "@ekklesia/shell",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
},
|
},
|
||||||
|
"./dashboard-assets": {
|
||||||
|
"types": "./dist/dashboard-assets.d.ts",
|
||||||
|
"import": "./dist/dashboard-assets.js"
|
||||||
|
},
|
||||||
"./display-assets": {
|
"./display-assets": {
|
||||||
"types": "./dist/display-assets.d.ts",
|
"types": "./dist/display-assets.d.ts",
|
||||||
"import": "./dist/display-assets.js"
|
"import": "./dist/display-assets.js"
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ const buildOptions = {
|
|||||||
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
||||||
},
|
},
|
||||||
entryPoints: {
|
entryPoints: {
|
||||||
app: path.join(packageDirectory, "src", "browser", "main.tsx"),
|
// Emitted under the exact path `apps/api` serves it from (ADR 0044), so the development server's
|
||||||
|
// `servedir` and production answer the same absolute URL `index.html` names.
|
||||||
|
"assets/ekklesia-dashboard/app": path.join(packageDirectory, "src", "browser", "main.tsx"),
|
||||||
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
|
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
|
||||||
// The display service worker (ADR 0043). A separate entry because a worker is a separate
|
// The display service worker (ADR 0043). A separate entry because a worker is a separate
|
||||||
// realm with its own globals; bundling it into the page would make it unregisterable and its
|
// realm with its own globals; bundling it into the page would make it unregisterable and its
|
||||||
|
|||||||
@@ -277,3 +277,36 @@ export function readAuthLanding(search: string): HostAuthLanding | undefined {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures what an emailed verification or reset link asked for, then removes it from the address
|
||||||
|
* bar.
|
||||||
|
*
|
||||||
|
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
|
||||||
|
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
|
||||||
|
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
|
||||||
|
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
|
||||||
|
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
|
||||||
|
* the reset form, and never in `localStorage` or `sessionStorage`.
|
||||||
|
*
|
||||||
|
* The location and history are passed in rather than read here, so this module still reads no
|
||||||
|
* global and `browser/main.tsx` remains the shell's one browser-global boundary. Only a recognized
|
||||||
|
* Ekklesia landing rewrites the URL; any other query is left exactly as it arrived.
|
||||||
|
*/
|
||||||
|
export function consumeAuthLanding(
|
||||||
|
location: Pick<Location, "pathname" | "search">,
|
||||||
|
history: Pick<History, "replaceState">,
|
||||||
|
): HostAuthLanding | undefined {
|
||||||
|
const landing = readAuthLanding(location.search);
|
||||||
|
if (!landing) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
history.replaceState(null, "", location.pathname);
|
||||||
|
} catch {
|
||||||
|
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
|
||||||
|
// finish the flow: the token is already captured, and failing here would strand the person on a
|
||||||
|
// page that cannot do anything.
|
||||||
|
}
|
||||||
|
return landing;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
|
import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
|
||||||
import { EkklesiaUiStyles } from "@ekklesia/ui";
|
import { EkklesiaUiStyles } from "@ekklesia/ui";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js";
|
import { consumeAuthLanding } from "../auth-protocol.js";
|
||||||
import type { EkklesiaHostConfig } from "../host-protocol.js";
|
import type { EkklesiaHostConfig } from "../host-protocol.js";
|
||||||
import { HostApp } from "./HostApp.js";
|
import { HostApp } from "./HostApp.js";
|
||||||
|
|
||||||
@@ -26,36 +26,9 @@ const config: EkklesiaHostConfig = {
|
|||||||
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
|
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Read once, before anything renders, and stripped from the address bar in the same step. See
|
||||||
* Captures what an emailed verification or reset link asked for, then removes it from the address
|
* `consumeAuthLanding` for why the token must not stay in the URL. */
|
||||||
* bar.
|
const authLanding = consumeAuthLanding(window.location, window.history);
|
||||||
*
|
|
||||||
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
|
|
||||||
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
|
|
||||||
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
|
|
||||||
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
|
|
||||||
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
|
|
||||||
* the reset form, and never in `localStorage` or `sessionStorage`.
|
|
||||||
*
|
|
||||||
* The parse is a pure function that recognizes only Ekklesia's own two landings, so nothing in the
|
|
||||||
* query string can name a destination.
|
|
||||||
*/
|
|
||||||
function captureAuthLanding(): HostAuthLanding | undefined {
|
|
||||||
const landing = readAuthLanding(window.location.search);
|
|
||||||
if (!landing) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
window.history.replaceState(null, "", window.location.pathname);
|
|
||||||
} catch {
|
|
||||||
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
|
|
||||||
// finish the flow: the token is already captured, and failing here would strand the person on a
|
|
||||||
// page that cannot do anything.
|
|
||||||
}
|
|
||||||
return landing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authLanding = captureAuthLanding();
|
|
||||||
|
|
||||||
const container = document.getElementById("ekklesia-host");
|
const container = document.getElementById("ekklesia-host");
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Exact built files a deployable app may publish for the Ekklesia Dashboard (ADR 0044).
|
||||||
|
*
|
||||||
|
* The same shape as `./display-assets.ts`, for the same reason: these are file URLs relative to this
|
||||||
|
* package's compiled `dist/dashboard-assets.js`, not repository paths and not request-derived
|
||||||
|
* selectors. `apps/api` reads these exact two files before binding and serves their bytes at fixed
|
||||||
|
* host-owned routes on the same origin as `/api`. Nothing here exposes a directory or a generic
|
||||||
|
* static-file root, and the source map beside the script is deliberately not listed.
|
||||||
|
*/
|
||||||
|
const packageRoot = new URL("../", import.meta.url);
|
||||||
|
|
||||||
|
export const dashboardHostAssetUrls = Object.freeze({
|
||||||
|
html: new URL("dist/browser/index.html", packageRoot).href,
|
||||||
|
script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", packageRoot).href,
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||||
import { HostApp } from "../src/browser/HostApp.js";
|
import { HostApp } from "../src/browser/HostApp.js";
|
||||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||||
|
|
||||||
@@ -140,6 +140,58 @@ async function submit(hostTestId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("consuming an emailed-link landing", () => {
|
||||||
|
it("captures a reset token and strips the whole query from the address bar in place", () => {
|
||||||
|
window.history.replaceState({}, "", "/?ekklesia_auth=reset-password&token=secret-reset-token");
|
||||||
|
const lengthBefore = window.history.length;
|
||||||
|
|
||||||
|
const landing = consumeAuthLanding(window.location, window.history);
|
||||||
|
|
||||||
|
expect(landing).toEqual({ kind: "reset-password", token: "secret-reset-token" });
|
||||||
|
expect(window.location.search).toBe("");
|
||||||
|
expect(window.location.href).not.toContain("secret-reset-token");
|
||||||
|
// Replaced, not pushed: Back cannot return to the entry that held the token.
|
||||||
|
expect(window.history.length).toBe(lengthBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips every recognized landing, including a failed verification and an invalid reset link", () => {
|
||||||
|
for (const search of [
|
||||||
|
"?ekklesia_auth=verify-email",
|
||||||
|
"?ekklesia_auth=verify-email&error=TOKEN_EXPIRED",
|
||||||
|
"?ekklesia_auth=reset-password&error=INVALID_TOKEN",
|
||||||
|
"?ekklesia_auth=reset-password&token=not%20a%20valid%20token",
|
||||||
|
]) {
|
||||||
|
window.history.replaceState({}, "", `/${search}`);
|
||||||
|
|
||||||
|
expect(consumeAuthLanding(window.location, window.history), search).toBeDefined();
|
||||||
|
expect(window.location.search, search).toBe("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unrelated query untouched and rewrites nothing", () => {
|
||||||
|
window.history.replaceState({}, "", "/?unrelated=1");
|
||||||
|
const replaceState = vi.fn();
|
||||||
|
|
||||||
|
expect(consumeAuthLanding(window.location, { replaceState })).toBeUndefined();
|
||||||
|
expect(replaceState).not.toHaveBeenCalled();
|
||||||
|
expect(window.location.search).toBe("?unrelated=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still returns the captured landing when the browser refuses the rewrite", () => {
|
||||||
|
const replaceState = vi.fn(() => {
|
||||||
|
throw new Error("SecurityError");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
consumeAuthLanding(
|
||||||
|
{ pathname: "/", search: "?ekklesia_auth=reset-password&token=abc123" },
|
||||||
|
{ replaceState },
|
||||||
|
),
|
||||||
|
).toEqual({ kind: "reset-password", token: "abc123" });
|
||||||
|
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("emailed-link landing parsing", () => {
|
describe("emailed-link landing parsing", () => {
|
||||||
it("recognizes exactly Ekklesia's own two landings", () => {
|
it("recognizes exactly Ekklesia's own two landings", () => {
|
||||||
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
|
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { dashboardHostAssetUrls } from "../src/dashboard-assets.js";
|
||||||
|
|
||||||
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||||
|
|
||||||
|
describe("Dashboard host asset locator", () => {
|
||||||
|
it("exports only frozen immutable href strings for the two exact built files, never the source map", () => {
|
||||||
|
expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true);
|
||||||
|
expect(dashboardHostAssetUrls).toEqual({
|
||||||
|
html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href,
|
||||||
|
script: new URL(
|
||||||
|
"dist/browser/assets/ekklesia-dashboard/app.js",
|
||||||
|
`file://${packageDirectory}/`,
|
||||||
|
).href,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(dashboardHostAssetUrls)).not.toContain(".map");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||||
|
) as { exports?: Record<string, unknown> };
|
||||||
|
expect(manifest.exports?.["./dashboard-assets"]).toEqual({
|
||||||
|
import: "./dist/dashboard-assets.js",
|
||||||
|
types: "./dist/dashboard-assets.d.ts",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootSource = await readFile(path.join(packageDirectory, "src/index.ts"), "utf8");
|
||||||
|
expect(rootSource).not.toContain("dashboardHostAssetUrls");
|
||||||
|
expect(rootSource).not.toContain("dashboard-assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
|
||||||
|
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
||||||
|
|
||||||
|
expect(html).toContain('id="ekklesia-host"');
|
||||||
|
expect(html.match(/<script\b/gu)).toHaveLength(1);
|
||||||
|
expect(html).toContain(
|
||||||
|
'<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the Dashboard entry at the exact path the locator names", async () => {
|
||||||
|
const buildScript = await readFile(
|
||||||
|
path.join(packageDirectory, "scripts/build-browser.mjs"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join(');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
|
"src/dashboard-assets.ts",
|
||||||
"src/display-assets.ts",
|
"src/display-assets.ts",
|
||||||
"src/host-protocol.ts",
|
"src/host-protocol.ts",
|
||||||
"src/module-context.ts"
|
"src/module-context.ts"
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ describe("repository dependency boundaries", () => {
|
|||||||
["design-package-depends-upward", "design-packages-depend-downward-only"],
|
["design-package-depends-upward", "design-packages-depend-downward-only"],
|
||||||
["cross-package-relative", "workspace-imports-use-public-exports"],
|
["cross-package-relative", "workspace-imports-use-public-exports"],
|
||||||
["cycle", "no-circular-source-dependencies"],
|
["cycle", "no-circular-source-dependencies"],
|
||||||
|
["runtime-peer-only-dependency", "runtime-imports-are-production-dependencies"],
|
||||||
|
["runtime-dev-only-dependency", "runtime-imports-are-production-dependencies"],
|
||||||
|
["runtime-jsx-implies-react", "runtime-imports-are-production-dependencies"],
|
||||||
|
["runtime-undeclared-dependency", "runtime-imports-are-production-dependencies"],
|
||||||
])("rejects %s", async (fixture, expectedRule) => {
|
])("rejects %s", async (fixture, expectedRule) => {
|
||||||
const result = await checkBoundaries(path.join(fixtures, fixture));
|
const result = await checkBoundaries(path.join(fixtures, fixture));
|
||||||
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
|
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
|
||||||
@@ -106,6 +110,57 @@ describe("repository dependency boundaries", () => {
|
|||||||
expect(uiViolations.map((violation) => violation.message).join(" ")).not.toContain("compat");
|
expect(uiViolations.map((violation) => violation.message).join(" ")).not.toContain("compat");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts runtime imports declared as production dependencies, and everything that is not shipped runtime code", async () => {
|
||||||
|
// Production and optional dependencies, a peer kept beside its production declaration, Node
|
||||||
|
// built-ins, the package's own exports, type-only imports of development dependencies, and
|
||||||
|
// tests or build scripts that run with development dependencies installed.
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "legal-runtime-dependencies"));
|
||||||
|
|
||||||
|
expect(result.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"runtime-peer-only-dependency",
|
||||||
|
"packages/sdk/src/index.ts",
|
||||||
|
"drizzle-orm",
|
||||||
|
"only as a peer dependency",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"runtime-dev-only-dependency",
|
||||||
|
"packages/sdk/src/index.ts",
|
||||||
|
"zod",
|
||||||
|
"only as a development dependency",
|
||||||
|
],
|
||||||
|
// The automatic JSX runtime imports `react/jsx-runtime` without that import appearing in source.
|
||||||
|
[
|
||||||
|
"runtime-jsx-implies-react",
|
||||||
|
"packages/ui/src/index.tsx",
|
||||||
|
"react",
|
||||||
|
"only as a peer dependency",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"runtime-undeclared-dependency",
|
||||||
|
"modules/calendar/src/server/index.ts",
|
||||||
|
"sharp",
|
||||||
|
"not at all",
|
||||||
|
],
|
||||||
|
])("reports %s once, naming the file, the package, and how it is declared", async (fixture, from, to, declaredAs) => {
|
||||||
|
// A workspace package's imports resolve from its own node_modules: a peer is never satisfied
|
||||||
|
// there by the consumer, and a production install omits development dependencies, so each of
|
||||||
|
// these imports fails at run time in the production image even though development works.
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, fixture));
|
||||||
|
|
||||||
|
expect(result.violations).toEqual([
|
||||||
|
{
|
||||||
|
from,
|
||||||
|
message: expect.stringContaining(`declares it ${declaredAs}`),
|
||||||
|
rule: "runtime-imports-are-production-dependencies",
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("still rejects module UI source that reaches into Core internals", async () => {
|
it("still rejects module UI source that reaches into Core internals", async () => {
|
||||||
// Allowing `.tsx` inside a module changed which *formats* a module may contain. It did not
|
// Allowing `.tsx` inside a module changed which *formats* a module may contain. It did not
|
||||||
// change which layers a module may import: module UI is subject to the identical layer rules
|
// change which layers a module may import: module UI is subject to the identical layer rules
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
|
||||||
|
* be active on one runner at once (and the inventoried Gitea ignores workflow `concurrency`), so
|
||||||
|
* nothing they start may claim a fixed host port, a fixed project or network name, or a fixed
|
||||||
|
* subnet. And when a disposable project does not become ready, the failure must say why without
|
||||||
|
* printing a credential.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const ciScripts = [
|
||||||
|
"scripts/test-migrations-integration.sh",
|
||||||
|
"scripts/smoke-infrastructure.sh",
|
||||||
|
"scripts/smoke-deploy.sh",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function source(relativePath: string): Promise<string> {
|
||||||
|
return readFile(path.join(repositoryRoot, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the real helper under bash with the given literal secrets and input. */
|
||||||
|
async function redact(input: string, secrets: readonly string[] = []): Promise<string> {
|
||||||
|
const helper = path.join(repositoryRoot, "scripts", "lib", "compose-diagnostics.sh");
|
||||||
|
const script = `source "$1"; shift; COMPOSE_DIAGNOSTICS_SECRETS=("$@"); printf '%s' "$INPUT" | compose_diagnostics_redact`;
|
||||||
|
const { stdout } = await execFileAsync("bash", ["-c", script, "redact", helper, ...secrets], {
|
||||||
|
env: { ...process.env, INPUT: input },
|
||||||
|
});
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CI scripts on a shared Docker daemon", () => {
|
||||||
|
it("publish every host port ephemerally, never a fixed one", async () => {
|
||||||
|
const migrations = await source("scripts/test-migrations-integration.sh");
|
||||||
|
// `.env.example` pins both ports for local development; the suite must override both.
|
||||||
|
expect(migrations).toContain('export POSTGRES_PORT="0"');
|
||||||
|
expect(migrations).toContain('export S3_PORT="0"');
|
||||||
|
|
||||||
|
const infrastructure = await source("scripts/smoke-infrastructure.sh");
|
||||||
|
expect(infrastructure).toContain('export POSTGRES_PORT="${EKKLESIA_SMOKE_POSTGRES_PORT:-0}"');
|
||||||
|
expect(infrastructure).toContain('export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"');
|
||||||
|
|
||||||
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
|
expect(deploy).toContain("EKKLESIA_HTTP_PORT=0");
|
||||||
|
expect(deploy).toContain("EKKLESIA_HTTPS_PORT=0");
|
||||||
|
|
||||||
|
for (const relativePath of ciScripts) {
|
||||||
|
const text = await source(relativePath);
|
||||||
|
// No `-p 8333:…`, `127.0.0.1:5432:…`, or `PORT=<non-zero>` anywhere.
|
||||||
|
expect(text, relativePath).not.toMatch(/\b(?:-p|--publish)\s+[^\s]*\d{2,5}:\d/u);
|
||||||
|
expect(text, relativePath).not.toMatch(/\b[A-Z_]*PORT="?[1-9]\d*"?\s*$/mu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("give every Compose project a name unique to the invocation, not only the run", async () => {
|
||||||
|
expect(await source("scripts/test-migrations-integration.sh")).toMatch(
|
||||||
|
/raw_project="ekklesia-migration-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
||||||
|
);
|
||||||
|
expect(await source("scripts/smoke-infrastructure.sh")).toMatch(
|
||||||
|
/raw_project="ekklesia-smoke-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
||||||
|
);
|
||||||
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
|
expect(deploy).toContain('run_id="$(openssl rand -hex 4)"');
|
||||||
|
expect(deploy).toContain('project="ekklesia-deploy-smoke-${run_id}"');
|
||||||
|
for (const relativePath of ciScripts) {
|
||||||
|
expect(await source(relativePath), relativePath).toContain('--project-name "$project"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
||||||
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
|
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
||||||
|
expect(deploy).toContain("choose_edge_network() {");
|
||||||
|
expect(deploy).toContain("docker network inspect --format '{{range .IPAM.Config}}");
|
||||||
|
expect(deploy).toContain('[[ "$up_output" == *"overlap"* ]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
|
||||||
|
const workflow = await source(".github/workflows/ci.yml");
|
||||||
|
expect(workflow).toContain(
|
||||||
|
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"',
|
||||||
|
);
|
||||||
|
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
|
||||||
|
for (const relativePath of ciScripts) {
|
||||||
|
const text = await source(relativePath);
|
||||||
|
expect(text, relativePath).toContain(
|
||||||
|
'source "$repository_root/scripts/lib/compose-diagnostics.sh"',
|
||||||
|
);
|
||||||
|
expect(text, relativePath).toContain("compose_report_not_ready");
|
||||||
|
expect(text, relativePath).toContain("COMPOSE_DIAGNOSTICS_SECRETS=(");
|
||||||
|
// Compose output is never printed raw: logs are only ever captured (the deploy smoke scans
|
||||||
|
// the API's for secrets), and a readiness failure's own output is never discarded.
|
||||||
|
for (const line of text
|
||||||
|
.split("\n")
|
||||||
|
.filter((candidate) => candidate.includes('compose[@]}" logs'))) {
|
||||||
|
expect(line, relativePath).toMatch(/="\$\("\$\{compose\[@\]\}" logs /u);
|
||||||
|
}
|
||||||
|
expect(text, relativePath).not.toMatch(/up --detach --wait[^\n]*>\/dev\/null 2>&1/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compose diagnostics redaction", () => {
|
||||||
|
it("masks credential-looking values, URL user information, and Authorization headers", async () => {
|
||||||
|
const output = await redact(
|
||||||
|
[
|
||||||
|
"POSTGRES_PASSWORD=hunter2hunter2",
|
||||||
|
' "AWS_SECRET_ACCESS_KEY": "abc/def+ghi"',
|
||||||
|
"S3_ACCESS_KEY=AKIAEXAMPLE other=fine",
|
||||||
|
"api_key: xyz123",
|
||||||
|
"connecting to postgresql://ekklesia:s3cr3t@postgres:5432/ekklesia",
|
||||||
|
"Authorization: Bearer eyJhbGciOi.payload.sig",
|
||||||
|
"EKKLESIA_IDENTITY_SECRET=0123456789abcdef",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const secret of [
|
||||||
|
"hunter2hunter2",
|
||||||
|
"abc/def+ghi",
|
||||||
|
"AKIAEXAMPLE",
|
||||||
|
"xyz123",
|
||||||
|
"s3cr3t",
|
||||||
|
"eyJhbGciOi",
|
||||||
|
"0123456789abcdef",
|
||||||
|
]) {
|
||||||
|
expect(output).not.toContain(secret);
|
||||||
|
}
|
||||||
|
expect(output).toContain("POSTGRES_PASSWORD=[redacted]");
|
||||||
|
expect(output).toContain("other=fine");
|
||||||
|
expect(output).toContain("postgresql://[redacted]@postgres:5432/ekklesia");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks every occurrence of a supplied literal, wherever it appears", async () => {
|
||||||
|
const output = await redact("started with temporary-pw-12345 and again temporary-pw-12345", [
|
||||||
|
"temporary-pw-12345",
|
||||||
|
]);
|
||||||
|
expect(output).toBe("started with [redacted] and again [redacted]\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the diagnosis itself readable", async () => {
|
||||||
|
const diagnosis =
|
||||||
|
"Error response from daemon: Bind for 127.0.0.1:8333 failed: port is already allocated";
|
||||||
|
expect(await redact(diagnosis)).toBe(`${diagnosis}\n`);
|
||||||
|
expect(await redact("duplicate key value violates unique constraint")).toBe(
|
||||||
|
"duplicate key value violates unique constraint\n",
|
||||||
|
);
|
||||||
|
expect(await redact('password authentication failed for user "ekklesia"')).toBe(
|
||||||
|
'password authentication failed for user "ekklesia"\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips terminal control sequences from container logs", async () => {
|
||||||
|
expect(await redact("\u001b[2Kpostgres-1 | ready")).toBe("postgres-1 | ready\n");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural guarantees of the reference deployment (ADR 0044), checked on the files themselves so
|
||||||
|
* an edit cannot quietly weaken them. `scripts/smoke-deploy.sh` proves the same configuration
|
||||||
|
* actually runs; this proves it still says what the documentation promises.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ComposeService {
|
||||||
|
build?: unknown;
|
||||||
|
cap_add?: string[];
|
||||||
|
cap_drop?: string[];
|
||||||
|
command?: string[];
|
||||||
|
env_file?: string[];
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
healthcheck?: { disable?: boolean };
|
||||||
|
image?: string;
|
||||||
|
init?: boolean;
|
||||||
|
networks?: Record<string, { ipv4_address?: string } | null>;
|
||||||
|
ports?: string[];
|
||||||
|
profiles?: string[];
|
||||||
|
read_only?: boolean;
|
||||||
|
security_opt?: string[];
|
||||||
|
tmpfs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposeDocument {
|
||||||
|
networks?: Record<
|
||||||
|
string,
|
||||||
|
{ internal?: boolean; ipam?: { config?: { subnet?: string; ip_range?: string }[] } }
|
||||||
|
>;
|
||||||
|
services?: Record<string, ComposeService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const deployDirectory = path.join(repositoryRoot, "deploy");
|
||||||
|
|
||||||
|
async function compose(file: string): Promise<ComposeDocument> {
|
||||||
|
// `merge: true` resolves the base file's `<<:` hardening anchors exactly as Compose does.
|
||||||
|
return parse(await readFile(path.join(deployDirectory, file), "utf8"), {
|
||||||
|
merge: true,
|
||||||
|
}) as ComposeDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function text(file: string): Promise<string> {
|
||||||
|
return readFile(path.join(deployDirectory, file), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function envAssignments(source: string): Map<string, string> {
|
||||||
|
const assignments = new Map<string, string>();
|
||||||
|
for (const line of source.split("\n")) {
|
||||||
|
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line);
|
||||||
|
if (match?.[1]) {
|
||||||
|
assignments.set(match[1], match[2] ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIGEST_PINNED = /^[^\s:@]+(?::[^\s@]+)?@sha256:[0-9a-f]{64}$/u;
|
||||||
|
|
||||||
|
describe("reference deployment compose files", () => {
|
||||||
|
it("keeps PostgreSQL on its internal network only, and publishes no port from the base", async () => {
|
||||||
|
const base = await compose("compose.yaml");
|
||||||
|
const services = base.services ?? {};
|
||||||
|
|
||||||
|
expect(Object.keys(services).sort()).toEqual(["api", "migrate", "object-storage", "postgres"]);
|
||||||
|
expect(Object.keys(services.postgres?.networks ?? {})).toEqual(["data"]);
|
||||||
|
expect(base.networks?.data?.internal).toBe(true);
|
||||||
|
expect(base.networks?.storage?.internal).toBe(true);
|
||||||
|
for (const [name, service] of Object.entries(services)) {
|
||||||
|
expect(service.ports, name).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
|
||||||
|
const document = await compose(overlay);
|
||||||
|
expect(document.services?.postgres, overlay).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins every third-party image by digest and runs the API only from EKKLESIA_IMAGE", async () => {
|
||||||
|
const documents = await Promise.all(
|
||||||
|
["compose.yaml", "compose.proxy.yaml", "compose.tunnel.yaml"].map(compose),
|
||||||
|
);
|
||||||
|
for (const document of documents) {
|
||||||
|
for (const [name, service] of Object.entries(document.services ?? {})) {
|
||||||
|
expect(service.build, name).toBeUndefined();
|
||||||
|
if (service.image === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name === "api" || name === "migrate") {
|
||||||
|
expect(service.image, name).toMatch(/^\$\{EKKLESIA_IMAGE:\?/u);
|
||||||
|
} else {
|
||||||
|
expect(service.image, name).toMatch(DIGEST_PINNED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hardens api and migrate, keeps migrate operator-only, and never lets it apply by default", async () => {
|
||||||
|
const services = (await compose("compose.yaml")).services ?? {};
|
||||||
|
for (const name of ["api", "migrate"]) {
|
||||||
|
const service = services[name];
|
||||||
|
expect(service?.init, name).toBe(true);
|
||||||
|
expect(service?.read_only, name).toBe(true);
|
||||||
|
expect(service?.tmpfs, name).toEqual(["/tmp"]);
|
||||||
|
expect(service?.cap_drop, name).toEqual(["ALL"]);
|
||||||
|
expect(service?.security_opt, name).toContain("no-new-privileges:true");
|
||||||
|
expect(service?.environment?.NODE_EXTRA_CA_CERTS, name).toBe("/run/ekklesia/tls/ca.crt");
|
||||||
|
}
|
||||||
|
const migrate = services.migrate;
|
||||||
|
expect(migrate?.profiles).toEqual(["ops"]);
|
||||||
|
expect(migrate?.healthcheck?.disable).toBe(true);
|
||||||
|
expect(migrate?.command).toEqual(["node", "core/dist/cli/plan-migrations.js"]);
|
||||||
|
expect(JSON.stringify(services)).not.toContain("apply-migrations");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves PostgreSQL and object storage over TLS from the installation-private CA", async () => {
|
||||||
|
const services = (await compose("compose.yaml")).services ?? {};
|
||||||
|
expect(services.postgres?.command).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"ssl=on",
|
||||||
|
"ssl_min_protocol_version=TLSv1.3",
|
||||||
|
"hba_file=/etc/ekklesia/pg_hba.conf",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(services["object-storage"]?.command).toEqual(
|
||||||
|
expect.arrayContaining(["-s3.port.https=8443"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hba = (await text("postgres/pg_hba.conf"))
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim() !== "" && !line.trim().startsWith("#"))
|
||||||
|
.map((line) => line.trim().split(/\s+/u));
|
||||||
|
expect(hba).toEqual([
|
||||||
|
["local", "all", "all", "trust"],
|
||||||
|
["hostssl", "all", "all", "all", "scram-sha-256"],
|
||||||
|
["hostnossl", "all", "all", "all", "reject"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the proxy the API's only trusted peer, at a fixed address outside the dynamic range", async () => {
|
||||||
|
const proxyOverlay = await compose("compose.proxy.yaml");
|
||||||
|
const proxy = proxyOverlay.services?.proxy;
|
||||||
|
const proxyAddress = proxy?.networks?.edge?.ipv4_address;
|
||||||
|
expect(proxyAddress).toBe("${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}");
|
||||||
|
expect(proxyOverlay.services?.api?.environment).toEqual({
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for",
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
|
||||||
|
});
|
||||||
|
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
|
||||||
|
{
|
||||||
|
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
|
||||||
|
subnet: "${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(proxy?.cap_drop).toEqual(["ALL"]);
|
||||||
|
expect(proxy?.cap_add).toEqual(["NET_BIND_SERVICE"]);
|
||||||
|
|
||||||
|
const tunnel = (await compose("compose.tunnel.yaml")).services?.tunnel;
|
||||||
|
expect(tunnel?.networks?.edge?.ipv4_address).toBe("${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}");
|
||||||
|
expect(tunnel?.env_file).toEqual(["${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes only the API's own paths, overwrites the forwarding header, and never ships local_certs", async () => {
|
||||||
|
const caddyfile = await text("Caddyfile.example");
|
||||||
|
const directives = caddyfile
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.trim().startsWith("#"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
|
||||||
|
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
|
||||||
|
expect(directives).toContain("header_up -CF-Connecting-IP");
|
||||||
|
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
|
||||||
|
expect(directives).toContain("header_up Host {host}");
|
||||||
|
expect(directives).toContain("tls_trust_pool file /etc/caddy/ekklesia-ca.crt");
|
||||||
|
// Caddy's internal CA is a test-only substitution made by scripts/smoke-deploy.sh.
|
||||||
|
expect(directives).not.toContain("local_certs");
|
||||||
|
expect(directives).not.toMatch(/tls\s+internal/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reference deployment env examples", () => {
|
||||||
|
it("contain no secret values", async () => {
|
||||||
|
const secretKeys = new Map([
|
||||||
|
["env/ekklesia.env.example", ["EKKLESIA_IDENTITY_SECRET", "S3_ACCESS_KEY", "S3_SECRET_KEY"]],
|
||||||
|
["env/postgres.env.example", ["POSTGRES_PASSWORD"]],
|
||||||
|
["env/object-storage.env.example", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]],
|
||||||
|
["env/tunnel.env.example", ["TUNNEL_TOKEN"]],
|
||||||
|
]);
|
||||||
|
for (const [file, keys] of secretKeys) {
|
||||||
|
const assignments = envAssignments(await text(file));
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(assignments.get(key), `${file} ${key}`).toBe("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ekklesia = envAssignments(await text("env/ekklesia.env.example"));
|
||||||
|
expect(ekklesia.get("DATABASE_URL")).toBe(
|
||||||
|
"postgresql://<user>:<password>@postgres:5432/<database>",
|
||||||
|
);
|
||||||
|
expect(ekklesia.get("EKKLESIA_DATABASE_TLS_MODE")).toBe("verify-full");
|
||||||
|
expect(ekklesia.get("S3_ENDPOINT")).toBe("https://object-storage:8443");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names every variable Core's host configuration reads", async () => {
|
||||||
|
// The single place executables read the environment (core/src/host/configuration.ts). A new
|
||||||
|
// variable there without a line here would leave an operator guessing.
|
||||||
|
const hostConfiguration = await readFile(
|
||||||
|
path.join(repositoryRoot, "core", "src", "host", "configuration.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const read = new Set(
|
||||||
|
[...hostConfiguration.matchAll(/environment\.([A-Z][A-Z0-9_]+)/gu)].map((match) => match[1]),
|
||||||
|
);
|
||||||
|
const example = await text("env/ekklesia.env.example");
|
||||||
|
// Set by the image, not by an operator.
|
||||||
|
read.delete("NODE_ENV");
|
||||||
|
// Guards against a pattern that silently stops matching and checks nothing.
|
||||||
|
expect(read.size).toBeGreaterThan(20);
|
||||||
|
for (const name of read) {
|
||||||
|
expect(example, String(name)).toMatch(new RegExp(`^#? ?${name}=`, "mu"));
|
||||||
|
}
|
||||||
|
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
|
||||||
|
});
|
||||||
|
});
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/sdk",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"polyfill": "1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
|
"react": "19.2.8"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "0.28.1",
|
||||||
|
"type-only": "1.0.0",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./schema": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module-sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
await build({ entryPoints: ["src/index.ts"] });
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
describe("sdk", () => {
|
||||||
|
it("runs with development dependencies installed", () => undefined);
|
||||||
|
});
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import "polyfill";
|
||||||
|
import { pgTableCreator } from "drizzle-orm/pg-core";
|
||||||
|
import type { Config } from "type-only";
|
||||||
|
import { type Options, type Settings } from "type-only/settings";
|
||||||
|
export type { Shape } from "type-only/shape";
|
||||||
|
export { createSchema } from "@ekklesia/sdk/schema";
|
||||||
|
|
||||||
|
export async function load(config: Config, options: Options, settings: Settings) {
|
||||||
|
const watcher = await import("fsevents");
|
||||||
|
return { config, options, path, readFile, settings, table: pgTableCreator, watcher };
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export function View() {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
export const builder = build;
|
||||||
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/module-ui-host": "workspace:*"
|
"@ekklesia/module-ui-host": "workspace:*",
|
||||||
|
"react": "19.2.8"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
|
|||||||
+3
@@ -2,6 +2,9 @@
|
|||||||
"name": "@ekklesia/module-announcements",
|
"name": "@ekklesia/module-announcements",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "19.2.8"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/sdk",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": { "zod": "4.0.0" },
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module-sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const identifier = z.string();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/ui",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"peerDependencies": { "react": "19.2.8" },
|
||||||
|
"devDependencies": { "react": "19.2.8" },
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "ui"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function Badge(props: { readonly label: string }) {
|
||||||
|
return <span>{props.label}</span>;
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/sdk",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"peerDependencies": { "drizzle-orm": "0.45.2" },
|
||||||
|
"devDependencies": { "drizzle-orm": "0.45.2" },
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module-sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { pgTableCreator } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const createTable = pgTableCreator((name) => name);
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-calendar",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export async function loadSharp() {
|
||||||
|
return await import("sharp");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user