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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ce4fe03fe
commit
80189c8ae2
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./database.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./store.js";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user