feat: give display devices their own paired identity
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s

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]>
This commit is contained in:
ekklesia
2026-08-23 23:41:03 -05:00
co-authored by Claude Opus 5
parent 3ce4fe03fe
commit 80189c8ae2
52 changed files with 5065 additions and 113 deletions
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig({
"./src/dashboard/schema.ts",
"./src/modules/lifecycle-schema.ts",
"./src/modules/configuration-schema.ts",
"./src/devices/schema.ts",
],
out: "./drizzle",
migrations: {
+53
View File
@@ -0,0 +1,53 @@
CREATE TABLE "ekklesia_core_device" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"credential_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_by_user_id" text,
"last_seen_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"revoked_by_user_id" text,
CONSTRAINT "ekklesia_core_device_name_check" CHECK (length("ekklesia_core_device"."name") between 1 and 128),
CONSTRAINT "ekklesia_core_device_credential_hash_check" CHECK (length("ekklesia_core_device"."credential_hash") = 64)
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_device_pairing_request" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" text NOT NULL,
"claim_secret_hash" text NOT NULL,
"module_id" text NOT NULL,
"permission_id" text NOT NULL,
"state" text DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"device_name" text,
"approved_at" timestamp with time zone,
"approved_by_user_id" text,
"consumed_at" timestamp with time zone,
"device_id" uuid,
CONSTRAINT "ekklesia_core_device_pairing_code_check" CHECK ("ekklesia_core_device_pairing_request"."code" ~ '^[0-9]{6}$'),
CONSTRAINT "ekklesia_core_device_pairing_claim_hash_check" CHECK (length("ekklesia_core_device_pairing_request"."claim_secret_hash") = 64),
CONSTRAINT "ekklesia_core_device_pairing_state_check" CHECK ("ekklesia_core_device_pairing_request"."state" in ('pending', 'approved', 'consumed'))
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_device_permission" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"permission_id" text NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
"granted_by_user_id" text,
"revoked_at" timestamp with time zone,
CONSTRAINT "ekklesia_core_device_permission_id_check" CHECK (length("ekklesia_core_device_permission"."permission_id") between 1 and 128)
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("revoked_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("approved_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("granted_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "ekklesia_core_device_credential_hash_idx" ON "ekklesia_core_device" USING btree ("credential_hash");--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_revoked_idx" ON "ekklesia_core_device" USING btree ("revoked_at");--> statement-breakpoint
CREATE UNIQUE INDEX "ekklesia_core_device_pairing_code_idx" ON "ekklesia_core_device_pairing_request" USING btree ("code") WHERE "ekklesia_core_device_pairing_request"."state" <> 'consumed';--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_pairing_claim_idx" ON "ekklesia_core_device_pairing_request" USING btree ("claim_secret_hash");--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_permission_lookup_idx" ON "ekklesia_core_device_permission" USING btree ("device_id","permission_id");
File diff suppressed because it is too large Load Diff
+6
View File
@@ -37,6 +37,12 @@
"destructive": {
"mode": "none"
}
},
{
"tag": "0006_device_identity",
"destructive": {
"mode": "none"
}
}
]
}
+7
View File
@@ -43,6 +43,13 @@
"when": 1787451496425,
"tag": "0005_module_configuration",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787543773057,
"tag": "0006_device_identity",
"breakpoints": true
}
]
}
+4
View File
@@ -42,6 +42,10 @@
"./database": {
"types": "./dist/database/index.d.ts",
"import": "./dist/database/index.js"
},
"./devices": {
"types": "./dist/devices/index.d.ts",
"import": "./dist/devices/index.js"
}
},
"scripts": {
+41 -7
View File
@@ -5,11 +5,16 @@ import {
type AuthorizationScope,
type EkklesiaPrincipal,
isAuthenticatedPrincipal,
isDevicePrincipal,
permissionBaselineSchema,
safeParseAuthorizationScope,
safeParsePermissionId,
} from "@ekklesia/contracts";
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js";
import type {
AuthorizationDevicePort,
AuthorizationGrantPort,
AuthorizationPlatformOwnerPort,
} from "./ports.js";
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
@@ -54,6 +59,10 @@ export interface AuthorizationEvaluator {
export interface CreateAuthorizationEvaluatorOptions {
readonly grants: AuthorizationGrantPort;
readonly platformOwners: AuthorizationPlatformOwnerPort;
/** Device authority. Optional so every existing caller composes unchanged; when it is absent a
* device principal simply has no permissions and is denied, which is the correct answer for a
* deployment that has no device support wired at all. */
readonly devices?: AuthorizationDevicePort;
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
* real wall clock; production composition must leave this unset. */
readonly now?: () => Date;
@@ -100,17 +109,14 @@ export function createAuthorizationEvaluator(
}
if (parsedBaseline.data === "authenticated") {
// A device deliberately does not satisfy this. The baseline means "a person is signed in",
// and every route that settles for it — the module UI inventory, anything a human is
// expected to be looking at — would otherwise open to an unattended screen (ADR 0036).
return isAuthenticatedPrincipal(request.principal)
? AUTHORIZATION_ALLOWED
: AUTHORIZATION_DENIED;
}
// parsedBaseline.data === "restricted": requires an authenticated principal AND
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
if (!isAuthenticatedPrincipal(request.principal)) {
return AUTHORIZATION_DENIED;
}
let scope: AuthorizationScope | undefined;
if (request.scope !== undefined) {
const parsedScope = safeParseAuthorizationScope(request.scope);
@@ -120,6 +126,34 @@ export function createAuthorizationEvaluator(
scope = parsedScope.data;
}
// A device satisfies `restricted` only by holding that exact permission, and by no other
// route: there is no owner check here, no baseline that stands in for a grant, and no branch
// that returns `allowed` before this lookup. "Paired" is not authority — a device that was
// paired for one display has nothing else.
if (isDevicePrincipal(request.principal)) {
if (!options.devices || scope !== undefined) {
// A scoped check has no device meaning yet; denying is the honest answer rather than
// silently treating it as unscoped.
return AUTHORIZATION_DENIED;
}
try {
return (await options.devices.hasActivePermission(
request.principal.deviceId,
parsedPermission.data,
))
? AUTHORIZATION_ALLOWED
: AUTHORIZATION_DENIED;
} catch {
throw new AuthorizationEvaluationError();
}
}
// parsedBaseline.data === "restricted": requires an authenticated principal AND
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
if (!isAuthenticatedPrincipal(request.principal)) {
return AUTHORIZATION_DENIED;
}
const userId = request.principal.userId;
let isOwner: boolean;
let hasGrant: boolean;
+22
View File
@@ -152,6 +152,23 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
"core.modules.configuration.manage",
);
/**
* Restricted, and deliberately a **platform** permission rather than a module's.
*
* A device is an identity the platform issues, so deciding that a screen in the foyer may exist —
* approving its pairing, seeing what has been paired, and taking it away again — is authority over
* Ekklesia itself, not over the module whose display happens to be shown. Letting a module declare
* or gate it would mean whoever administers a slideshow could mint credentials that outlive it.
*
* It gates approval, listing, and revocation together. Splitting "see the paired devices" from
* "revoke one" would be ceremony: the same person is trusted with both, and a reader who cannot act
* on what they see is not a role anyone has asked for (ADR 0027's reasoning, applied again).
*
* Holding it confers **no** authority inside any module and no ability to read a device credential
* — none is stored in a recoverable form, so there is no code path that could serve one (ADR 0036).
*/
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
/**
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
@@ -185,6 +202,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
description: "Read and change an installed module's own configuration values.",
id: CORE_MODULES_CONFIGURATION_MANAGE,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE,
}),
]);
/**
+20 -1
View File
@@ -1,4 +1,9 @@
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
import type {
AuthorizationScope,
EkklesiaDeviceId,
EkklesiaUserId,
PermissionId,
} from "@ekklesia/contracts";
export interface ActiveGrantQuery {
readonly userId: EkklesiaUserId;
@@ -26,3 +31,17 @@ export interface AuthorizationGrantPort {
export interface AuthorizationPlatformOwnerPort {
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
}
/**
* The evaluator's only read dependency for **device** authority.
*
* Separate from `AuthorizationGrantPort` because the two answer questions about different kinds of
* subject, not because devices are a special case in the decision: `authorize` consults exactly one
* of them per call, by principal kind, and neither can be reached without going through it.
*
* There is deliberately no device counterpart to `AuthorizationPlatformOwnerPort`. A device is
* never a platform owner, so there is no port that could accidentally say it was.
*/
export interface AuthorizationDevicePort {
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
}
+16
View File
@@ -0,0 +1,16 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { deviceSchema } from "./schema.js";
export type DeviceDrizzleDatabase = NodePgDatabase<typeof deviceSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`.
*
* There is deliberately no pool-constructing counterpart: device identity is only ever read and
* written by the running API, which already owns one validated pool. A second constructor would be
* a second place for transport policy to drift.
*/
export function createDeviceDrizzleView(pool: Pool): DeviceDrizzleDatabase {
return drizzle(pool, { schema: deviceSchema });
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./database.js";
export * from "./schema.js";
export * from "./store.js";
+168
View File
@@ -0,0 +1,168 @@
import { relations, sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned device identity: paired display devices, the permissions granted to them, and the
* short-lived pairing requests that create them (ADR 0036).
*
* **Devices are not users.** There is deliberately no foreign key from a device to
* `ekklesia_core_user`, and no device row ever appears in the identity tables: a device is its own
* principal class with its own credential and its own explicit permissions. The only reference to a
* user here is *who approved a pairing*, which is audit information about a person's action, not a
* device's identity.
*
* Device permissions live in their own table rather than in `ekklesia_core_grant`. That table's
* `user_id` is a non-null foreign key into the user table, and generalizing it to a polymorphic
* subject would rewrite the platform's central authorization record — and its relationship-derived
* grants, audit semantics, and indexes — to serve one narrow case. A separate table keeps the
* device story additive and auditable while the evaluator remains the single place any decision is
* made (`core/src/authorization/evaluator.ts`).
*/
export const ekklesiaCoreDevice = pgTable(
"ekklesia_core_device",
{
id: uuid("id").primaryKey().defaultRandom(),
/** Assigned by the approving administrator, never self-asserted by the unpaired device. */
name: text("name").notNull(),
/**
* SHA-256 of the device's credential, hex encoded. The raw credential exists only in the
* response that sets the cookie and in the device's own cookie jar; it is never stored, logged,
* or recoverable from this row. A fast hash is right here — unlike a password, the credential
* is 256 bits of CSPRNG output, so there is nothing to brute-force and a slow KDF would only
* add latency to every request a display makes.
*/
credentialHash: text("credential_hash").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
createdByUserId: text("created_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
/** Coarse operational signal, throttled by the resolver; never an identity input. */
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
/** Server-side authority. Revocation is this column, never the deletion of a remote cookie. */
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByUserId: text("revoked_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
},
(table) => [
// Every credential is distinct, and the resolver looks a presented credential up by this exact
// hash — an index probe rather than a scan-and-compare, so there is no comparison to time.
uniqueIndex("ekklesia_core_device_credential_hash_idx").on(table.credentialHash),
index("ekklesia_core_device_revoked_idx").on(table.revokedAt),
check("ekklesia_core_device_name_check", sql`length(${table.name}) between 1 and 128`),
check("ekklesia_core_device_credential_hash_check", sql`length(${table.credentialHash}) = 64`),
],
);
export const ekklesiaCoreDevicePermission = pgTable(
"ekklesia_core_device_permission",
{
id: uuid("id").primaryKey().defaultRandom(),
deviceId: uuid("device_id")
.notNull()
.references(() => ekklesiaCoreDevice.id, { onDelete: "cascade" }),
permissionId: text("permission_id").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
grantedByUserId: text("granted_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
},
(table) => [
index("ekklesia_core_device_permission_lookup_idx").on(table.deviceId, table.permissionId),
check(
"ekklesia_core_device_permission_id_check",
sql`length(${table.permissionId}) between 1 and 128`,
),
],
);
/**
* One in-flight pairing attempt.
*
* Three distinct secrets meet here and must not be confused:
*
* - `code` is the short digits shown on the screen. It is **public by design** — it is displayed on
* a television in a hallway — and it identifies the request to an administrator. It is stored in
* the clear because it is not a secret and because an administrator looks a request up by it.
* - `claimSecretHash` is SHA-256 of a 256-bit secret held only in the requesting browser's
* HttpOnly cookie. It answers "are you the device that asked?", which the code cannot: anyone who
* can *see* the screen knows the code, so a flow that issued a credential on the strength of the
* code alone would hand the device's identity to whoever read it off the wall.
* - The device credential does not exist yet, and is created only when the approved request is
* consumed by the browser holding the claim secret.
*
* `expiresAt` is the only expiry authority; there is no `expired` state to write and therefore no
* sweeper that must run for correctness. `state` records only what someone *did* — nothing here
* ages a row on its own.
*/
export const ekklesiaCoreDevicePairingRequest = pgTable(
"ekklesia_core_device_pairing_request",
{
id: uuid("id").primaryKey().defaultRandom(),
code: text("code").notNull(),
claimSecretHash: text("claim_secret_hash").notNull(),
/** The display the device actually navigated to, resolved server-side from the publication
* registry. Never a value the browser chose. */
moduleId: text("module_id").notNull(),
/** Derived from that module's validated display declaration, so a device cannot ask for a
* permission of its choosing. */
permissionId: text("permission_id").notNull(),
state: text("state").notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
/** Chosen by the approving administrator; carried here until the request is consumed and the
* device row is created. */
deviceName: text("device_name"),
approvedAt: timestamp("approved_at", { withTimezone: true }),
approvedByUserId: text("approved_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
consumedAt: timestamp("consumed_at", { withTimezone: true }),
deviceId: uuid("device_id").references(() => ekklesiaCoreDevice.id, { onDelete: "set null" }),
},
(table) => [
// At most one live request may hold a given code, which is what makes a short code safe to
// generate: a collision is refused by PostgreSQL rather than resolved by whichever row a query
// happened to return first. Consumed rows keep their code without blocking reuse later.
uniqueIndex("ekklesia_core_device_pairing_code_idx")
.on(table.code)
.where(sql`${table.state} <> 'consumed'`),
index("ekklesia_core_device_pairing_claim_idx").on(table.claimSecretHash),
check("ekklesia_core_device_pairing_code_check", sql`${table.code} ~ '^[0-9]{6}$'`),
check(
"ekklesia_core_device_pairing_claim_hash_check",
sql`length(${table.claimSecretHash}) = 64`,
),
check(
"ekklesia_core_device_pairing_state_check",
sql`${table.state} in ('pending', 'approved', 'consumed')`,
),
],
);
export const ekklesiaCoreDeviceRelations = relations(ekklesiaCoreDevice, ({ many }) => ({
permissions: many(ekklesiaCoreDevicePermission),
}));
export const ekklesiaCoreDevicePermissionRelations = relations(
ekklesiaCoreDevicePermission,
({ one }) => ({
device: one(ekklesiaCoreDevice, {
fields: [ekklesiaCoreDevicePermission.deviceId],
references: [ekklesiaCoreDevice.id],
}),
}),
);
/** The device tables, bundled exactly like `authorizationSchema`, so a Drizzle view over the
* platform's shared pool is typed against them. */
export const deviceSchema = {
ekklesiaCoreDevice,
ekklesiaCoreDevicePairingRequest,
ekklesiaCoreDevicePermission,
ekklesiaCoreDevicePermissionRelations,
ekklesiaCoreDeviceRelations,
};
+552
View File
@@ -0,0 +1,552 @@
import { createHash, randomBytes, randomInt } from "node:crypto";
import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
import type { DeviceDrizzleDatabase } from "./database.js";
import {
ekklesiaCoreDevice,
ekklesiaCoreDevicePairingRequest,
ekklesiaCoreDevicePermission,
} from "./schema.js";
export type DeviceStoreErrorCode =
| "DEVICE_PAIRING_CODE_UNAVAILABLE"
| "DEVICE_STORE_FAILED"
| "DEVICE_NOT_FOUND";
const deviceStoreMessages: Readonly<Record<DeviceStoreErrorCode, string>> = Object.freeze({
DEVICE_NOT_FOUND: "The device does not exist.",
DEVICE_PAIRING_CODE_UNAVAILABLE: "A pairing code could not be allocated. Try again.",
DEVICE_STORE_FAILED: "The device operation could not be completed.",
});
/** Never carries a credential, a claim secret, SQL, a driver error, or a cause. */
export class DeviceStoreError extends Error {
readonly code: DeviceStoreErrorCode;
constructor(code: DeviceStoreErrorCode) {
super(deviceStoreMessages[code]);
this.name = "DeviceStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* How long a pairing code is worth showing on a screen.
*
* Long enough that someone can walk from the display to a computer and type six digits; short
* enough that a code photographed off a wall is worthless by the time anyone acts on it. It is not
* the only thing protecting pairing — approval requires an authenticated administrator holding
* `core.devices.manage`, and the credential is issued only to the browser holding the claim secret
* — so this bound is defence in depth rather than the defence.
*/
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
/**
* How long a device credential is accepted before the device must pair again.
*
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
* decides on every single request whether the device is still revoked, so an administrator's revoke
* takes effect immediately regardless of how long the credential would otherwise have lived. The
* 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;
/** 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. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
const PAIRING_CODE_ALLOCATION_ATTEMPTS = 8;
/**
* How many pairing requests may be waiting at once, across the whole installation.
*
* Starting a pairing needs no identity — an unpaired screen has none to offer — so without a ceiling
* anyone who can reach the platform could fill this table. A building has a handful of screens and
* pairs them one at a time, so this is far above any real use and still a hard bound. Expired rows
* are swept on the way in, so the ceiling counts genuinely live requests rather than history.
*/
const MAX_LIVE_PAIRING_REQUESTS = 50;
/** SHA-256, hex. Applied to 256-bit CSPRNG output, never to anything user-chosen. */
export function hashDeviceSecret(secret: string): string {
return createHash("sha256").update(secret, "utf8").digest("hex");
}
/** 256 bits, URL-safe. Used for both the pairing claim secret and the device credential — the two
* values that must never be guessable. */
function generateSecret(): string {
return randomBytes(32).toString("base64url");
}
/** Six digits from a rejection-free CSPRNG range, zero padded so every code is exactly six
* characters and none is easier to type than another. */
function generatePairingCode(): string {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export interface CreatePairingRequestInput {
readonly moduleId: string;
readonly permissionId: PermissionId;
readonly now: Date;
}
export interface CreatedPairingRequest {
readonly code: string;
readonly expiresAt: Date;
/** Returned exactly once, to be written into the device's HttpOnly cookie and never persisted or
* logged in this form. */
readonly claimSecret: string;
}
export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown";
export interface PairingRequestView {
readonly moduleId: string;
readonly permissionId: string;
readonly createdAt: Date;
readonly expiresAt: Date;
readonly status: "pending" | "approved";
}
export interface ConsumedPairing {
readonly deviceId: EkklesiaDeviceId;
/** Returned exactly once, to be written into the device's HttpOnly cookie. */
readonly credential: string;
readonly expiresAt: Date;
}
export interface DeviceSummary {
readonly id: EkklesiaDeviceId;
readonly name: string;
readonly createdAt: Date;
readonly lastSeenAt?: Date;
readonly revokedAt?: Date;
readonly permissions: readonly string[];
}
export interface ResolvedDevice {
readonly deviceId: EkklesiaDeviceId;
readonly lastSeenAt?: Date;
}
export interface DeviceStore {
createPairingRequest(input: CreatePairingRequestInput): Promise<CreatedPairingRequest>;
/** Looks a live request up by the code an administrator typed. Never used by the device itself. */
findPendingByCode(code: string, now: Date): Promise<PairingRequestView | undefined>;
approvePairingRequest(input: {
readonly code: string;
readonly deviceName: string;
readonly approvedBy: EkklesiaUserId;
readonly now: Date;
}): Promise<PairingRequestView | undefined>;
/** The device's own poll. Consumes an approved request atomically and issues the credential. */
claimPairingRequest(input: {
readonly claimSecret: string;
readonly now: Date;
}): Promise<{ readonly status: PairingRequestStatus; readonly paired?: ConsumedPairing }>;
/** Resolves a presented credential to a live, unrevoked device. */
resolveCredential(credential: string, now: Date): Promise<ResolvedDevice | undefined>;
touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise<void>;
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
listDevices(): Promise<readonly DeviceSummary[]>;
revokeDevice(input: {
readonly deviceId: EkklesiaDeviceId;
readonly revokedBy: EkklesiaUserId;
readonly now: Date;
}): Promise<void>;
}
function rethrowAsStoreError(error: unknown): never {
if (error instanceof DeviceStoreError) {
throw error;
}
throw new DeviceStoreError("DEVICE_STORE_FAILED");
}
export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
/** Deletes rows that can no longer do anything: expired-and-never-approved requests, and
* consumed ones past their window. Run opportunistically on request creation rather than by a
* scheduler — pairing is rare, so the only moment the table can grow is also a fine moment to
* trim it, and nothing depends on it having run. */
const sweepDeadRequests = async (now: Date): Promise<void> => {
await db
.delete(ekklesiaCoreDevicePairingRequest)
.where(
or(
and(
lt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
),
lt(ekklesiaCoreDevicePairingRequest.expiresAt, new Date(now.getTime() - 86_400_000)),
),
)
.catch(() => undefined);
};
const store: DeviceStore = {
async approvePairingRequest(input): Promise<PairingRequestView | undefined> {
try {
// Conditional on `pending` and unexpired, so approving twice, approving an expired request,
// or approving one already consumed all affect zero rows rather than racing.
const rows = await db
.update(ekklesiaCoreDevicePairingRequest)
.set({
approvedAt: input.now,
approvedByUserId: input.approvedBy,
deviceName: input.deviceName,
state: "approved",
})
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.code, input.code),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
),
)
.returning({
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
});
const row = rows[0];
return row === undefined
? undefined
: Object.freeze({ ...row, status: "approved" as const });
} catch (error) {
rethrowAsStoreError(error);
}
},
async claimPairingRequest(input): Promise<{
readonly status: PairingRequestStatus;
readonly paired?: ConsumedPairing;
}> {
const claimSecretHash = hashDeviceSecret(input.claimSecret);
try {
return await db.transaction(async (tx) => {
const rows = await tx
.select({
deviceName: ekklesiaCoreDevicePairingRequest.deviceName,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
id: ekklesiaCoreDevicePairingRequest.id,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
state: ekklesiaCoreDevicePairingRequest.state,
approvedByUserId: ekklesiaCoreDevicePairingRequest.approvedByUserId,
})
.from(ekklesiaCoreDevicePairingRequest)
.where(eq(ekklesiaCoreDevicePairingRequest.claimSecretHash, claimSecretHash))
.limit(1);
const request = rows[0];
if (!request) {
return { status: "unknown" as const };
}
if (request.state === "consumed") {
// A credential was already issued for this request. Replaying the claim never issues a
// second one.
return { status: "unknown" as const };
}
if (request.expiresAt.getTime() <= input.now.getTime()) {
return { status: "expired" as const };
}
if (request.state !== "approved") {
return { status: "pending" as const };
}
// The single-use gate: only the transaction whose UPDATE actually matches the still
// `approved` row proceeds to mint a credential. A concurrent second claim matches zero
// rows and is told the request is gone.
const consumed = await tx
.update(ekklesiaCoreDevicePairingRequest)
.set({ consumedAt: input.now, state: "consumed" })
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.id, request.id),
eq(ekklesiaCoreDevicePairingRequest.state, "approved"),
),
)
.returning({ id: ekklesiaCoreDevicePairingRequest.id });
if (consumed.length === 0) {
return { status: "unknown" as const };
}
const credential = generateSecret();
const created = await tx
.insert(ekklesiaCoreDevice)
.values({
createdByUserId: request.approvedByUserId,
credentialHash: hashDeviceSecret(credential),
name: request.deviceName ?? "Display device",
})
.returning({ id: ekklesiaCoreDevice.id });
const deviceId = created[0]?.id;
if (deviceId === undefined) {
throw new DeviceStoreError("DEVICE_STORE_FAILED");
}
// Exactly the one permission the request was created for — never a bundle, never every
// permission the module declares.
await tx.insert(ekklesiaCoreDevicePermission).values({
deviceId,
grantedByUserId: request.approvedByUserId,
permissionId: request.permissionId,
});
await tx
.update(ekklesiaCoreDevicePairingRequest)
.set({ deviceId })
.where(eq(ekklesiaCoreDevicePairingRequest.id, request.id));
return {
paired: Object.freeze({
credential,
deviceId: deviceId as EkklesiaDeviceId,
expiresAt: new Date(input.now.getTime() + DEVICE_CREDENTIAL_TTL_MS),
}),
status: "pending" as const,
};
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async createPairingRequest(input): Promise<CreatedPairingRequest> {
await sweepDeadRequests(input.now);
const live = await db
.select({ id: ekklesiaCoreDevicePairingRequest.id })
.from(ekklesiaCoreDevicePairingRequest)
.where(
and(
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
),
)
.limit(MAX_LIVE_PAIRING_REQUESTS);
if (live.length >= MAX_LIVE_PAIRING_REQUESTS) {
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
}
const claimSecret = generateSecret();
const expiresAt = new Date(input.now.getTime() + DEVICE_PAIRING_REQUEST_TTL_MS);
// Collisions are resolved by the partial unique index, not by reading first and hoping: a
// concurrent request that took the same code makes this insert fail and we simply draw again.
for (let attempt = 0; attempt < PAIRING_CODE_ALLOCATION_ATTEMPTS; attempt += 1) {
const code = generatePairingCode();
try {
await db.insert(ekklesiaCoreDevicePairingRequest).values({
claimSecretHash: hashDeviceSecret(claimSecret),
code,
expiresAt,
moduleId: input.moduleId,
permissionId: input.permissionId,
});
return Object.freeze({ claimSecret, code, expiresAt });
} catch {
// Fall through and draw a different code.
}
}
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
},
async findPendingByCode(code, now): Promise<PairingRequestView | undefined> {
try {
const rows = await db
.select({
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
state: ekklesiaCoreDevicePairingRequest.state,
})
.from(ekklesiaCoreDevicePairingRequest)
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.code, code),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
gt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
),
)
.limit(1);
const row = rows[0];
return row === undefined
? undefined
: Object.freeze({
createdAt: row.createdAt,
expiresAt: row.expiresAt,
moduleId: row.moduleId,
permissionId: row.permissionId,
status: "pending" as const,
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async hasActivePermission(deviceId, permission): Promise<boolean> {
try {
// Joined against the device row so a revoked device has no permissions at all, without
// every permission row needing to be revoked separately.
const rows = await db
.select({ id: ekklesiaCoreDevicePermission.id })
.from(ekklesiaCoreDevicePermission)
.innerJoin(
ekklesiaCoreDevice,
eq(ekklesiaCoreDevice.id, ekklesiaCoreDevicePermission.deviceId),
)
.where(
and(
eq(ekklesiaCoreDevicePermission.deviceId, deviceId),
eq(ekklesiaCoreDevicePermission.permissionId, permission),
isNull(ekklesiaCoreDevicePermission.revokedAt),
isNull(ekklesiaCoreDevice.revokedAt),
),
)
.limit(1);
return rows.length > 0;
} catch (error) {
rethrowAsStoreError(error);
}
},
async listDevices(): Promise<readonly DeviceSummary[]> {
try {
const devices = await db
.select({
createdAt: ekklesiaCoreDevice.createdAt,
id: ekklesiaCoreDevice.id,
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
name: ekklesiaCoreDevice.name,
revokedAt: ekklesiaCoreDevice.revokedAt,
})
.from(ekklesiaCoreDevice)
.orderBy(ekklesiaCoreDevice.createdAt);
const permissions = await db
.select({
deviceId: ekklesiaCoreDevicePermission.deviceId,
permissionId: ekklesiaCoreDevicePermission.permissionId,
})
.from(ekklesiaCoreDevicePermission)
.where(isNull(ekklesiaCoreDevicePermission.revokedAt));
return Object.freeze(
devices.map((device) =>
Object.freeze({
createdAt: device.createdAt,
id: device.id as EkklesiaDeviceId,
name: device.name,
permissions: Object.freeze(
permissions
.filter((permission) => permission.deviceId === device.id)
.map((permission) => permission.permissionId)
.sort(),
),
...(device.lastSeenAt === null ? {} : { lastSeenAt: device.lastSeenAt }),
...(device.revokedAt === null ? {} : { revokedAt: device.revokedAt }),
}),
),
);
} catch (error) {
rethrowAsStoreError(error);
}
},
async resolveCredential(credential, now): Promise<ResolvedDevice | undefined> {
try {
const rows = await db
.select({
createdAt: ekklesiaCoreDevice.createdAt,
id: ekklesiaCoreDevice.id,
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
})
.from(ekklesiaCoreDevice)
.where(
and(
eq(ekklesiaCoreDevice.credentialHash, hashDeviceSecret(credential)),
isNull(ekklesiaCoreDevice.revokedAt),
),
)
.limit(1);
const row = rows[0];
if (!row) {
return undefined;
}
// The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable.
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
return undefined;
}
return Object.freeze({
deviceId: row.id as EkklesiaDeviceId,
...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }),
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async revokeDevice(input): Promise<void> {
try {
const rows = await db
.update(ekklesiaCoreDevice)
.set({ revokedAt: input.now, revokedByUserId: input.revokedBy })
.where(
and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)),
)
.returning({ id: ekklesiaCoreDevice.id });
if (rows.length === 0) {
// Either no such device or already revoked. Both are "not an active device", and telling
// the two apart would only serve enumeration.
const existing = await db
.select({ id: ekklesiaCoreDevice.id })
.from(ekklesiaCoreDevice)
.where(eq(ekklesiaCoreDevice.id, input.deviceId))
.limit(1);
if (existing.length === 0) {
throw new DeviceStoreError("DEVICE_NOT_FOUND");
}
return;
}
await db
.update(ekklesiaCoreDevicePermission)
.set({ revokedAt: input.now })
.where(
and(
eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId),
isNull(ekklesiaCoreDevicePermission.revokedAt),
),
);
} catch (error) {
rethrowAsStoreError(error);
}
},
async touchLastSeen(deviceId, now): Promise<void> {
// Throttled in SQL so concurrent requests from one display cannot each decide to write.
await db
.update(ekklesiaCoreDevice)
.set({ lastSeenAt: now })
.where(
and(
eq(ekklesiaCoreDevice.id, deviceId),
or(
isNull(ekklesiaCoreDevice.lastSeenAt),
lt(
ekklesiaCoreDevice.lastSeenAt,
new Date(now.getTime() - DEVICE_LAST_SEEN_THROTTLE_MS),
),
),
),
)
.catch(() => undefined);
},
};
return Object.freeze(store);
}
/** Exported for the schema-drift check and tests; never used to build a query string by hand. */
export const DEVICE_PAIRING_CODE_PATTERN = /^[0-9]{6}$/u;
export { sql as deviceStoreSql };
+189
View File
@@ -0,0 +1,189 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
createDevicePrincipal,
parsePermissionId,
} from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest";
import { createAuthorizationEvaluator } from "../src/authorization/evaluator.js";
/**
* Device authority, decided by the **same** evaluator that decides everything else.
*
* The point of these tests is mostly negative: a device must not acquire authority from being a
* principal, from being paired, from an `authenticated` baseline, or from platform ownership. The
* only thing that ever makes a device allowed is an explicit permission granted to that exact
* device (ADR 0036).
*/
const displayPermission = parsePermissionId("announcements.slideshow.view");
const otherPermission = parsePermissionId("calendar.display.view");
const deviceId = "3f1c8a4e-1d2b-4c3a-9e5f-6a7b8c9d0e1f";
function evaluator(options: { readonly devicePermissions?: readonly string[] } = {}) {
const granted = new Set(options.devicePermissions ?? []);
const hasActivePermission = vi.fn(async (_deviceId: string, permission: string) =>
granted.has(permission),
);
// Deliberately always true: if the evaluator ever consulted these for a device, every assertion
// below would flip, which is exactly what must not happen.
const isPlatformOwner = vi.fn(async () => true);
const hasActiveGrant = vi.fn(async () => true);
return {
evaluator: createAuthorizationEvaluator({
devices: { hasActivePermission } as never,
grants: { hasActiveGrant } as never,
platformOwners: { isPlatformOwner } as never,
}),
hasActiveGrant,
hasActivePermission,
isPlatformOwner,
};
}
describe("device principals go through the ordinary evaluator", () => {
it("allows a device holding the exact restricted permission", async () => {
const harness = evaluator({ devicePermissions: [displayPermission] });
await expect(
harness.evaluator.authorize({
baseline: "restricted",
permission: displayPermission,
principal: createDevicePrincipal(deviceId),
}),
).resolves.toEqual({ allowed: true });
expect(harness.hasActivePermission).toHaveBeenCalledWith(deviceId, displayPermission);
});
it("denies a device any permission it was not granted", async () => {
const harness = evaluator({ devicePermissions: [displayPermission] });
// Pairing a screen for one display is not authority over another module's.
await expect(
harness.evaluator.authorize({
baseline: "restricted",
permission: otherPermission,
principal: createDevicePrincipal(deviceId),
}),
).resolves.toEqual({ allowed: false });
});
it("never satisfies an authenticated baseline, however many permissions it holds", async () => {
const harness = evaluator({ devicePermissions: [displayPermission] });
// "A person is signed in" is not true of a television. Every route that settles for the
// `authenticated` baseline — the module UI inventory among them — stays closed.
await expect(
harness.evaluator.authorize({
baseline: "authenticated",
permission: displayPermission,
principal: createDevicePrincipal(deviceId),
}),
).resolves.toEqual({ allowed: false });
expect(harness.hasActivePermission).not.toHaveBeenCalled();
});
it("never consults platform ownership or user grants for a device", async () => {
const harness = evaluator();
await expect(
harness.evaluator.authorize({
baseline: "restricted",
permission: displayPermission,
principal: createDevicePrincipal(deviceId),
}),
).resolves.toEqual({ allowed: false });
// Both ports would have answered "yes". A device is denied anyway, because neither is a path a
// device can reach — there is no device that is a platform owner.
expect(harness.isPlatformOwner).not.toHaveBeenCalled();
expect(harness.hasActiveGrant).not.toHaveBeenCalled();
});
it("denies every device when the deployment composed no device support at all", async () => {
const withoutDevices = createAuthorizationEvaluator({
grants: { hasActiveGrant: async () => true } as never,
platformOwners: { isPlatformOwner: async () => true } as never,
});
await expect(
withoutDevices.authorize({
baseline: "restricted",
permission: displayPermission,
principal: createDevicePrincipal(deviceId),
}),
).resolves.toEqual({ allowed: false });
});
it("denies a scoped check for a device rather than treating it as unscoped", async () => {
const harness = evaluator({ devicePermissions: [displayPermission] });
await expect(
harness.evaluator.authorize({
baseline: "restricted",
permission: displayPermission,
principal: createDevicePrincipal(deviceId),
scope: "event-1",
}),
).resolves.toEqual({ allowed: false });
});
it("still allows a device through a public baseline, like any other principal", async () => {
const harness = evaluator();
await expect(
harness.evaluator.authorize({
baseline: "public",
permission: displayPermission,
principal: createDevicePrincipal(deviceId),
}),
).resolves.toEqual({ allowed: true });
});
it("leaves human and anonymous decisions exactly as they were", async () => {
const harness = evaluator();
await expect(
harness.evaluator.authorize({
baseline: "restricted",
permission: displayPermission,
principal: createAuthenticatedPrincipal("user-1"),
}),
).resolves.toEqual({ allowed: true });
await expect(
harness.evaluator.authorize({
baseline: "restricted",
permission: displayPermission,
principal: ANONYMOUS_PRINCIPAL,
}),
).resolves.toEqual({ allowed: false });
await expect(
harness.evaluator.authorize({
baseline: "authenticated",
permission: displayPermission,
principal: createAuthenticatedPrincipal("user-1"),
}),
).resolves.toEqual({ allowed: true });
});
it("propagates a device lookup failure rather than reporting it as denied", async () => {
const failing = createAuthorizationEvaluator({
devices: {
hasActivePermission: async () => {
throw new Error("database unavailable");
},
} as never,
grants: { hasActiveGrant: async () => false } as never,
platformOwners: { isPlatformOwner: async () => false } as never,
});
// A caller must never be told "no permission" when the true answer is "could not be
// determined", exactly as for a human.
await expect(
failing.authorize({
baseline: "restricted",
permission: displayPermission,
principal: createDevicePrincipal(deviceId),
}),
).rejects.toMatchObject({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
});
+8 -1
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_UI_READ,
@@ -132,13 +133,14 @@ describe("CORE_PERMISSION_REGISTRY", () => {
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
});
it("contains exactly these five permissions and no others", () => {
it("contains exactly these six permissions and no others", () => {
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_MANAGE,
]);
});
});
@@ -171,6 +173,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
description: "Read and change an installed module's own configuration values.",
id: CORE_MODULES_CONFIGURATION_MANAGE,
},
{
baseline: "restricted",
description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE,
},
]);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -228,6 +229,11 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_authorization_audit",
"ekklesia_core_dashboard_group",
"ekklesia_core_dashboard_placement",
// Device identity is Core's, and deliberately its own tables rather than rows smuggled into
// the user or grant tables (ADR 0036).
"ekklesia_core_device",
"ekklesia_core_device_pairing_request",
"ekklesia_core_device_permission",
"ekklesia_core_grant",
"ekklesia_core_migrations",
"ekklesia_core_module_configuration",
@@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
+1 -1
View File
@@ -87,7 +87,7 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
5 permissions registered
6 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
@@ -3,8 +3,15 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseModuleManifest } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
import {
createModuleConfigurationStore,
createModuleConfigurationView,
@@ -12,13 +19,6 @@ import {
ModuleConfigurationStoreError,
resolveModuleConfigurationFor,
} from "../src/modules/index.js";
import { parseModuleManifest } from "@ekklesia/contracts";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
/**
* The module configuration store against real PostgreSQL.
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -3,8 +3,16 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseModuleManifest } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
import type { DiscoveredModule } from "../src/modules/discovery.js";
import {
createModuleLifecycleStore,
createModuleLifecycleView,
@@ -12,14 +20,6 @@ import {
ModuleLifecycleStoreError,
resolveModuleEligibility,
} from "../src/modules/index.js";
import { parseModuleManifest } from "@ekklesia/contracts";
import type { DiscoveredModule } from "../src/modules/discovery.js";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
/**
* The module lifecycle configuration store against real PostgreSQL.
@@ -180,6 +180,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
+3 -1
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_UI_READ,
@@ -74,7 +75,7 @@ describe("composePlatformPermissionRegistry", () => {
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
// Core's own five declarations plus one from each module.
expect(registry.declarations).toHaveLength(7);
expect(registry.declarations).toHaveLength(8);
});
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
@@ -94,6 +95,7 @@ describe("composePlatformPermissionRegistry", () => {
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_MANAGE,
"calendar.events.read",
"calendar.events.write",
"people.directory.read",