Files
Ekklesia/core/src/devices/store.ts
T
ekklesiaandClaude Opus 5 80189c8ae2
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s
feat: give display devices their own paired identity
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]>
2026-08-23 23:41:03 -05:00

553 lines
21 KiB
TypeScript

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 };