ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]>
202 lines
6.4 KiB
TypeScript
202 lines
6.4 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
type DoctorDependencies,
|
|
diagnoseEkklesia,
|
|
formatDoctorReport,
|
|
} from "../src/diagnostics/index.js";
|
|
import type { MigrationDatabase } from "../src/migrations/index.js";
|
|
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
|
|
import { composePlatformPermissionRegistry } from "../src/modules/permissions.js";
|
|
|
|
const originalNoColor = process.env.NO_COLOR;
|
|
|
|
afterEach(() => {
|
|
if (originalNoColor === undefined) {
|
|
delete process.env.NO_COLOR;
|
|
} else {
|
|
process.env.NO_COLOR = originalNoColor;
|
|
}
|
|
});
|
|
|
|
function zeroSchemaAggregate(): ModuleSchemaAggregate {
|
|
return Object.freeze({
|
|
discoveredModules: Object.freeze([]),
|
|
drizzleSchema: Object.freeze({}),
|
|
modules: Object.freeze([]),
|
|
tables: Object.freeze([]),
|
|
});
|
|
}
|
|
|
|
function zeroMigrationAggregate(): ModuleMigrationAggregate {
|
|
return Object.freeze({
|
|
discoveredModules: Object.freeze([]),
|
|
migrations: Object.freeze([]),
|
|
modules: Object.freeze([]),
|
|
});
|
|
}
|
|
|
|
function noConfigurationDependencies(): DoctorDependencies {
|
|
return {
|
|
composePermissions: composePlatformPermissionRegistry,
|
|
createMigrationDatabase: () => {
|
|
throw new Error("database must not be constructed");
|
|
},
|
|
discoverMigrations: async () => zeroMigrationAggregate(),
|
|
discoverModules: async () => Object.freeze([]),
|
|
discoverSchemas: async () => zeroSchemaAggregate(),
|
|
planMigrations: async () => {
|
|
throw new Error("database must not be planned");
|
|
},
|
|
probeStorage: async () => {
|
|
throw new Error("storage must not be probed");
|
|
},
|
|
readModuleConfiguration: async () => {
|
|
throw new Error("module configuration must not be read");
|
|
},
|
|
readModuleLifecycle: async () => {
|
|
throw new Error("module lifecycle configuration must not be read");
|
|
},
|
|
runtimeSatisfies: () => true,
|
|
};
|
|
}
|
|
|
|
describe("Ekklesia Doctor terminal report", () => {
|
|
it("renders the real zero-module and no-database shape exactly", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
noConfigurationDependencies(),
|
|
);
|
|
|
|
expect(formatDoctorReport(report).join("\n")).toBe(`Ekklesia Doctor
|
|
|
|
Platform
|
|
✓ Runtime version supported
|
|
Active Node.js satisfies the declared supported range.
|
|
|
|
Modules
|
|
✓ Module discovery complete
|
|
0 modules installed
|
|
✓ Dependency graph valid
|
|
Dependency order is empty.
|
|
✓ Permission registry composed
|
|
6 permissions registered
|
|
○ Module lifecycle skipped
|
|
DATABASE_URL is not configured.
|
|
○ Module configuration skipped
|
|
DATABASE_URL is not configured.
|
|
|
|
Data definitions
|
|
✓ Schemas validated
|
|
0 modules · 0 tables
|
|
✓ Migrations validated
|
|
0 migrations · checksums valid
|
|
|
|
PostgreSQL
|
|
✓ Database TLS verified
|
|
TLS required with certificate and hostname verification.
|
|
○ Connection check skipped
|
|
DATABASE_URL is not configured.
|
|
○ Migration plan skipped
|
|
A validated migration inventory and configured PostgreSQL connection are required.
|
|
|
|
Identity
|
|
○ Identity configuration skipped
|
|
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
|
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
|
✓ Direct client-address mode
|
|
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
|
|
|
Object storage
|
|
○ Object storage not configured
|
|
No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
|
|
✓ No module requires object storage
|
|
0 modules declare a logical storage bucket.
|
|
|
|
Result: READY WITH WARNINGS`);
|
|
});
|
|
|
|
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
noConfigurationDependencies(),
|
|
);
|
|
const baseline = formatDoctorReport(report);
|
|
const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
|
|
|
try {
|
|
process.env.NO_COLOR = "1";
|
|
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
|
const interactiveWithoutColor = formatDoctorReport(report);
|
|
process.env.NO_COLOR = "";
|
|
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
|
|
const nonInteractive = formatDoctorReport(report);
|
|
|
|
expect(interactiveWithoutColor).toEqual(baseline);
|
|
expect(nonInteractive).toEqual(baseline);
|
|
expect(interactiveWithoutColor.some((line) => line.includes("\u001B"))).toBe(false);
|
|
expect(Object.isFrozen(interactiveWithoutColor)).toBe(true);
|
|
} finally {
|
|
if (originalIsTtyDescriptor) {
|
|
Object.defineProperty(process.stdout, "isTTY", originalIsTtyDescriptor);
|
|
} else {
|
|
delete (process.stdout as { isTTY?: boolean }).isTTY;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("never renders secrets from a hostile database exception", async () => {
|
|
const hostile =
|
|
"postgresql://admin:[email protected]/db SELECT password FROM users /home/me/.env";
|
|
const database = Object.freeze({}) as MigrationDatabase;
|
|
const dependencies: DoctorDependencies = {
|
|
...noConfigurationDependencies(),
|
|
createMigrationDatabase: () => database,
|
|
planMigrations: async () => {
|
|
throw new Error(hostile);
|
|
},
|
|
};
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
databaseUrl: "postgresql://another:[email protected]/db",
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
dependencies,
|
|
);
|
|
const output = formatDoctorReport(report).join("\n");
|
|
|
|
for (const forbidden of [
|
|
"admin",
|
|
"secret",
|
|
"host.invalid",
|
|
"SELECT",
|
|
"password",
|
|
"/home/me",
|
|
"postgresql://",
|
|
]) {
|
|
expect(output).not.toContain(forbidden);
|
|
}
|
|
});
|
|
});
|