Pair displays at the platform's own /display/setup, with or without modules
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s
Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.
- /display/setup is the platform's own display page, served whenever
the deployment has device support, whatever is installed. It pairs a
platform display with no module. Once paired, it opens the module it
is set to show; with none set, it shows its name and waits, rechecking
so it stays alive and notices revocation. "setup" is now a reserved
module id.
- Migration core:0009_platform_display_pairing lets a pairing request
name no module. It relaxes module_id and permission_id and adds a
both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
display. Approval accepts an optional moduleId, so the administrator
can choose an installed display module and the device gets that
module's declared permission. A module display's own request can't be
redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
its name and the module displays it may show. It renews the device's
cookie.
- GET /api/v1/devices/setup is replaced by
GET /api/v1/devices/display-modules: the modules a display can be set
to show.
- Settings -> Displays always offers the /display/setup address, Copy
setup link and Add display. Add display has an optional "Show on this
screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
906c015ca8
commit
300d9eab51
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "module_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "permission_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_module_check" CHECK (("ekklesia_core_device_pairing_request"."module_id" is null) = ("ekklesia_core_device_pairing_request"."permission_id" is null));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0009_platform_display_pairing",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1792036365485,
|
||||
"tag": "0008_user_preferences",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1792124723290,
|
||||
"tag": "0009_platform_display_pairing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -104,12 +104,19 @@ export const ekklesiaCoreDevicePairingRequest = pgTable(
|
||||
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(),
|
||||
/**
|
||||
* The module display the device will show, and the display permission approval grants it, or
|
||||
* both `null` for a platform pairing (ADR 0065): a screen that opened `/display/setup` and is
|
||||
* paired as a platform display with no module yet.
|
||||
*
|
||||
* A module display page sets both when the request is made, resolved server-side from the
|
||||
* publication registry and that module's validated display declaration, so a device cannot ask
|
||||
* for a permission of its choosing. A platform request may have them set once, by the
|
||||
* approving administrator choosing which installed display module the screen shows; the
|
||||
* permission is again derived from that module's declaration, never from the request.
|
||||
*/
|
||||
moduleId: text("module_id"),
|
||||
permissionId: text("permission_id"),
|
||||
state: text("state").notNull().default("pending"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
@@ -140,6 +147,11 @@ export const ekklesiaCoreDevicePairingRequest = pgTable(
|
||||
"ekklesia_core_device_pairing_state_check",
|
||||
sql`${table.state} in ('pending', 'approved', 'consumed')`,
|
||||
),
|
||||
// A module and its display permission are set together or not at all.
|
||||
check(
|
||||
"ekklesia_core_device_pairing_module_check",
|
||||
sql`(${table.moduleId} is null) = (${table.permissionId} is null)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+95
-16
@@ -97,9 +97,13 @@ function generatePairingCode(): string {
|
||||
return randomInt(0, 1_000_000).toString().padStart(6, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* A screen asking to be paired. `display` is the module display it opened, with the permission
|
||||
* derived from that module's validated declaration; it is absent for a platform pairing from
|
||||
* `/display/setup`, which creates a platform display with no module yet (ADR 0065).
|
||||
*/
|
||||
export interface CreatePairingRequestInput {
|
||||
readonly moduleId: string;
|
||||
readonly permissionId: PermissionId;
|
||||
readonly display?: { readonly moduleId: string; readonly permissionId: PermissionId };
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
@@ -114,8 +118,10 @@ export interface CreatedPairingRequest {
|
||||
export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown";
|
||||
|
||||
export interface PairingRequestView {
|
||||
readonly moduleId: string;
|
||||
readonly permissionId: string;
|
||||
/** The module display the screen will show, or `undefined` for a platform pairing that no module
|
||||
* has been chosen for. */
|
||||
readonly moduleId: string | undefined;
|
||||
readonly permissionId: string | undefined;
|
||||
readonly createdAt: Date;
|
||||
readonly expiresAt: Date;
|
||||
readonly status: "pending" | "approved";
|
||||
@@ -137,6 +143,13 @@ export interface DeviceSummary {
|
||||
readonly permissions: readonly string[];
|
||||
}
|
||||
|
||||
/** One live device as it sees itself (ADR 0065): its name, and the permissions it holds now. */
|
||||
export interface DeviceSelf {
|
||||
readonly id: EkklesiaDeviceId;
|
||||
readonly name: string;
|
||||
readonly permissions: readonly string[];
|
||||
}
|
||||
|
||||
export interface ResolvedDevice {
|
||||
readonly deviceId: EkklesiaDeviceId;
|
||||
readonly lastSeenAt?: Date;
|
||||
@@ -146,11 +159,18 @@ 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>;
|
||||
/**
|
||||
* Approves a pending request by its code and names the display. `display`, when given, is the
|
||||
* module display the administrator chose for a platform request; it is refused (the request is
|
||||
* left pending and `undefined` returned) for a request that already names a different module,
|
||||
* and ignored when it names the same one.
|
||||
*/
|
||||
approvePairingRequest(input: {
|
||||
readonly code: string;
|
||||
readonly deviceName: string;
|
||||
readonly approvedBy: EkklesiaUserId;
|
||||
readonly now: Date;
|
||||
readonly display?: { readonly moduleId: string; readonly permissionId: PermissionId };
|
||||
}): Promise<PairingRequestView | undefined>;
|
||||
/** The device's own poll. Consumes an approved request atomically and issues the credential. */
|
||||
claimPairingRequest(input: {
|
||||
@@ -162,6 +182,8 @@ export interface DeviceStore {
|
||||
touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise<void>;
|
||||
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
|
||||
listDevices(): Promise<readonly DeviceSummary[]>;
|
||||
/** A live, unrevoked device's own name and active permissions, or `undefined`. */
|
||||
describeDevice(deviceId: EkklesiaDeviceId): Promise<DeviceSelf | undefined>;
|
||||
revokeDevice(input: {
|
||||
readonly deviceId: EkklesiaDeviceId;
|
||||
readonly revokedBy: EkklesiaUserId;
|
||||
@@ -201,6 +223,11 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
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.
|
||||
//
|
||||
// A module chosen for a platform request is written in the same statement, and only where
|
||||
// the request names no module yet or already names that one, so a module display's own
|
||||
// request can never be redirected to a different module's permission.
|
||||
const chosen = input.display;
|
||||
const rows = await db
|
||||
.update(ekklesiaCoreDevicePairingRequest)
|
||||
.set({
|
||||
@@ -208,12 +235,23 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
approvedByUserId: input.approvedBy,
|
||||
deviceName: input.deviceName,
|
||||
state: "approved",
|
||||
...(chosen === undefined
|
||||
? {}
|
||||
: { moduleId: chosen.moduleId, permissionId: chosen.permissionId }),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevicePairingRequest.code, input.code),
|
||||
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
|
||||
...(chosen === undefined
|
||||
? []
|
||||
: [
|
||||
or(
|
||||
isNull(ekklesiaCoreDevicePairingRequest.moduleId),
|
||||
eq(ekklesiaCoreDevicePairingRequest.moduleId, chosen.moduleId),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
@@ -225,7 +263,13 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
const row = rows[0];
|
||||
return row === undefined
|
||||
? undefined
|
||||
: Object.freeze({ ...row, status: "approved" as const });
|
||||
: Object.freeze({
|
||||
createdAt: row.createdAt,
|
||||
expiresAt: row.expiresAt,
|
||||
moduleId: row.moduleId ?? undefined,
|
||||
permissionId: row.permissionId ?? undefined,
|
||||
status: "approved" as const,
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
@@ -297,13 +341,16 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
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,
|
||||
});
|
||||
// Exactly the one permission the request carries — never a bundle, never every
|
||||
// permission the module declares — and none at all for a platform display no module was
|
||||
// chosen for: it is paired, and simply shows nothing yet (ADR 0065).
|
||||
if (request.permissionId !== null) {
|
||||
await tx.insert(ekklesiaCoreDevicePermission).values({
|
||||
deviceId,
|
||||
grantedByUserId: request.approvedByUserId,
|
||||
permissionId: request.permissionId,
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(ekklesiaCoreDevicePairingRequest)
|
||||
@@ -350,8 +397,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
claimSecretHash: hashDeviceSecret(claimSecret),
|
||||
code,
|
||||
expiresAt,
|
||||
moduleId: input.moduleId,
|
||||
permissionId: input.permissionId,
|
||||
moduleId: input.display?.moduleId ?? null,
|
||||
permissionId: input.display?.permissionId ?? null,
|
||||
});
|
||||
return Object.freeze({ claimSecret, code, expiresAt });
|
||||
} catch {
|
||||
@@ -386,8 +433,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
: Object.freeze({
|
||||
createdAt: row.createdAt,
|
||||
expiresAt: row.expiresAt,
|
||||
moduleId: row.moduleId,
|
||||
permissionId: row.permissionId,
|
||||
moduleId: row.moduleId ?? undefined,
|
||||
permissionId: row.permissionId ?? undefined,
|
||||
status: "pending" as const,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -421,6 +468,38 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
}
|
||||
},
|
||||
|
||||
async describeDevice(deviceId): Promise<DeviceSelf | undefined> {
|
||||
try {
|
||||
const devices = await db
|
||||
.select({ id: ekklesiaCoreDevice.id, name: ekklesiaCoreDevice.name })
|
||||
.from(ekklesiaCoreDevice)
|
||||
.where(and(eq(ekklesiaCoreDevice.id, deviceId), isNull(ekklesiaCoreDevice.revokedAt)))
|
||||
.limit(1);
|
||||
const device = devices[0];
|
||||
if (device === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const permissions = await db
|
||||
.select({ permissionId: ekklesiaCoreDevicePermission.permissionId })
|
||||
.from(ekklesiaCoreDevicePermission)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevicePermission.deviceId, deviceId),
|
||||
isNull(ekklesiaCoreDevicePermission.revokedAt),
|
||||
),
|
||||
);
|
||||
return Object.freeze({
|
||||
id: device.id as EkklesiaDeviceId,
|
||||
name: device.name,
|
||||
permissions: Object.freeze(
|
||||
[...new Set(permissions.map((row) => row.permissionId))].sort(),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async listDevices(): Promise<readonly DeviceSummary[]> {
|
||||
try {
|
||||
const devices = await db
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061).
|
||||
* Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061, ADR 0065).
|
||||
*
|
||||
* A module id is a **stable URL identifier**. Every module with a Dashboard UI is addressed at
|
||||
* `/<module-id>`, and every module with settings at `/settings/<module-id>`, beside the platform's
|
||||
* own pages in the same two namespaces. A module called `settings` would collide with the
|
||||
* `/<module-id>`, every module with settings at `/settings/<module-id>`, and every module with a
|
||||
* display at `/display/<module-id>`, beside the platform's own pages in the same three namespaces. A module called `settings` would collide with the
|
||||
* Dashboard's Settings, and one called `discover` with Settings → Discover. This is the complete set
|
||||
* the platform serves today, not a guess at what it might want later.
|
||||
*/
|
||||
@@ -39,6 +39,13 @@ export const RESERVED_SETTINGS_NAMES = Object.freeze([
|
||||
"system",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* The display host's own pages (`/display/<name>`): `setup`, where a new screen starts pairing as a
|
||||
* platform display before any module is chosen for it (ADR 0065). Every other `/display/<name>` is a
|
||||
* module's display.
|
||||
*/
|
||||
export const RESERVED_DISPLAY_HOST_NAMES = Object.freeze(["setup"] as const);
|
||||
|
||||
/**
|
||||
* Every reserved name, sorted. `apps/api` holds this list to the routes it actually registers and to
|
||||
* the pages and Settings sections the shell interprets, so a new platform route or Settings section
|
||||
@@ -46,7 +53,7 @@ export const RESERVED_SETTINGS_NAMES = Object.freeze([
|
||||
* installation with a module of that id, so the set grows only deliberately.
|
||||
*/
|
||||
export const RESERVED_MODULE_IDS = Object.freeze(
|
||||
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
|
||||
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES, ...RESERVED_DISPLAY_HOST_NAMES].sort(),
|
||||
);
|
||||
|
||||
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
|
||||
|
||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
"core:0009_platform_display_pairing",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
"core:0009_platform_display_pairing",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
"core:0009_platform_display_pairing",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ekklesiaCoreDevicePairingRequest } from "../src/devices/index.js";
|
||||
|
||||
/**
|
||||
* A pairing request may name no module (ADR 0065): a screen on the platform's `/display/setup`
|
||||
* pairs as a platform display, and a module is chosen for it, if at all, when it is approved.
|
||||
*/
|
||||
const migrationsDirectory = path.join(
|
||||
path.dirname(path.dirname(fileURLToPath(import.meta.url))),
|
||||
"drizzle",
|
||||
);
|
||||
|
||||
describe("platform display pairing requests", () => {
|
||||
it("lets a request name no module, but never a module without its permission", () => {
|
||||
const config = getTableConfig(ekklesiaCoreDevicePairingRequest);
|
||||
for (const name of ["module_id", "permission_id"]) {
|
||||
expect(config.columns.find((column) => column.name === name)?.notNull, name).toBe(false);
|
||||
}
|
||||
expect(config.checks.map((check) => check.name)).toContain(
|
||||
"ekklesia_core_device_pairing_module_check",
|
||||
);
|
||||
});
|
||||
|
||||
it("is migrated by relaxing two columns and adding the both-or-neither check, nothing else", async () => {
|
||||
const sql = await readFile(
|
||||
path.join(migrationsDirectory, "0009_platform_display_pairing.sql"),
|
||||
"utf8",
|
||||
);
|
||||
const statements = sql
|
||||
.split("--> statement-breakpoint")
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
expect(statements).toEqual([
|
||||
'ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "module_id" DROP NOT NULL;',
|
||||
'ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "permission_id" DROP NOT NULL;',
|
||||
'ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_module_check" CHECK (("ekklesia_core_device_pairing_request"."module_id" is null) = ("ekklesia_core_device_pairing_request"."permission_id" is null));',
|
||||
]);
|
||||
// Existing requests, which all name a module, satisfy the new check unchanged.
|
||||
});
|
||||
|
||||
it("is a migration that destroys nothing", async () => {
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"),
|
||||
) as { entries: { tag: string; destructive: { mode: string } }[] };
|
||||
expect(metadata.entries.find((entry) => entry.tag === "0009_platform_display_pairing")).toEqual(
|
||||
{ destructive: { mode: "none" }, tag: "0009_platform_display_pairing" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
"core:0009_platform_display_pairing",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isReservedModuleId,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryIssueCode,
|
||||
RESERVED_DISPLAY_HOST_NAMES,
|
||||
RESERVED_MODULE_IDS,
|
||||
RESERVED_SETTINGS_NAMES,
|
||||
RESERVED_TOP_LEVEL_NAMES,
|
||||
@@ -301,9 +302,15 @@ describe("module discovery", () => {
|
||||
"displays",
|
||||
"general",
|
||||
"settings",
|
||||
"setup",
|
||||
"system",
|
||||
]);
|
||||
for (const list of [RESERVED_MODULE_IDS, RESERVED_TOP_LEVEL_NAMES, RESERVED_SETTINGS_NAMES]) {
|
||||
for (const list of [
|
||||
RESERVED_MODULE_IDS,
|
||||
RESERVED_TOP_LEVEL_NAMES,
|
||||
RESERVED_SETTINGS_NAMES,
|
||||
RESERVED_DISPLAY_HOST_NAMES,
|
||||
]) {
|
||||
expect(Object.isFrozen(list)).toBe(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
"core:0009_platform_display_pairing",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user