Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
650932a170 | ||
|
|
ae976ca229 | ||
|
|
f9585cc7ec | ||
|
|
6dae29412f | ||
|
|
40ef0ff802 | ||
|
|
a0e9948820 | ||
|
|
6e64b3c5f9 | ||
|
|
fa27603133 | ||
|
|
9e01679fcb | ||
|
|
227c92e35d |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+7
-1
@@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
|
|||||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.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, renewDeviceSessionCookie } 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 { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
@@ -407,6 +407,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||||
|
...(options.devices
|
||||||
|
? {
|
||||||
|
renewDeviceCredential: (c: Context) =>
|
||||||
|
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.devices) {
|
if (options.devices) {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
|||||||
*/
|
*/
|
||||||
function writeDeviceCookie(
|
function writeDeviceCookie(
|
||||||
c: Context,
|
c: Context,
|
||||||
options: DeviceRoutesOptions,
|
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||||
name: string,
|
name: string,
|
||||||
value: string,
|
value: string,
|
||||||
maxAgeSeconds: number,
|
maxAgeSeconds: number,
|
||||||
@@ -106,6 +106,27 @@ function writeDeviceCookie(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
|
||||||
|
*
|
||||||
|
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
|
||||||
|
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
|
||||||
|
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
|
||||||
|
*/
|
||||||
|
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
|
||||||
|
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
|
||||||
|
if (credential === undefined || credential.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
{ secureCookies },
|
||||||
|
DEVICE_SESSION_COOKIE,
|
||||||
|
credential,
|
||||||
|
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||||
const routes = new HonoApp();
|
const routes = new HonoApp();
|
||||||
const now = options.now ?? ((): Date => new Date());
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
|
|||||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||||
* surface-owned files before binding and supplies their frozen contents here. */
|
* surface-owned files before binding and supplies their frozen contents here. */
|
||||||
readonly assets?: DisplayHostAssets;
|
readonly assets?: DisplayHostAssets;
|
||||||
|
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
|
||||||
|
* deployment has device support; called only for a request that resolved to a device. */
|
||||||
|
readonly renewDeviceCredential?: (c: Context) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||||
@@ -199,6 +202,11 @@ function mountDisplay(
|
|||||||
if (!viewer) {
|
if (!viewer) {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
}
|
}
|
||||||
|
// Every screen asks for this on start, on reconnect, and periodically while running, so it
|
||||||
|
// is where an in-use credential's browser expiry is pushed forward.
|
||||||
|
if (principal.kind === "device") {
|
||||||
|
options.renewDeviceCredential?.(c);
|
||||||
|
}
|
||||||
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||||
} catch {
|
} catch {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
|
|||||||
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
|
||||||
|
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
|
||||||
|
*/
|
||||||
|
describe("credential lifetime", () => {
|
||||||
|
const ttlSeconds = 180 * 24 * 60 * 60;
|
||||||
|
const descriptor = "/api/v1/module-displays/announcements";
|
||||||
|
|
||||||
|
function renewal(response: Response): string | undefined {
|
||||||
|
return setCookieFor(response, "ekklesia_device_session");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
|
||||||
|
const running = await platform("renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// The same credential, re-issued with a full lifetime and the same protections.
|
||||||
|
const header = renewal(response);
|
||||||
|
expect(header).toBeDefined();
|
||||||
|
expect(header).toContain(`ekklesia_device_session=${credential};`);
|
||||||
|
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
|
||||||
|
expect(header).toMatch(/HttpOnly/u);
|
||||||
|
expect(header).toMatch(/SameSite=Strict/u);
|
||||||
|
expect(header).toMatch(/Path=\//u);
|
||||||
|
expect(header).not.toMatch(/Domain=/iu);
|
||||||
|
|
||||||
|
// Using it moved the server's window forward too.
|
||||||
|
const [row] = await query<{ recent: boolean }>(
|
||||||
|
running.url,
|
||||||
|
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
|
||||||
|
);
|
||||||
|
expect(row?.recent).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
|
||||||
|
const running = await platform("legacy");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
// A production screen paired 179 days ago that checked in this morning: under the old
|
||||||
|
// rule it had one day left. It must carry straight on.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
|
||||||
|
const running = await platform("abandoned");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never renews a revoked screen", async () => {
|
||||||
|
const running = await platform("revoked-renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const listed = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string }> };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hand a device cookie to a person viewing the display", async () => {
|
||||||
|
const running = await platform("human-renewal");
|
||||||
|
try {
|
||||||
|
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||||
const running = await platform("mixed");
|
const running = await platform("mixed");
|
||||||
const device = new DeviceBrowser(running.origin);
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
|
|||||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a device credential is accepted before the device must pair again.
|
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
|
||||||
*
|
*
|
||||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||||
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||||
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
|
|
||||||
*/
|
*/
|
||||||
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
/** When a device credential expires: the idle window, measured from the last time the device was
|
||||||
|
* seen, or from pairing if it never has been. */
|
||||||
|
export function deviceCredentialExpiresAt(input: {
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly lastSeenAt?: Date | null | undefined;
|
||||||
|
}): Date {
|
||||||
|
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
|
||||||
|
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
||||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||||
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
|||||||
}
|
}
|
||||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||||
// checked above and is what makes a long horizon acceptable.
|
// checked above and is what makes a long horizon acceptable.
|
||||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
|
||||||
|
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
|
isModuleStorageError,
|
||||||
isValidModuleStorageKey,
|
isValidModuleStorageKey,
|
||||||
MODULE_STORAGE_KEY_MAX_BYTES,
|
MODULE_STORAGE_KEY_MAX_BYTES,
|
||||||
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
||||||
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recognizes a storage error from an installed module's separate package copy", () => {
|
||||||
|
const external = Object.assign(new Error("The storage service could not be reached."), {
|
||||||
|
code: "STORAGE_PROVIDER_UNAVAILABLE",
|
||||||
|
name: "ModuleStorageError",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external).not.toBeInstanceOf(ModuleStorageError);
|
||||||
|
expect(isModuleStorageError(external)).toBe(true);
|
||||||
|
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
||||||
const shared = fakeProvider();
|
const shared = fakeProvider();
|
||||||
const announcements = createModuleStorage({
|
const announcements = createModuleStorage({
|
||||||
|
|||||||
+2
-2
@@ -511,10 +511,10 @@ finds its files:
|
|||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
--file deploy/Dockerfile \
|
--file deploy/Dockerfile \
|
||||||
--build-arg VERSION=0.2.2 \
|
--build-arg VERSION=0.2.4 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.2.2 \
|
--tag ekklesia:0.2.4 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ services:
|
|||||||
command:
|
command:
|
||||||
- mini
|
- mini
|
||||||
- -dir=/data
|
- -dir=/data
|
||||||
|
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
|
||||||
|
# volume size leaves modest hosts with room for only a few collections; after that, writes
|
||||||
|
# stall instead of failing. This changes allocation granularity, not object or total capacity.
|
||||||
|
- -master.volumeSizeLimitMB=32
|
||||||
- -s3.port.https=8443
|
- -s3.port.https=8443
|
||||||
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||||
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- Status: Accepted
|
- Status: Accepted
|
||||||
- Date: 2026-08-24
|
- Date: 2026-08-24
|
||||||
|
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
|
||||||
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
||||||
[0032](0032-native-module-realtime-transport.md),
|
[0032](0032-native-module-realtime-transport.md),
|
||||||
[0033](0033-display-kiosk-host-contract.md),
|
[0033](0033-display-kiosk-host-contract.md),
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 0056: A device credential lasts while the device is in use
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-12
|
||||||
|
- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md).
|
||||||
|
Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every
|
||||||
|
request, re-pairing as a new identity — is unchanged.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a
|
||||||
|
ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually
|
||||||
|
stop being an identity.
|
||||||
|
|
||||||
|
A fixed horizon does the second and fails the first. Every screen in the building expires on the
|
||||||
|
anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code
|
||||||
|
in front of the congregation until someone with Dashboard access walks to it. For screens paired
|
||||||
|
together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy
|
||||||
|
on day 181 than on day 179; only the calendar changed.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the
|
||||||
|
device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180
|
||||||
|
days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one
|
||||||
|
write per 15 minutes), so the server-side window slides with use and needs no new column or
|
||||||
|
migration.
|
||||||
|
|
||||||
|
**The browser's copy is renewed by the host, not rotated.** The display descriptor —
|
||||||
|
`GET /api/v1/module-displays/<module>`, which every display requests when it starts, when it
|
||||||
|
reconnects, and periodically while it runs — re-issues the device cookie that authenticated the
|
||||||
|
request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`,
|
||||||
|
`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged.
|
||||||
|
|
||||||
|
Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked,
|
||||||
|
expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a
|
||||||
|
display never receives a device cookie.
|
||||||
|
|
||||||
|
**Revocation remains the boundary.** It is still checked on every request, and an administrator's
|
||||||
|
revoke still takes effect immediately.
|
||||||
|
|
||||||
|
## Why not rotate
|
||||||
|
|
||||||
|
Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and
|
||||||
|
could reveal a cloned credential. For an unattended screen it trades that for a new way to strand
|
||||||
|
the display: a renewal response lost to a network drop, a power cut, or a browser that never
|
||||||
|
persisted the new cookie leaves the screen holding a dead credential and showing a pairing code,
|
||||||
|
which is the failure this decision exists to remove. A refresh-token scheme with overlap windows
|
||||||
|
could avoid that, at the cost of real complexity in the one place mistakes are invisible until a
|
||||||
|
wall goes dark. That is not justified while revocation is immediate and a device principal can only
|
||||||
|
read the displays it was paired for.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A screen that is used at least once every 180 days stays paired until an administrator revokes
|
||||||
|
it. An abandoned screen still stops being an identity 180 days after it was last seen.
|
||||||
|
- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next
|
||||||
|
time it requests its descriptor, provided its browser still holds the cookie.
|
||||||
|
- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked.
|
||||||
|
Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a
|
||||||
|
device may do — the display permissions of the module it was paired for, never a person's access
|
||||||
|
— and by revocation. Operators can see each device's last-seen time and revoke anything
|
||||||
|
unexpected.
|
||||||
|
- A credential is not bound to a browser or network. Like a human session cookie, whoever presents
|
||||||
|
it is the device.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired
|
||||||
|
together fail together.
|
||||||
|
- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and
|
||||||
|
its credential would never stop working without someone remembering to revoke it.
|
||||||
|
- **Rotate on renewal.** Rejected for now; see above.
|
||||||
|
- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared
|
||||||
|
middleware that never writes responses. One endpoint every display already requests periodically
|
||||||
|
is enough, and keeps the behaviour in one visible place.
|
||||||
@@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
||||||
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
||||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056)
|
||||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||||
@@ -64,3 +64,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
|
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
|
||||||
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
|
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
|
||||||
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
|
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
|
||||||
|
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
|
||||||
|
*
|
||||||
|
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
|
||||||
|
* by the host's storage provider does not share class identity with the module's
|
||||||
|
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
|
||||||
|
* module boundary.
|
||||||
|
*/
|
||||||
|
export function isModuleStorageError(
|
||||||
|
value: unknown,
|
||||||
|
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
|
||||||
|
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { code, key } = value as { code?: unknown; key?: unknown };
|
||||||
|
return (
|
||||||
|
typeof code === "string" &&
|
||||||
|
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
|
||||||
|
(key === undefined || typeof key === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
|
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
|
||||||
* not characters, because that is what the wire format bounds. */
|
* not characters, because that is what the wire format bounds. */
|
||||||
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/design-tokens",
|
"name": "@ekklesia/design-tokens",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-database",
|
"name": "@ekklesia/module-database",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-pack",
|
"name": "@ekklesia/module-pack",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-sdk",
|
"name": "@ekklesia/module-sdk",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export {
|
|||||||
isDevicePrincipal,
|
isDevicePrincipal,
|
||||||
isModuleHttpMethod,
|
isModuleHttpMethod,
|
||||||
isModuleRouteBodyMode,
|
isModuleRouteBodyMode,
|
||||||
|
isModuleStorageError,
|
||||||
isValidModuleStorageKey,
|
isValidModuleStorageKey,
|
||||||
isValidModuleStorageReadUrlFilename,
|
isValidModuleStorageReadUrlFilename,
|
||||||
MODULE_HTTP_BODY_MODES,
|
MODULE_HTTP_BODY_MODES,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-build",
|
"name": "@ekklesia/module-ui-build",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-host",
|
"name": "@ekklesia/module-ui-host",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-sdk",
|
"name": "@ekklesia/module-ui-sdk",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/typescript-config",
|
"name": "@ekklesia/typescript-config",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/ui",
|
"name": "@ekklesia/ui",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ function required(name) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optional(name) {
|
||||||
|
return process.env[name] || "";
|
||||||
|
}
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
|
|
||||||
function check(condition, description) {
|
function check(condition, description) {
|
||||||
@@ -462,37 +466,51 @@ async function ownerCookie() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what
|
* The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
|
||||||
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all;
|
* when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
|
||||||
* present, its records route returns exactly the expected values, which proves its tables, its
|
* retains its stronger records assertion, which also proves its database facade.
|
||||||
* dependencies, and the platform's database facade all work from module storage.
|
|
||||||
*/
|
*/
|
||||||
async function moduleChecks() {
|
async function moduleChecks() {
|
||||||
const expectation = required("SMOKE_MODULE_EXPECT");
|
const expectation = required("SMOKE_MODULE_EXPECT");
|
||||||
|
const moduleId = optional("SMOKE_MODULE_ID") || "example";
|
||||||
|
const probePath = optional("SMOKE_MODULE_PROBE_PATH");
|
||||||
const cookie = await ownerCookie();
|
const cookie = await ownerCookie();
|
||||||
const headers = { cookie, origin: dashboardOrigin };
|
const headers = { cookie, origin: dashboardOrigin };
|
||||||
|
|
||||||
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
|
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
|
||||||
const inventoryBody = await inventory.json();
|
const inventoryBody = await inventory.json();
|
||||||
const installed =
|
const installed =
|
||||||
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example");
|
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
|
||||||
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
|
||||||
|
|
||||||
if (expectation === "absent") {
|
if (expectation === "absent") {
|
||||||
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
|
check(
|
||||||
|
inventory.status === 200 && !installed,
|
||||||
|
`the module inventory has no \`${moduleId}\` module`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
check(installed, `the module inventory lists the \`${moduleId}\` module`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleId === "example") {
|
||||||
|
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
||||||
|
if (expectation === "absent") {
|
||||||
check(
|
check(
|
||||||
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
|
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
|
||||||
"the absent module's routes are not mounted",
|
"the absent module's routes are not mounted",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const expected = JSON.parse(expectation);
|
const expected = JSON.parse(expectation);
|
||||||
check(installed, "the module inventory lists the `example` module");
|
|
||||||
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
|
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
|
||||||
check(
|
check(
|
||||||
JSON.stringify(values) === JSON.stringify(expected),
|
JSON.stringify(values) === JSON.stringify(expected),
|
||||||
`the module's records route returns ${JSON.stringify(expected)}`,
|
`the module's records route returns ${JSON.stringify(expected)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (expectation !== "absent" && probePath) {
|
||||||
|
const probe = await fetch(`${apiOrigin}${probePath}`, { headers });
|
||||||
|
await probe.arrayBuffer();
|
||||||
|
check(probe.status === 200, `the module probe ${probePath} responds`);
|
||||||
|
}
|
||||||
|
|
||||||
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
||||||
body: "{}",
|
body: "{}",
|
||||||
|
|||||||
@@ -426,7 +426,9 @@ async function seedSignage(owner) {
|
|||||||
const base = "/api/v1/modules/signage";
|
const base = "/api/v1/modules/signage";
|
||||||
const images = [];
|
const images = [];
|
||||||
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
||||||
const uploaded = await owner.evaluate(
|
let uploaded;
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||||
|
uploaded = await owner.evaluate(
|
||||||
`(async () => {
|
`(async () => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = 640;
|
canvas.width = 640;
|
||||||
@@ -435,14 +437,37 @@ async function seedSignage(owner) {
|
|||||||
context.fillStyle = ${JSON.stringify(colour)};
|
context.fillStyle = ${JSON.stringify(colour)};
|
||||||
context.fillRect(0, 0, 640, 360);
|
context.fillRect(0, 0, 640, 360);
|
||||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||||
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||||
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||||
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||||
});
|
}).then(
|
||||||
return { status: response.status, body: await response.json().catch(() => null) };
|
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
|
||||||
|
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
|
||||||
|
);
|
||||||
})()`,
|
})()`,
|
||||||
`uploading slide ${index}`,
|
`uploading slide ${index}`,
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
attempt === 1 &&
|
||||||
|
uploaded.status === 503 &&
|
||||||
|
uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE"
|
||||||
|
) {
|
||||||
|
note(`slide ${index} met one transient storage outage; retrying once`);
|
||||||
|
await sleep(1_000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (uploaded.status === "timeout") {
|
||||||
|
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
|
||||||
|
}
|
||||||
|
if (uploaded.status !== 200 && uploaded.status !== 201) {
|
||||||
|
const code =
|
||||||
|
typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code)
|
||||||
|
? ` (${uploaded.body.code})`
|
||||||
|
: "";
|
||||||
|
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`);
|
||||||
|
}
|
||||||
images.push(uploaded.body?.image?.id);
|
images.push(uploaded.body?.image?.id);
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
|
|||||||
+159
-18
@@ -7,6 +7,8 @@
|
|||||||
# bash scripts/smoke-deploy.sh builds the image first
|
# bash scripts/smoke-deploy.sh builds the image first
|
||||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||||
|
# EKKLESIA_SMOKE_MODULE_RELEASE_DIR=<dir> EKKLESIA_SMOKE_MODULE_ID=<id> \
|
||||||
|
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||||
#
|
#
|
||||||
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
|
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
|
||||||
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
|
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
|
||||||
@@ -15,10 +17,10 @@
|
|||||||
# ADR 0048 end to end with that same image:
|
# ADR 0048 end to end with that same image:
|
||||||
#
|
#
|
||||||
# - it boots with no modules;
|
# - it boots with no modules;
|
||||||
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module
|
# - either the repository's module template is packaged by the canonical packager, or an external
|
||||||
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build
|
# canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is
|
||||||
# environment), verified by the same image's own verifier, extracted to staging, installed into
|
# verified by the same image's own verifier, extracted to staging, installed into module storage,
|
||||||
# module storage, its migrations planned and explicitly applied, and the API restarted;
|
# its migrations planned and explicitly applied, and the API restarted;
|
||||||
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
|
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
|
||||||
# - the module is removed and the API restarted: Core still runs, migration planning reports the
|
# - the module is removed and the API restarted: Core still runs, migration planning reports the
|
||||||
# module's retained history, and its data survives;
|
# module's retained history, and its data survives;
|
||||||
@@ -83,6 +85,28 @@ same-origin | split) ;;
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
module_release_directory="${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"
|
||||||
|
module_id="${EKKLESIA_SMOKE_MODULE_ID:-example}"
|
||||||
|
module_probe_path="${EKKLESIA_SMOKE_MODULE_PROBE_PATH:-}"
|
||||||
|
[[ "$module_id" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || {
|
||||||
|
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_ID is not a valid module id." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if [[ -n "$module_release_directory" ]]; then
|
||||||
|
[[ "$topology" == split ]] || {
|
||||||
|
echo "smoke-deploy: an external module release requires the split topology." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -d "$module_release_directory" ]] || {
|
||||||
|
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_RELEASE_DIR is not a directory." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
if [[ -n "$module_probe_path" && "$module_probe_path" != "/api/v1/modules/${module_id}/"* ]]; then
|
||||||
|
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_PROBE_PATH must be inside the selected module's API namespace." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
run_id="$(openssl rand -hex 4)"
|
run_id="$(openssl rand -hex 4)"
|
||||||
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||||
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
|
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
|
||||||
@@ -217,8 +241,10 @@ if [[ -z "$image" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$topology" == split ]]; then
|
if [[ "$topology" == split ]]; then
|
||||||
# The module template as a Module Package v1, from the canonical packager. Never a second platform
|
# Never a second platform image: the package is installed into module storage below, after the
|
||||||
# image: the package is installed into module storage below, after the image verifies it.
|
# generic image verifies it. The default keeps Core's own template smoke self-contained; downstream
|
||||||
|
# module CI hands this harness the exact release pair it built instead.
|
||||||
|
if [[ -z "$module_release_directory" ]]; then
|
||||||
step "Packaging the module template with the canonical Ekklesia packager"
|
step "Packaging the module template with the canonical Ekklesia packager"
|
||||||
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
|
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
|
||||||
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
|
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
|
||||||
@@ -226,13 +252,20 @@ if [[ "$topology" == split ]]; then
|
|||||||
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
|
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
|
||||||
--output "type=local,dest=${work}/module-package" . >/dev/null ||
|
--output "type=local,dest=${work}/module-package" . >/dev/null ||
|
||||||
fail "the module package did not build."
|
fail "the module package did not build."
|
||||||
|
package_output="$work/module-package"
|
||||||
|
else
|
||||||
|
step "Accepting the external module's canonical release pair"
|
||||||
|
package_output="$module_release_directory"
|
||||||
|
fi
|
||||||
# The archive and its digest are one release: copied together, from this run's own build output,
|
# The archive and its digest are one release: copied together, from this run's own build output,
|
||||||
# into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
|
# into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
|
||||||
# mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
|
# mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
|
||||||
package_archive="$(module_package_release "$work/module-package" "$work/package")" ||
|
package_archive="$(module_package_release "$package_output" "$work/package")" ||
|
||||||
fail "the packager did not produce exactly one archive and its digest."
|
fail "the packager did not produce exactly one archive and its digest."
|
||||||
|
if [[ -z "$module_release_directory" ]]; then
|
||||||
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
|
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
|
||||||
fail "the packager produced an unexpected archive name."
|
fail "the packager produced an unexpected archive name."
|
||||||
|
fi
|
||||||
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
|
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -453,18 +486,41 @@ install_test_module() {
|
|||||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||||
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
|
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
|
||||||
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
||||||
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1
|
--sha256-file "/package/${package_archive}.sha256" --extract-to "/staging/${module_id}" 2>&1
|
||||||
)" || fail "the module package did not verify: ${verified}"
|
)" || fail "the module package did not verify: ${verified}"
|
||||||
echo "ok ${verified%%$'\n'*}"
|
echo "ok ${verified%%$'\n'*}"
|
||||||
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
|
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
|
||||||
-v "$daemon_work/staging:/staging" "$postgres_image" -ec '
|
-v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec '
|
||||||
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example &&
|
incoming="/modules/.incoming-${MODULE_ID}"
|
||||||
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example &&
|
installed="/modules/${MODULE_ID}"
|
||||||
chmod -R a+rX,go-w /modules/.incoming-example &&
|
staged="/staging/${MODULE_ID}"
|
||||||
mv /modules/.incoming-example /modules/example' ||
|
rm -rf "$incoming" && cp -R "$staged" "$incoming" && rm -rf "$staged" &&
|
||||||
|
chown -R 0:0 "$incoming" && chmod -R a+rX,go-w "$incoming" && mv "$incoming" "$installed"' ||
|
||||||
fail "the verified module could not be installed into module storage."
|
fail "the verified module could not be installed into module storage."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The package and its claimed digest are deliberately made to disagree. The verifier must reject it
|
||||||
|
# before extraction; the valid release pair is left untouched for the installation that follows.
|
||||||
|
reject_invalid_package() {
|
||||||
|
local rejected zero_digest
|
||||||
|
zero_digest="$(printf '0%.0s' {1..64})"
|
||||||
|
if rejected="$(
|
||||||
|
docker run --rm --network none --read-only \
|
||||||
|
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||||
|
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||||
|
--cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||||
|
-v "$daemon_work/package:/package:ro" \
|
||||||
|
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
||||||
|
--sha256 "$zero_digest" 2>&1
|
||||||
|
)"; then
|
||||||
|
fail "the verifier accepted a package whose archive did not match its claimed digest."
|
||||||
|
fi
|
||||||
|
grep -q '^\[MODULE_PACKAGE_DIGEST_MISMATCH\]' <<<"$rejected" ||
|
||||||
|
fail "the invalid package was rejected for an unexpected reason."
|
||||||
|
echo "ok an invalid package is rejected before installation"
|
||||||
|
}
|
||||||
|
|
||||||
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
|
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
|
||||||
# restart is the only way a module change is picked up; nothing is ever hot loaded.
|
# restart is the only way a module change is picked up; nothing is ever hot loaded.
|
||||||
restart_api() {
|
restart_api() {
|
||||||
@@ -476,9 +532,21 @@ restart_api() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# recreate_api removes the application container, not its installation-owned module bind mount or
|
||||||
|
# database volumes, then creates a fresh container from the same generic image.
|
||||||
|
recreate_api() {
|
||||||
|
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation."
|
||||||
|
"${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed."
|
||||||
|
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
|
||||||
|
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||||
|
fail "the recreated API did not become healthy."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
|
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
|
||||||
module_check() {
|
module_check() {
|
||||||
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
|
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
|
||||||
|
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
|
||||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,21 +559,36 @@ if [[ "$topology" == split ]]; then
|
|||||||
step "Booting the generic image with empty module storage"
|
step "Booting the generic image with empty module storage"
|
||||||
module_check absent || fail "the image without modules did not run module-free."
|
module_check absent || fail "the image without modules did not run module-free."
|
||||||
|
|
||||||
|
step "Rejecting an invalid package before installation"
|
||||||
|
reject_invalid_package
|
||||||
|
|
||||||
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
|
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
|
||||||
install_module_and_migrate() {
|
install_module_and_migrate() {
|
||||||
install_test_module
|
install_test_module
|
||||||
local plan
|
local plan
|
||||||
plan="$(plan_output)"
|
plan="$(plan_output)"
|
||||||
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" ||
|
grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan" ||
|
||||||
fail "the plan does not list the installed module's migration as pending."
|
fail "the plan does not list the installed module's migration as pending."
|
||||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||||
fail "applying the installed module's migration failed."
|
fail "applying the installed module's migration failed."
|
||||||
|
plan="$(plan_output)"
|
||||||
|
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
|
||||||
|
fail "the plan does not list the installed module's migration as applied."
|
||||||
|
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
|
||||||
|
fail "the installed module still has a pending migration after apply."
|
||||||
|
fi
|
||||||
|
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||||
|
fail "reapplying migrations was not idempotent."
|
||||||
}
|
}
|
||||||
restart_api install_module_and_migrate
|
restart_api install_module_and_migrate
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
module_check '[]' || fail "the installed module did not run from module storage."
|
module_check '[]' || fail "the installed module did not run from module storage."
|
||||||
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
|
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
|
||||||
>/dev/null || fail "could not write the module's own data."
|
>/dev/null || fail "could not write the module's own data."
|
||||||
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
|
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
|
||||||
|
else
|
||||||
|
module_check present || fail "the installed module did not run from module storage."
|
||||||
|
fi
|
||||||
|
|
||||||
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
|
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
|
||||||
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
||||||
@@ -516,6 +599,8 @@ if [[ "$topology" == split ]]; then
|
|||||||
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
|
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
|
||||||
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
|
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
|
||||||
browser_container="${project}-browser"
|
browser_container="${project}-browser"
|
||||||
|
start_owner_browser() {
|
||||||
|
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
||||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||||
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
@@ -524,8 +609,11 @@ if [[ "$topology" == split ]]; then
|
|||||||
"$browser_image" \
|
"$browser_image" \
|
||||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||||
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
||||||
|
}
|
||||||
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
||||||
stage browser.mjs
|
stage browser.mjs
|
||||||
|
run_browser_checks() {
|
||||||
|
start_owner_browser
|
||||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||||
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
--network "$edge_network" \
|
--network "$edge_network" \
|
||||||
@@ -533,7 +621,9 @@ if [[ "$topology" == split ]]; then
|
|||||||
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||||
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
||||||
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
|
--entrypoint node "$image" /smoke/browser.mjs
|
||||||
|
}
|
||||||
|
run_browser_checks || fail "browser checks failed."
|
||||||
|
|
||||||
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||||
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
|
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
|
||||||
@@ -569,6 +659,7 @@ if [[ "$topology" == split ]]; then
|
|||||||
--network "$edge_network" \
|
--network "$edge_network" \
|
||||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||||
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
|
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
|
||||||
|
-e SMOKE_CALL_TIMEOUT_MS="${EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000}" \
|
||||||
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
|
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
|
||||||
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
|
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
|
||||||
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
|
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
|
||||||
@@ -610,29 +701,79 @@ if [[ "$topology" == split ]]; then
|
|||||||
((display_status == 0)) || fail "display-device checks failed."
|
((display_status == 0)) || fail "display-device checks failed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
step "Restarting the API process with the installed module and data intact"
|
||||||
|
restart_api
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
|
module_check '["Hello, kept"]' || fail "the module or its data did not survive an API restart."
|
||||||
|
else
|
||||||
|
module_check present || fail "the module did not survive an API restart."
|
||||||
|
fi
|
||||||
|
run_browser_checks || fail "the module UI did not survive an API restart."
|
||||||
|
|
||||||
|
step "Recreating the API container with the same module storage and database volumes"
|
||||||
|
recreate_api
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
|
module_check '["Hello, kept"]' || fail "the module or its data did not survive container recreation."
|
||||||
|
else
|
||||||
|
module_check present || fail "the module did not survive container recreation."
|
||||||
|
fi
|
||||||
|
plan="$(plan_output)"
|
||||||
|
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
|
||||||
|
fail "container recreation made an applied module migration pending again."
|
||||||
|
fi
|
||||||
|
run_browser_checks || fail "the module UI did not survive container recreation."
|
||||||
|
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||||
|
display_driver 120 restart || fail "the display runtime or its data did not survive container recreation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Disabling and re-enabling the module through the supported lifecycle command"
|
||||||
|
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||||
|
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
|
||||||
|
module_check absent || fail "the disabled module was still reported as running."
|
||||||
|
module_storage "test -d /modules/${module_id}" ||
|
||||||
|
fail "disabling the module removed its installed package."
|
||||||
|
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||||
|
core/dist/cli/set-module-enabled.js --enable --module "$module_id"
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
|
module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data."
|
||||||
|
else
|
||||||
|
module_check present || fail "the re-enabled module did not return."
|
||||||
|
fi
|
||||||
|
run_browser_checks || fail "the re-enabled module UI did not return."
|
||||||
|
|
||||||
step "Removing the module from module storage and restarting: Core runs, its data stays"
|
step "Removing the module from module storage and restarting: Core runs, its data stays"
|
||||||
restart_api module_storage 'rm -rf /modules/example'
|
restart_api module_storage "rm -rf /modules/${module_id}"
|
||||||
module_check absent || fail "the removed module is still running."
|
module_check absent || fail "the removed module is still running."
|
||||||
plan="$(plan_output)"
|
plan="$(plan_output)"
|
||||||
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \
|
grep -q "^- \\[retained\\] ${module_id}: .*applied migration.*kept; the module is not installed$" \
|
||||||
<<<"$plan" || fail "migration planning did not report the removed module's retained history."
|
<<<"$plan" || fail "migration planning did not report the removed module's retained history."
|
||||||
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
|
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
|
||||||
fail "migration planning did not succeed cleanly with the module absent."
|
fail "migration planning did not succeed cleanly with the module absent."
|
||||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||||
fail "applying migrations failed with the module absent."
|
fail "applying migrations failed with the module absent."
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
|
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
|
||||||
fail "the removed module's data did not survive."
|
fail "the removed module's data did not survive."
|
||||||
|
fi
|
||||||
echo "ok the module is gone, Core runs, its history is retained, and its data survives"
|
echo "ok the module is gone, Core runs, its history is retained, and its data survives"
|
||||||
|
|
||||||
step "Reinstalling the exact same package and restarting: the module returns with its previous data"
|
step "Reinstalling the exact same package and restarting: the module returns with its previous data"
|
||||||
restart_api install_test_module
|
restart_api install_test_module
|
||||||
plan="$(plan_output)"
|
plan="$(plan_output)"
|
||||||
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" ||
|
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
|
||||||
fail "the restored module's migration history is not valid."
|
fail "the restored module's migration history is not valid."
|
||||||
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
|
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
|
||||||
fail "the restored module left retained or pending migrations."
|
fail "the restored module left retained or pending migrations."
|
||||||
fi
|
fi
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
|
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
|
||||||
|
else
|
||||||
|
module_check present || fail "the restored module did not return."
|
||||||
|
fi
|
||||||
|
run_browser_checks || fail "the restored module UI did not return."
|
||||||
|
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||||
|
display_driver 120 restart || fail "the restored display runtime did not return with its data."
|
||||||
|
fi
|
||||||
echo "ok the restored module runs again, with its history strict and its data intact"
|
echo "ok the restored module runs again, with its history strict and its data intact"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/shell",
|
"name": "@ekklesia/shell",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ describe("CI scripts on a shared Docker daemon", () => {
|
|||||||
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
|
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
|
||||||
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
|
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
|
||||||
expect(deploy).toMatch(
|
expect(deploy).toMatch(
|
||||||
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to \/staging\/example/u,
|
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to "\/staging\/\$\{module_id\}"/u,
|
||||||
);
|
);
|
||||||
expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
|
expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
|
||||||
expect(deploy).toContain(
|
expect(deploy).toContain(
|
||||||
@@ -187,16 +187,20 @@ describe("CI scripts on a shared Docker daemon", () => {
|
|||||||
);
|
);
|
||||||
expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier");
|
expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier");
|
||||||
// Module storage only ever receives the verified tree from staging.
|
// Module storage only ever receives the verified tree from staging.
|
||||||
expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example");
|
expect(deploy).toContain(`staged="/staging/\${MODULE_ID}"`);
|
||||||
|
expect(deploy).toContain('cp -R "$staged" "$incoming"');
|
||||||
|
expect(deploy).toContain('mv "$incoming" "$installed"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => {
|
it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => {
|
||||||
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||||
expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"');
|
expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"');
|
||||||
// The pair is made from this run's own build output, into a directory that must not exist yet.
|
// The pair is made from this run's own build output, into a directory that must not exist yet.
|
||||||
expect(deploy).toContain('--output "type=local,dest=${work}/module-package"');
|
expect(deploy).toContain(`--output "type=local,dest=\${work}/module-package"`);
|
||||||
|
expect(deploy).toContain('package_output="$work/module-package"');
|
||||||
|
expect(deploy).toContain('package_output="$module_release_directory"');
|
||||||
expect(deploy).toContain(
|
expect(deploy).toContain(
|
||||||
'package_archive="$(module_package_release "$work/module-package" "$work/package")"',
|
'package_archive="$(module_package_release "$package_output" "$work/package")"',
|
||||||
);
|
);
|
||||||
expect(deploy).toMatch(/^\s*stage package$/mu);
|
expect(deploy).toMatch(/^\s*stage package$/mu);
|
||||||
// BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted.
|
// BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted.
|
||||||
@@ -207,11 +211,36 @@ describe("CI scripts on a shared Docker daemon", () => {
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.find((line) => line.includes("core/dist/cli/verify-module-package.js"));
|
.find((line) => line.includes("core/dist/cli/verify-module-package.js"));
|
||||||
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
|
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
|
||||||
expect(verifier).toContain('"/package/${package_archive}"');
|
expect(verifier).toContain(`"/package/\${package_archive}"`);
|
||||||
expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"');
|
expect(verifier).toContain(`--sha256-file "/package/\${package_archive}.sha256"`);
|
||||||
expect(verifier).not.toMatch(/--sha256\s/u);
|
expect(verifier).not.toMatch(/--sha256\s/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts one exact external module release without putting its source in the platform image", async () => {
|
||||||
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
|
expect(deploy).toContain(`module_release_directory="\${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"`);
|
||||||
|
expect(deploy).toContain(`module_id="\${EKKLESIA_SMOKE_MODULE_ID:-example}"`);
|
||||||
|
expect(deploy).toContain('package_output="$module_release_directory"');
|
||||||
|
expect(deploy).toContain('-e SMOKE_MODULE_ID="$module_id"');
|
||||||
|
expect(deploy).toContain(`--extract-to "/staging/\${module_id}"`);
|
||||||
|
expect(deploy).toContain(`installed="/modules/\${MODULE_ID}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proves installed module state across restart, container recreation, and lifecycle changes", async () => {
|
||||||
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
|
expect(deploy).toContain(`"\${compose[@]}" rm --force api`);
|
||||||
|
expect(deploy).toContain("container recreation made an applied module migration pending again");
|
||||||
|
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --disable --module "$module_id"');
|
||||||
|
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --enable --module "$module_id"');
|
||||||
|
expect(deploy).toContain("the restored display runtime did not return with its data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives page-side display requests enough time on a constrained shared runner", async () => {
|
||||||
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
|
expect(deploy).toContain('-e SMOKE_CALL_TIMEOUT_MS="');
|
||||||
|
expect(deploy).toContain("EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000");
|
||||||
|
});
|
||||||
|
|
||||||
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
|
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
|
||||||
// ADR 0048: the split topology installs its test module into module storage at run time.
|
// ADR 0048: the split topology installs its test module into module storage at run time.
|
||||||
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ describe("reference deployment compose files", () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(services["object-storage"]?.command).toEqual(
|
expect(services["object-storage"]?.command).toEqual(
|
||||||
expect.arrayContaining(["-s3.port.https=8443"]),
|
expect.arrayContaining(["-master.volumeSizeLimitMB=32", "-s3.port.https=8443"]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hba = (await text("postgres/pg_hba.conf"))
|
const hba = (await text("postgres/pg_hba.conf"))
|
||||||
|
|||||||
+8
-8
@@ -13,18 +13,18 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/design-tokens": "^0.2.2",
|
"@ekklesia/design-tokens": "^0.2.4",
|
||||||
"@ekklesia/module-database": "^0.2.2",
|
"@ekklesia/module-database": "^0.2.4",
|
||||||
"@ekklesia/module-sdk": "^0.2.2",
|
"@ekklesia/module-sdk": "^0.2.4",
|
||||||
"@ekklesia/module-ui-sdk": "^0.2.2",
|
"@ekklesia/module-ui-sdk": "^0.2.4",
|
||||||
"@ekklesia/ui": "^0.2.2",
|
"@ekklesia/ui": "^0.2.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ekklesia/module-pack": "0.2.2",
|
"@ekklesia/module-pack": "0.2.4",
|
||||||
"@ekklesia/module-ui-build": "^0.2.2",
|
"@ekklesia/module-ui-build": "^0.2.4",
|
||||||
"@ekklesia/typescript-config": "^0.2.2",
|
"@ekklesia/typescript-config": "^0.2.4",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.2.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user