feat(db): full async Repository conversion for PostgreSQL support

Mechanical conversion of the entire data access layer from synchronous
node:sqlite API to async DbAdapter interface. Enables PostgreSQL
(PgAdapter) as a drop-in backend alongside SQLite (SqliteAdapter).

Repository (2208 lines):
  - Constructor accepts DbAdapter instead of DatabaseSync
  - Internal _run/_get/_all/_exec helpers wrap adapter calls
  - All 155 methods converted to async, return Promise<T>
  - transact() uses adapter.transaction() (supports PG savepoints)

14 caller files updated (327 call sites):
  - routes-admin.ts: 202 repo calls + 6 async helper functions
  - service-api-http: 40 repo calls + async getClusterKey
  - routes-firmware.ts, routes-os-updates.ts, routes-auth.ts,
    routes-setup.ts, middleware.ts: all handlers made async
  - shared/auth.ts: resolveSession + revokeSession now async
  - shared/bundle.ts: generateBundle now async, .map→for..of loops
  - shared/pairing.ts: all 3 functions async
  - shared/audit.ts: audit() now async
  - shared/camera-health.ts: checkAll repo calls awaited
  - service-coordinator-ws: session + kiosk lookups awaited
  - service-store/index.ts: creates SqliteAdapter.fromExisting()

SqliteAdapter gains static fromExisting(db) factory for wrapping an
already-opened DatabaseSync (migrations run on raw db, then adapter
wraps for Repository queries).

tsc --noEmit: zero errors.
This commit is contained in:
Mitchell R 2026-05-23 02:07:44 +02:00
parent 46fcbe5197
commit ed2050cfd8
No known key found for this signature in database
17 changed files with 1329 additions and 1249 deletions

View file

@ -170,7 +170,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
// Auth-check endpoint for Angie auth_request subrequest. // Auth-check endpoint for Angie auth_request subrequest.
// Returns 200 if session cookie is valid + admin role, 401 otherwise. // Returns 200 if session cookie is valid + admin role, 401 otherwise.
app.get("/api/admin/_check", (event) => { app.get("/api/admin/_check", async (event) => {
const authz = event.req.headers.get("authorization"); const authz = event.req.headers.get("authorization");
if (authz?.startsWith("Bearer ")) { if (authz?.startsWith("Bearer ")) {
return deps.auth.verifyApiKey(authz.slice(7), event.req.headers.get("x-real-ip")).then((key) => { return deps.auth.verifyApiKey(authz.slice(7), event.req.headers.get("x-real-ip")).then((key) => {
@ -185,7 +185,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
const cookie = event.req.headers.get("cookie") ?? ""; const cookie = event.req.headers.get("cookie") ?? "";
const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`)); const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`));
if (!match) return new Response(null, { status: 401 }); if (!match) return new Response(null, { status: 401 });
const resolved = deps.auth.resolveSession(match[1]!); const resolved = await deps.auth.resolveSession(match[1]!);
if (!resolved || resolved.session.totp_pending) { if (!resolved || resolved.session.totp_pending) {
return new Response(null, { status: 401 }); return new Response(null, { status: 401 });
} }
@ -199,9 +199,9 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
}); });
app.get("/healthz", () => ({ status: "ok" })); app.get("/healthz", () => ({ status: "ok" }));
app.get("/readyz", () => { app.get("/readyz", async () => {
try { try {
deps.repo.isSetupComplete(); await deps.repo.isSetupComplete();
return { status: "ready" }; return { status: "ready" };
} catch { } catch {
return { status: "not_ready" }; return { status: "not_ready" };
@ -290,7 +290,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
auth: AuthApi, auth: AuthApi,
): Promise<string> { ): Promise<string> {
const KEY = "nodered_api_key"; const KEY = "nodered_api_key";
const stored = repo.getSetupExtra(KEY); const stored = await repo.getSetupExtra(KEY);
if (typeof stored === "string" && stored.length > 0) { if (typeof stored === "string" && stored.length > 0) {
return secrets.decryptString(stored, "nodered_api_key"); return secrets.decryptString(stored, "nodered_api_key");
} }
@ -299,7 +299,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
scopes: ["admin"], scopes: ["admin"],
expiresAt: null, expiresAt: null,
}); });
repo.setSetupExtra(KEY, secrets.encryptString(plaintext, "nodered_api_key")); await repo.setSetupExtra(KEY, secrets.encryptString(plaintext, "nodered_api_key"));
return plaintext; return plaintext;
} }

View file

@ -61,7 +61,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
return; return;
} }
if (!deps.repo.isSetupComplete()) { if (!(await deps.repo.isSetupComplete())) {
if (!path.startsWith("/auth/")) { if (!path.startsWith("/auth/")) {
return new Response(null, { status: 302, headers: { location: "/setup" } }); return new Response(null, { status: 302, headers: { location: "/setup" } });
} }
@ -102,7 +102,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
if (!cookie) { if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } }); return new Response(null, { status: 302, headers: { location: "/auth/login" } });
} }
const resolved = deps.auth.resolveSession(cookie); const resolved = await deps.auth.resolveSession(cookie);
if (!resolved) { if (!resolved) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } }); return new Response(null, { status: 302, headers: { location: "/auth/login" } });
} }

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() ?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
?? "anon"; ?? "anon";
if (!loginGuard.take(`login:${ip}`)) { if (!loginGuard.take(`login:${ip}`)) {
audit(deps.repo, event as any, "user.login", { await audit(deps.repo, event as any, "user.login", {
result: "failed", result: "failed",
metadata: { reason: "rate_limited", ip }, metadata: { reason: "rate_limited", ip },
}); });
@ -45,7 +45,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
return htmlPage(LoginPage({ error: "Username and password required.", username })); return htmlPage(LoginPage({ error: "Username and password required.", username }));
} }
const user = deps.repo.getUserByUsername(username); const user = await deps.repo.getUserByUsername(username);
if (!user || !user.is_active) { if (!user || !user.is_active) {
return htmlPage(LoginPage({ error: "Invalid credentials.", username })); return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
} }
@ -64,8 +64,8 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
if (count >= deps.auth.config.loginLockoutThreshold) { if (count >= deps.auth.config.loginLockoutThreshold) {
patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString(); patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString();
} }
deps.repo.updateUser(user.id, patch); await deps.repo.updateUser(user.id, patch);
audit(deps.repo, event as any, "user.login", { await audit(deps.repo, event as any, "user.login", {
result: "failed", result: "failed",
actor_type: "system", actor_type: "system",
actor_label: username, actor_label: username,
@ -74,13 +74,13 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
return htmlPage(LoginPage({ error: "Invalid credentials.", username })); return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
} }
deps.repo.updateUser(user.id, { await deps.repo.updateUser(user.id, {
failed_login_count: 0, failed_login_count: 0,
locked_until: null, locked_until: null,
last_login_at: new Date().toISOString(), last_login_at: new Date().toISOString(),
}); });
audit(deps.repo, event as any, "user.login", { await audit(deps.repo, event as any, "user.login", {
actor_type: "user", actor_type: "user",
actor_id: user.id, actor_id: user.id,
actor_label: user.username, actor_label: user.username,
@ -106,12 +106,12 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
// ---- TOTP ----------------------------------------------------------------- // ---- TOTP -----------------------------------------------------------------
app.get("/auth/totp", (event) => { app.get("/auth/totp", async (event) => {
const cookie = getCookie(event, deps.cookieName); const cookie = getCookie(event, deps.cookieName);
if (!cookie) { if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } }); return new Response(null, { status: 302, headers: { location: "/auth/login" } });
} }
const resolved = deps.auth.resolveSession(cookie); const resolved = await deps.auth.resolveSession(cookie);
if (!resolved || !resolved.session.totp_pending) { if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
@ -123,7 +123,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
if (!cookie) { if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } }); return new Response(null, { status: 302, headers: { location: "/auth/login" } });
} }
const resolved = deps.auth.resolveSession(cookie); const resolved = await deps.auth.resolveSession(cookie);
if (!resolved || !resolved.session.totp_pending) { if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
@ -146,18 +146,18 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
return htmlPage(TotpPage({ error: "Invalid code. Try again." })); return htmlPage(TotpPage({ error: "Invalid code. Try again." }));
} }
deps.repo.setSessionTotpPending(session.id, false); await deps.repo.setSessionTotpPending(session.id, false);
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
}); });
// ---- Recovery code -------------------------------------------------------- // ---- Recovery code --------------------------------------------------------
app.get("/auth/recovery", (event) => { app.get("/auth/recovery", async (event) => {
const cookie = getCookie(event, deps.cookieName); const cookie = getCookie(event, deps.cookieName);
if (!cookie) { if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } }); return new Response(null, { status: 302, headers: { location: "/auth/login" } });
} }
const resolved = deps.auth.resolveSession(cookie); const resolved = await deps.auth.resolveSession(cookie);
if (!resolved || !resolved.session.totp_pending) { if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
@ -169,7 +169,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
if (!cookie) { if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } }); return new Response(null, { status: 302, headers: { location: "/auth/login" } });
} }
const resolved = deps.auth.resolveSession(cookie); const resolved = await deps.auth.resolveSession(cookie);
if (!resolved || !resolved.session.totp_pending) { if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
@ -189,22 +189,22 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
return htmlPage(RecoveryPage({ error: "Invalid recovery code." })); return htmlPage(RecoveryPage({ error: "Invalid recovery code." }));
} }
deps.repo.updateUser(user.id, { await deps.repo.updateUser(user.id, {
recovery_codes_hashed: result.remaining, recovery_codes_hashed: result.remaining,
}); });
deps.repo.setSessionTotpPending(session.id, false); await deps.repo.setSessionTotpPending(session.id, false);
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
}); });
// ---- Logout --------------------------------------------------------------- // ---- Logout ---------------------------------------------------------------
app.post("/auth/logout", (event) => { app.post("/auth/logout", async (event) => {
const cookie = getCookie(event, deps.cookieName); const cookie = getCookie(event, deps.cookieName);
if (cookie) { if (cookie) {
const resolved = deps.auth.resolveSession(cookie); const resolved = await deps.auth.resolveSession(cookie);
if (resolved) { if (resolved) {
deps.auth.revokeSession(resolved.session.id); await deps.auth.revokeSession(resolved.session.id);
} }
} }
return redirectClearCookie("/auth/login", deps.cookieName); return redirectClearCookie("/auth/login", deps.cookieName);

View file

@ -32,9 +32,9 @@ const ALLOWED_ARCHES = new Set([
export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
// ---- List page ----------------------------------------------------------- // ---- List page -----------------------------------------------------------
app.get("/admin/firmware", (event) => { app.get("/admin/firmware", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const releases = deps.repo.listFirmwareReleases(); const releases = await deps.repo.listFirmwareReleases();
return htmlPage(FirmwarePage({ return htmlPage(FirmwarePage({
user: user.username, user: user.username,
releases, releases,
@ -70,7 +70,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
const { sha256, signature } = deps.firmware.signBlob(buf); const { sha256, signature } = deps.firmware.signBlob(buf);
const artifactPath = await deps.firmware.storeBlob(buf, sha256); const artifactPath = await deps.firmware.storeBlob(buf, sha256);
const release = deps.repo.createFirmwareRelease({ const release = await deps.repo.createFirmwareRelease({
id: randomUUID(), id: randomUUID(),
version, version,
channel: channelRaw as FirmwareChannel, channel: channelRaw as FirmwareChannel,
@ -82,7 +82,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
release_notes: releaseNotes, release_notes: releaseNotes,
uploaded_by: user.id, uploaded_by: user.id,
}); });
audit(deps.repo, event as any, "firmware.upload", { await audit(deps.repo, event as any, "firmware.upload", {
resource_type: "firmware_release", resource_type: "firmware_release",
resource_id: release.id, resource_id: release.id,
metadata: { version, channel: channelRaw, arch, sha256, size: buf.length }, metadata: { version, channel: channelRaw, arch, sha256, size: buf.length },
@ -123,7 +123,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
const { sha256, signature } = deps.firmware.signBlob(buf); const { sha256, signature } = deps.firmware.signBlob(buf);
const artifactPath = await deps.firmware.storeBlob(buf, sha256); const artifactPath = await deps.firmware.storeBlob(buf, sha256);
const id = randomUUID(); const id = randomUUID();
const release = deps.repo.createFirmwareRelease({ const release = await deps.repo.createFirmwareRelease({
id, id,
version: body.version, version: body.version,
channel: body.channel, channel: body.channel,
@ -140,10 +140,10 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
}); });
// ---- Yank --------------------------------------------------------------- // ---- Yank ---------------------------------------------------------------
app.post("/admin/firmware/:id/yank", (event) => { app.post("/admin/firmware/:id/yank", async (event) => {
const id = String(getRouterParam(event, "id")); const id = String(getRouterParam(event, "id"));
deps.repo.yankFirmwareRelease(id); await deps.repo.yankFirmwareRelease(id);
audit(deps.repo, event as any, "firmware.yank", { await audit(deps.repo, event as any, "firmware.yank", {
resource_type: "firmware_release", resource_type: "firmware_release",
resource_id: id, resource_id: id,
}); });
@ -160,15 +160,15 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
if (!ALLOWED_CHANNELS.has(channelRaw)) { if (!ALLOWED_CHANNELS.has(channelRaw)) {
throw createError({ statusCode: 400, statusMessage: "invalid channel" }); throw createError({ statusCode: 400, statusMessage: "invalid channel" });
} }
deps.repo.setKioskFirmwarePref(id, { await deps.repo.setKioskFirmwarePref(id, {
channel: channelRaw, channel: channelRaw,
target_version: targetRaw ? targetRaw : null, target_version: targetRaw ? targetRaw : null,
}); });
const k = deps.repo.getKioskById(id); const k = await deps.repo.getKioskById(id);
if (!k) { if (!k) {
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
} }
const releases = deps.repo.listFirmwareReleases(); const releases = await deps.repo.listFirmwareReleases();
return htmlFragment(KioskFirmwarePanel({ kiosk: k, releases })); return htmlFragment(KioskFirmwarePanel({ kiosk: k, releases }));
}); });
@ -183,11 +183,11 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
// ---- Rollouts ----------------------------------------------------------- // ---- Rollouts -----------------------------------------------------------
app.get("/admin/firmware/rollouts", (event) => { app.get("/admin/firmware/rollouts", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const rollouts = deps.repo.listFirmwareRollouts(); const rollouts = await deps.repo.listFirmwareRollouts();
const releases = deps.repo.listFirmwareReleases(); const releases = await deps.repo.listFirmwareReleases();
const kiosks = deps.repo.listKiosks(); const kiosks = await deps.repo.listKiosks();
return htmlPage(FirmwareRolloutsPage({ return htmlPage(FirmwareRolloutsPage({
user: user.username, user: user.username,
rollouts, rollouts,
@ -200,7 +200,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
const body = await readBody<Record<string, string | string[]>>(event); const body = await readBody<Record<string, string | string[]>>(event);
const releaseId = String(body?.["release_id"] ?? ""); const releaseId = String(body?.["release_id"] ?? "");
if (!releaseId) throw createError({ statusCode: 400, statusMessage: "release_id required" }); if (!releaseId) throw createError({ statusCode: 400, statusMessage: "release_id required" });
const release = deps.repo.getFirmwareRelease(releaseId); const release = await deps.repo.getFirmwareRelease(releaseId);
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" }); if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
const targetsRaw = body?.["target_kiosk_ids"]; const targetsRaw = body?.["target_kiosk_ids"];
@ -210,15 +210,15 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) ? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))
: []; : [];
const user = event.context.user!; const user = event.context.user!;
const rollout = deps.repo.createFirmwareRollout({ const rollout = await deps.repo.createFirmwareRollout({
id: randomUUID(), id: randomUUID(),
release_id: releaseId, release_id: releaseId,
target_kiosk_ids: targets, target_kiosk_ids: targets,
percentage, percentage,
created_by: user.id ?? null, created_by: user.id ?? null,
}); });
deps.repo.updateFirmwareRolloutState(rollout.id, "active"); await deps.repo.updateFirmwareRolloutState(rollout.id, "active");
audit(deps.repo, event as any, "firmware.rollout.create", { await audit(deps.repo, event as any, "firmware.rollout.create", {
resource_type: "firmware_rollout", resource_type: "firmware_rollout",
resource_id: rollout.id, resource_id: rollout.id,
metadata: { release_id: releaseId, percentage, target_count: targets.length }, metadata: { release_id: releaseId, percentage, target_count: targets.length },
@ -226,7 +226,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
// Bump every targeted kiosk to check now (best-effort over WS). // Bump every targeted kiosk to check now (best-effort over WS).
const coord = getCoordinator(); const coord = getCoordinator();
if (targets.length === 0) { if (targets.length === 0) {
const allKiosks = deps.repo.listKiosks(); const allKiosks = await deps.repo.listKiosks();
for (const k of allKiosks) coord.sendToKiosk(k.id, { type: "firmware_check" }); for (const k of allKiosks) coord.sendToKiosk(k.id, { type: "firmware_check" });
} else { } else {
for (const id of targets) coord.sendToKiosk(id, { type: "firmware_check" }); for (const id of targets) coord.sendToKiosk(id, { type: "firmware_check" });
@ -241,7 +241,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
if (state !== "paused" && state !== "active" && state !== "complete") { if (state !== "paused" && state !== "active" && state !== "complete") {
throw createError({ statusCode: 400, statusMessage: "invalid state" }); throw createError({ statusCode: 400, statusMessage: "invalid state" });
} }
deps.repo.updateFirmwareRolloutState(id, state); await deps.repo.updateFirmwareRolloutState(id, state);
return new Response(null, { status: 302, headers: { location: "/admin/firmware/rollouts" } }); return new Response(null, { status: 302, headers: { location: "/admin/firmware/rollouts" } });
}); });
} }

View file

@ -25,17 +25,17 @@ function clamp(n: number, lo: number, hi: number): number {
export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
// ---- List page ----------------------------------------------------------- // ---- List page -----------------------------------------------------------
app.get("/admin/os-updates", (event) => { app.get("/admin/os-updates", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const releases = deps.repo.listOsUpdateReleases(); const releases = await deps.repo.listOsUpdateReleases();
return htmlPage(OsUpdatePage({ user: user.username, releases })); return htmlPage(OsUpdatePage({ user: user.username, releases }));
}); });
// ---- Yank --------------------------------------------------------------- // ---- Yank ---------------------------------------------------------------
app.post("/admin/os-updates/:id/yank", (event) => { app.post("/admin/os-updates/:id/yank", async (event) => {
const id = String(getRouterParam(event, "id")); const id = String(getRouterParam(event, "id"));
deps.repo.yankOsUpdateRelease(id); await deps.repo.yankOsUpdateRelease(id);
audit(deps.repo, event as any, "os_update.yank", { await audit(deps.repo, event as any, "os_update.yank", {
resource_type: "os_update_release", resource_type: "os_update_release",
resource_id: id, resource_id: id,
}); });
@ -51,15 +51,15 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
if (!ALLOWED_CHANNELS.has(channelRaw)) { if (!ALLOWED_CHANNELS.has(channelRaw)) {
throw createError({ statusCode: 400, statusMessage: "invalid channel" }); throw createError({ statusCode: 400, statusMessage: "invalid channel" });
} }
deps.repo.setKioskOsUpdatePref(id, { await deps.repo.setKioskOsUpdatePref(id, {
channel: channelRaw, channel: channelRaw,
target_version: targetRaw ? targetRaw : null, target_version: targetRaw ? targetRaw : null,
}); });
const k = deps.repo.getKioskById(id); const k = await deps.repo.getKioskById(id);
if (!k) { if (!k) {
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
} }
const releases = deps.repo.listOsUpdateReleases(); const releases = await deps.repo.listOsUpdateReleases();
return htmlFragment(KioskOsUpdatePanel({ kiosk: k, releases })); return htmlFragment(KioskOsUpdatePanel({ kiosk: k, releases }));
}); });
@ -72,11 +72,11 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
}); });
// ---- Rollouts ----------------------------------------------------------- // ---- Rollouts -----------------------------------------------------------
app.get("/admin/os-updates/rollouts", (event) => { app.get("/admin/os-updates/rollouts", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const rollouts = deps.repo.listOsUpdateRollouts(); const rollouts = await deps.repo.listOsUpdateRollouts();
const releases = deps.repo.listOsUpdateReleases(); const releases = await deps.repo.listOsUpdateReleases();
const kiosks = deps.repo.listKiosks(); const kiosks = await deps.repo.listKiosks();
return htmlPage(OsUpdateRolloutsPage({ return htmlPage(OsUpdateRolloutsPage({
user: user.username, user: user.username,
rollouts, rollouts,
@ -89,7 +89,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
const body = await readBody<Record<string, string | string[]>>(event); const body = await readBody<Record<string, string | string[]>>(event);
const releaseId = String(body?.["release_id"] ?? ""); const releaseId = String(body?.["release_id"] ?? "");
if (!releaseId) throw createError({ statusCode: 400, statusMessage: "release_id required" }); if (!releaseId) throw createError({ statusCode: 400, statusMessage: "release_id required" });
const release = deps.repo.getOsUpdateRelease(releaseId); const release = await deps.repo.getOsUpdateRelease(releaseId);
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" }); if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
const targetsRaw = body?.["target_kiosk_ids"]; const targetsRaw = body?.["target_kiosk_ids"];
@ -99,15 +99,15 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) ? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))
: []; : [];
const user = event.context.user!; const user = event.context.user!;
const rollout = deps.repo.createOsUpdateRollout({ const rollout = await deps.repo.createOsUpdateRollout({
id: randomUUID(), id: randomUUID(),
release_id: releaseId, release_id: releaseId,
target_kiosk_ids: targets, target_kiosk_ids: targets,
percentage, percentage,
created_by: user.id ?? null, created_by: user.id ?? null,
}); });
deps.repo.updateOsUpdateRolloutState(rollout.id, "active"); await deps.repo.updateOsUpdateRolloutState(rollout.id, "active");
audit(deps.repo, event as any, "os_update.rollout.create", { await audit(deps.repo, event as any, "os_update.rollout.create", {
resource_type: "os_update_rollout", resource_type: "os_update_rollout",
resource_id: rollout.id, resource_id: rollout.id,
metadata: { release_id: releaseId, percentage, target_count: targets.length }, metadata: { release_id: releaseId, percentage, target_count: targets.length },
@ -122,7 +122,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
if (state !== "paused" && state !== "active" && state !== "complete") { if (state !== "paused" && state !== "active" && state !== "complete") {
throw createError({ statusCode: 400, statusMessage: "invalid state" }); throw createError({ statusCode: 400, statusMessage: "invalid state" });
} }
deps.repo.updateOsUpdateRolloutState(id, state); await deps.repo.updateOsUpdateRolloutState(id, state);
return new Response(null, { status: 302, headers: { location: "/admin/os-updates/rollouts" } }); return new Response(null, { status: 302, headers: { location: "/admin/os-updates/rollouts" } });
}); });
@ -165,7 +165,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
let release; let release;
try { try {
release = deps.repo.createOsUpdateRelease({ release = await deps.repo.createOsUpdateRelease({
id: randomUUID(), id: randomUUID(),
version, version,
channel, channel,
@ -181,7 +181,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
throw createError({ statusCode: 409, statusMessage: (err as Error).message }); throw createError({ statusCode: 409, statusMessage: (err as Error).message });
} }
audit(deps.repo, event as any, "os_update.import", { await audit(deps.repo, event as any, "os_update.import", {
resource_type: "os_update_release", resource_type: "os_update_release",
resource_id: release.id, resource_id: release.id,
metadata: { metadata: {

View file

@ -7,15 +7,15 @@ import type { AdminDeps } from "./index.js";
import { SetupPage } from "../../web-templates/auth-pages.js"; import { SetupPage } from "../../web-templates/auth-pages.js";
export function registerSetupRoutes(app: H3, deps: AdminDeps): void { export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
app.get("/setup", () => { app.get("/setup", async () => {
if (deps.repo.isSetupComplete()) { if (await deps.repo.isSetupComplete()) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
return htmlPage(SetupPage({})); return htmlPage(SetupPage({}));
}); });
app.post("/setup", async (event) => { app.post("/setup", async (event) => {
if (deps.repo.isSetupComplete()) { if (await deps.repo.isSetupComplete()) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
@ -38,17 +38,17 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
} }
const hash = await deps.auth.hashPassword(password); const hash = await deps.auth.hashPassword(password);
deps.repo.createUser({ username, password_hash: hash, role: "admin" }); await deps.repo.createUser({ username, password_hash: hash, role: "admin" });
const clusterKey = deps.secrets.generateClusterKey(); const clusterKey = deps.secrets.generateClusterKey();
const encryptedCluster = deps.secrets.encryptString(clusterKey, "cluster"); const encryptedCluster = deps.secrets.encryptString(clusterKey, "cluster");
deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster); await deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
deps.repo.markClusterKeyProvisioned(); await deps.repo.markClusterKeyProvisioned();
// Setup only creates admin user + cluster key. // Setup only creates admin user + cluster key.
// Displays are created when kiosks are paired (kiosk reports HDMI ports). // Displays are created when kiosks are paired (kiosk reports HDMI ports).
// Layouts are created by admin after pairing. // Layouts are created by admin after pairing.
deps.repo.markSetupComplete(); await deps.repo.markSetupComplete();
return new Response(null, { return new Response(null, {
status: 302, status: 302,

View file

@ -195,8 +195,8 @@ function extractBearerToken(event: any): string | null {
return null; return null;
} }
function getClusterKey(repo: Repository, secrets: SecretsApi): string | undefined { async function getClusterKey(repo: Repository, secrets: SecretsApi): Promise<string | undefined> {
const enc = repo.getSetupExtra("cluster_key_encrypted") as string | undefined; const enc = await repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
if (!enc) return undefined; if (!enc) return undefined;
return secrets.decryptString(enc, "cluster"); return secrets.decryptString(enc, "cluster");
} }
@ -230,7 +230,7 @@ function registerPairingRoutes(
managed_image?: boolean; managed_image?: boolean;
}>(event); }>(event);
const result = initiatePairing(repo, { const result = await initiatePairing(repo, {
proposedName: body?.proposed_name ?? null, proposedName: body?.proposed_name ?? null,
hardwareModel: body?.hardware_model ?? null, hardwareModel: body?.hardware_model ?? null,
capabilities: body?.capabilities ?? [], capabilities: body?.capabilities ?? [],
@ -254,7 +254,7 @@ function registerPairingRoutes(
const code = (body?.code ?? "").trim().toUpperCase(); const code = (body?.code ?? "").trim().toUpperCase();
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" }); if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
const result = claimPairing(repo, code); const result = await claimPairing(repo, code);
if (result.status === "pending") { if (result.status === "pending") {
return new Response(JSON.stringify({ status: "pending" }), { return new Response(JSON.stringify({ status: "pending" }), {
status: 202, status: 202,
@ -296,8 +296,8 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token); const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const clusterKey = getClusterKey(repo, secrets); const clusterKey = await getClusterKey(repo, secrets);
const bundle = generateBundle(repo, secrets, kiosk.id, clusterKey); const bundle = await generateBundle(repo, secrets, kiosk.id, clusterKey);
if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" }); if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" });
// Content-hash ETag: kiosk sends If-None-Match on subsequent fetches. // Content-hash ETag: kiosk sends If-None-Match on subsequent fetches.
@ -365,7 +365,7 @@ function registerKioskRoutes(
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() ?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
?? null; ?? null;
repo.touchKiosk(kiosk.id, { await repo.touchKiosk(kiosk.id, {
bundle_version: body?.bundle_version ?? null, bundle_version: body?.bundle_version ?? null,
kiosk_app_version: body?.kiosk_app_version ?? null, kiosk_app_version: body?.kiosk_app_version ?? null,
os_version: body?.os_version ?? null, os_version: body?.os_version ?? null,
@ -391,7 +391,7 @@ function registerKioskRoutes(
// applied. Persist for the admin UI to render. Error string clears on a // applied. Persist for the admin UI to render. Error string clears on a
// successful apply (kiosk omits it). verifyKioskKey returns just {id}; // successful apply (kiosk omits it). verifyKioskKey returns just {id};
// re-read the full row to check the managed_image flag. // re-read the full row to check the managed_image flag.
const kioskFull = repo.getKioskById(kiosk.id); const kioskFull = await repo.getKioskById(kiosk.id);
if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") { if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") {
const patch: Record<string, unknown> = { const patch: Record<string, unknown> = {
managed_config_applied_version: body.managed_config_applied_version, managed_config_applied_version: body.managed_config_applied_version,
@ -400,7 +400,7 @@ function registerKioskRoutes(
if (body.managed_config_error !== undefined) { if (body.managed_config_error !== undefined) {
patch["managed_config_error"] = body.managed_config_error ?? null; patch["managed_config_error"] = body.managed_config_error ?? null;
} }
repo.updateKiosk(kiosk.id, patch as any); await repo.updateKiosk(kiosk.id, patch as any);
} }
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset). // Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
@ -423,7 +423,7 @@ function registerKioskRoutes(
// Sync displays reported by the kiosk // Sync displays reported by the kiosk
if (Array.isArray(body?.displays)) { if (Array.isArray(body?.displays)) {
const existing = repo.listDisplaysForKiosk(kiosk.id); const existing = await repo.listDisplaysForKiosk(kiosk.id);
const seenDisplayIds = new Set<number>(); const seenDisplayIds = new Set<number>();
for (const [position, reported] of body.displays.entries()) { for (const [position, reported] of body.displays.entries()) {
const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0 const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0
@ -445,7 +445,7 @@ function registerKioskRoutes(
|| match.height_px !== reported.height_px || match.height_px !== reported.height_px
|| (powerState != null && match.actual_power_state !== powerState) || (powerState != null && match.actual_power_state !== powerState)
) { ) {
repo.updateDisplay(match.id, { await repo.updateDisplay(match.id, {
name: reported.name, name: reported.name,
index: reportedIndex, index: reportedIndex,
width_px: reported.width_px, width_px: reported.width_px,
@ -458,7 +458,7 @@ function registerKioskRoutes(
} }
} else { } else {
// New display — create it // New display — create it
const created = repo.createDisplayForKiosk(kiosk.id, { const created = await repo.createDisplayForKiosk(kiosk.id, {
name: reported.name, name: reported.name,
index: reportedIndex, index: reportedIndex,
width_px: reported.width_px, width_px: reported.width_px,
@ -470,7 +470,7 @@ function registerKioskRoutes(
? "unknown" ? "unknown"
: null; : null;
if (powerState != null) { if (powerState != null) {
repo.updateDisplay(created.id, { await repo.updateDisplay(created.id, {
actual_power_state: powerState, actual_power_state: powerState,
actual_power_state_at: new Date().toISOString(), actual_power_state_at: new Date().toISOString(),
} as any); } as any);
@ -481,14 +481,14 @@ function registerKioskRoutes(
for (const display of existing) { for (const display of existing) {
if (seenDisplayIds.has(display.id) || !display.is_enabled) continue; if (seenDisplayIds.has(display.id) || !display.is_enabled) continue;
if (!display.name.endsWith(" HDMI-0")) continue; if (!display.name.endsWith(" HDMI-0")) continue;
if (repo.listLayoutsForDisplay(display.id).length > 0) continue; if ((await repo.listLayoutsForDisplay(display.id)).length > 0) continue;
repo.updateDisplay(display.id, { is_enabled: false } as any); await repo.updateDisplay(display.id, { is_enabled: false } as any);
} }
} }
// Re-read kiosk so we see the freshly-persisted applied_version above when // Re-read kiosk so we see the freshly-persisted applied_version above when
// computing whether the server still has a newer config to deliver. // computing whether the server still has a newer config to deliver.
const fresh = repo.getKioskById(kiosk.id); const fresh = await repo.getKioskById(kiosk.id);
let pendingConfig: { version: number; config: unknown } | undefined; let pendingConfig: { version: number; config: unknown } | undefined;
if ( if (
fresh?.managed_image fresh?.managed_image
@ -552,7 +552,7 @@ function registerKioskRoutes(
} }
} }
const eventId = repo.insertEvent({ const eventId = await repo.insertEvent({
source_kiosk_id: kiosk.id, source_kiosk_id: kiosk.id,
source_camera_id: body.camera_id ?? null, source_camera_id: body.camera_id ?? null,
source_type: (body.source_type as any) ?? "system", source_type: (body.source_type as any) ?? "system",
@ -569,7 +569,7 @@ function registerKioskRoutes(
const layoutId = Number(body.payload?.["layout_id"]); const layoutId = Number(body.payload?.["layout_id"]);
if (Number.isInteger(displayId) && Number.isInteger(layoutId)) { if (Number.isInteger(displayId) && Number.isInteger(layoutId)) {
try { try {
repo.updateDisplay(displayId, { active_layout_id: layoutId } as any); await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
} catch { } catch {
// Display might not exist; layout.changed is best-effort telemetry. // Display might not exist; layout.changed is best-effort telemetry.
} }
@ -588,7 +588,7 @@ function registerKioskRoutes(
"display.power.changed", "display.power.changed",
"camera.changed", "camera.changed",
]); ]);
const markForwarded = () => repo.markEventForwarded(eventId); const markForwarded = () => { repo.markEventForwarded(eventId); };
if (flatTopics.has(body.topic)) { if (flatTopics.has(body.topic)) {
const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}), source: "kiosk" }; const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}), source: "kiosk" };
nodered.forward(body.topic, out, markForwarded); nodered.forward(body.topic, out, markForwarded);
@ -641,7 +641,7 @@ function registerKioskRoutes(
logged_at: e.logged_at, logged_at: e.logged_at,
})); }));
const count = repo.insertKioskLogs(kiosk.id, entries); const count = await repo.insertKioskLogs(kiosk.id, entries);
return { ok: true, count }; return { ok: true, count };
}); });
@ -664,7 +664,7 @@ function registerKioskRoutes(
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
const verified = await auth.verifyKioskKey(token); const verified = await auth.verifyKioskKey(token);
if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const kiosk = repo.getKioskById(verified.id); const kiosk = await repo.getKioskById(verified.id);
if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });
const url = new URL(event.req.url); const url = new URL(event.req.url);
@ -677,15 +677,15 @@ function registerKioskRoutes(
let release = null; let release = null;
// Explicit per-kiosk pin wins over all rollout / channel selection. // Explicit per-kiosk pin wins over all rollout / channel selection.
if (kiosk.firmware_target_version) { if (kiosk.firmware_target_version) {
release = repo.getFirmwareReleaseByVersionArch(kiosk.firmware_target_version, arch); release = await repo.getFirmwareReleaseByVersionArch(kiosk.firmware_target_version, arch);
if (release?.yanked_at) release = null; if (release?.yanked_at) release = null;
} }
// Active rollouts: most-recent matching, with bucket eligibility. // Active rollouts: most-recent matching, with bucket eligibility.
if (!release) { if (!release) {
const rollouts = repo.listActiveRolloutsForKiosk(kiosk.id); const rollouts = await repo.listActiveRolloutsForKiosk(kiosk.id);
for (const rollout of rollouts) { for (const rollout of rollouts) {
if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue; if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue;
const r = repo.getFirmwareRelease(rollout.release_id); const r = await repo.getFirmwareRelease(rollout.release_id);
if (!r || r.yanked_at) continue; if (!r || r.yanked_at) continue;
if (r.arch !== arch) continue; if (r.arch !== arch) continue;
release = r; release = r;
@ -695,7 +695,7 @@ function registerKioskRoutes(
// Channel-latest fallback. // Channel-latest fallback.
if (!release) { if (!release) {
const channel = (kiosk.firmware_channel ?? "stable") as FirmwareChannel; const channel = (kiosk.firmware_channel ?? "stable") as FirmwareChannel;
release = repo.getLatestFirmwareRelease(channel, arch); release = await repo.getLatestFirmwareRelease(channel, arch);
} }
if (!release || release.version === currentVersion) { if (!release || release.version === currentVersion) {
@ -732,7 +732,7 @@ function registerKioskRoutes(
?? new URL(event.req.url).pathname.split("/").pop(); ?? new URL(event.req.url).pathname.split("/").pop();
if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" }); if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" });
const release = repo.getFirmwareRelease(id); const release = await repo.getFirmwareRelease(id);
if (!release || release.yanked_at) { if (!release || release.yanked_at) {
throw createError({ statusCode: 404, statusMessage: "release not found" }); throw createError({ statusCode: 404, statusMessage: "release not found" });
} }
@ -765,8 +765,8 @@ function registerKioskRoutes(
if (!body?.version) { if (!body?.version) {
throw createError({ statusCode: 400, statusMessage: "version required" }); throw createError({ statusCode: 400, statusMessage: "version required" });
} }
repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null); await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
repo.insertEvent({ await repo.insertEvent({
source_kiosk_id: kiosk.id, source_kiosk_id: kiosk.id,
source_camera_id: null, source_camera_id: null,
source_type: "system", source_type: "system",
@ -792,7 +792,7 @@ function registerKioskRoutes(
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
const verified = await auth.verifyKioskKey(token); const verified = await auth.verifyKioskKey(token);
if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const kiosk = repo.getKioskById(verified.id); const kiosk = await repo.getKioskById(verified.id);
if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });
const url = new URL(event.req.url); const url = new URL(event.req.url);
@ -804,14 +804,14 @@ function registerKioskRoutes(
let release = null; let release = null;
if (kiosk.os_update_target_version) { if (kiosk.os_update_target_version) {
release = repo.getOsUpdateReleaseByVersionCompatibility(kiosk.os_update_target_version, compatibility); release = await repo.getOsUpdateReleaseByVersionCompatibility(kiosk.os_update_target_version, compatibility);
if (release?.yanked_at) release = null; if (release?.yanked_at) release = null;
} }
if (!release) { if (!release) {
const rollouts = repo.listActiveOsUpdateRolloutsForKiosk(kiosk.id); const rollouts = await repo.listActiveOsUpdateRolloutsForKiosk(kiosk.id);
for (const rollout of rollouts) { for (const rollout of rollouts) {
if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue; if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue;
const r = repo.getOsUpdateRelease(rollout.release_id); const r = await repo.getOsUpdateRelease(rollout.release_id);
if (!r || r.yanked_at) continue; if (!r || r.yanked_at) continue;
if (r.compatibility !== compatibility) continue; if (r.compatibility !== compatibility) continue;
release = r; release = r;
@ -820,7 +820,7 @@ function registerKioskRoutes(
} }
if (!release) { if (!release) {
const channel = (kiosk.os_update_channel ?? "stable") as FirmwareChannel; const channel = (kiosk.os_update_channel ?? "stable") as FirmwareChannel;
release = repo.getLatestOsUpdateRelease(channel, compatibility); release = await repo.getLatestOsUpdateRelease(channel, compatibility);
} }
if (!release || release.version === currentVersion) { if (!release || release.version === currentVersion) {
@ -852,7 +852,7 @@ function registerKioskRoutes(
?? new URL(event.req.url).pathname.split("/").pop(); ?? new URL(event.req.url).pathname.split("/").pop();
if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" }); if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" });
const release = repo.getOsUpdateRelease(id); const release = await repo.getOsUpdateRelease(id);
if (!release || release.yanked_at) { if (!release || release.yanked_at) {
throw createError({ statusCode: 404, statusMessage: "release not found" }); throw createError({ statusCode: 404, statusMessage: "release not found" });
} }
@ -908,8 +908,8 @@ function registerKioskRoutes(
if (!body?.version) { if (!body?.version) {
throw createError({ statusCode: 400, statusMessage: "version required" }); throw createError({ statusCode: 400, statusMessage: "version required" });
} }
repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null); await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
repo.insertEvent({ await repo.insertEvent({
source_kiosk_id: kiosk.id, source_kiosk_id: kiosk.id,
source_camera_id: null, source_camera_id: null,
source_type: "system", source_type: "system",

View file

@ -265,7 +265,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
if (!authed && cookieHeader) { if (!authed && cookieHeader) {
const cookieVal = parseCookieValue(cookieHeader, cookieName); const cookieVal = parseCookieValue(cookieHeader, cookieName);
if (cookieVal) { if (cookieVal) {
const result = auth.resolveSession(cookieVal); const result = await auth.resolveSession(cookieVal);
if (result) authed = true; if (result) authed = true;
} }
} }
@ -317,7 +317,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
socket.destroy(); socket.destroy();
return; return;
} }
const kioskData = repo.getKioskById(kiosk.id); const kioskData = await repo.getKioskById(kiosk.id);
if (!kioskData) { if (!kioskData) {
socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
socket.destroy(); socket.destroy();

View file

@ -162,7 +162,13 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
obs.log.info("schema up to date (version {v})", { v: currentVersion }); obs.log.info("schema up to date (version {v})", { v: currentVersion });
} }
this._repo = new Repository(this.db, async (table, op, id) => { // Wrap the already-configured DatabaseSync in a SqliteAdapter for the
// Repository's async DbAdapter interface. Migrations already ran on
// this.db above — SqliteAdapter just wraps it for query access.
const { SqliteAdapter } = await import("./sqlite-adapter.js");
const adapter = SqliteAdapter.fromExisting(this.db);
this._repo = new Repository(adapter, async (table, op, id) => {
// Best-effort broadcast — never let a failed event-bus call fail a write. // Best-effort broadcast — never let a failed event-bus call fail a write.
try { try {
await this.events.emitBroadcast("store.changed", obs, { table, op, id }); await this.events.emitBroadcast("store.changed", obs, { table, op, id });
@ -181,12 +187,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
obs.log.info("store ready"); obs.log.info("store ready");
} }
private runPurge(obs: Observable): void { private async runPurge(obs: Observable): Promise<void> {
if (!this._repo) return; if (!this._repo) return;
const r = this._repo; const r = this._repo;
const kl = r.purgeKioskLogs(14); const kl = await r.purgeKioskLogs(14);
const el = r.purgeEventLog(30, 100_000); const el = await r.purgeEventLog(30, 100_000);
const al = r.purgeAuditLog(90); const al = await r.purgeAuditLog(90);
if (kl + el + al > 0) { if (kl + el + al > 0) {
obs.log.info("purge: {kl} kiosk_logs, {el} event_log, {al} audit_log", { kl, el, al }); obs.log.info("purge: {kl} kiosk_logs, {el} event_log, {al} audit_log", { kl, el, al });
} }

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,15 @@ export class SqliteAdapter implements DbAdapter {
this.db.exec("PRAGMA synchronous = NORMAL"); this.db.exec("PRAGMA synchronous = NORMAL");
} }
/** Wrap an already-opened DatabaseSync (e.g. after migrations ran). */
static fromExisting(db: DatabaseSync): SqliteAdapter {
const adapter = Object.create(SqliteAdapter.prototype) as SqliteAdapter;
(adapter as any).db = db;
(adapter as any).stmts = new Map();
(adapter as any).txDepth = 0;
return adapter;
}
private prep(sql: string): StatementSync { private prep(sql: string): StatementSync {
let s = this.stmts.get(sql); let s = this.stmts.get(sql);
if (!s) { if (!s) {

View file

@ -30,12 +30,12 @@ export interface AuditInput {
actor_label?: string | null; actor_label?: string | null;
} }
export function audit( export async function audit(
repo: Repository, repo: Repository,
event: AuditCtx | null, event: AuditCtx | null,
action: string, action: string,
input: AuditInput = {}, input: AuditInput = {},
): void { ): Promise<void> {
try { try {
const ctx = event?.context; const ctx = event?.context;
let actor_type: AuditActorType = input.actor_type ?? "system"; let actor_type: AuditActorType = input.actor_type ?? "system";
@ -58,7 +58,7 @@ export function audit(
?? headers?.get("x-forwarded-for")?.split(",")[0]?.trim() ?? headers?.get("x-forwarded-for")?.split(",")[0]?.trim()
?? null; ?? null;
repo.insertAudit({ await repo.insertAudit({
actor_type, actor_type,
actor_id, actor_id,
actor_label, actor_label,

View file

@ -44,8 +44,8 @@ export interface AuthApi {
ipAddress: string | null; ipAddress: string | null;
totpPending: boolean; totpPending: boolean;
}): Promise<{ session: Session; cookieValue: string }>; }): Promise<{ session: Session; cookieValue: string }>;
resolveSession(cookieValue: string): { session: Session; user: User } | null; resolveSession(cookieValue: string): Promise<{ session: Session; user: User } | null>;
revokeSession(sid: string): void; revokeSession(sid: string): Promise<void>;
createApiKey(input: { createApiKey(input: {
name: string; name: string;
scopes: ApiKeyScope[]; scopes: ApiKeyScope[];
@ -203,7 +203,7 @@ export function createAuth(
const expiresAt = new Date( const expiresAt = new Date(
Date.now() + config.sessionMaxSeconds * 1000, Date.now() + config.sessionMaxSeconds * 1000,
).toISOString(); ).toISOString();
const session = repo.createSession({ const session = await repo.createSession({
id, id,
user_id: input.user.id, user_id: input.user.id,
csrf_token: csrfToken, csrf_token: csrfToken,
@ -215,29 +215,29 @@ export function createAuth(
return { session, cookieValue: signCookie(id) }; return { session, cookieValue: signCookie(id) };
} }
function resolveSession( async function resolveSession(
cookieValue: string, cookieValue: string,
): { session: Session; user: User } | null { ): Promise<{ session: Session; user: User } | null> {
const sid = unsignCookie(cookieValue); const sid = unsignCookie(cookieValue);
if (!sid) return null; if (!sid) return null;
const session = repo.getSessionById(sid); const session = await repo.getSessionById(sid);
if (!session) return null; if (!session) return null;
if (session.revoked_at) return null; if (session.revoked_at) return null;
const now = new Date(); const now = new Date();
if (new Date(session.expires_at) <= now) return null; if (new Date(session.expires_at) <= now) return null;
const idleMs = config.sessionIdleSeconds * 1000; const idleMs = config.sessionIdleSeconds * 1000;
if (now.getTime() - new Date(session.last_seen_at).getTime() > idleMs) { if (now.getTime() - new Date(session.last_seen_at).getTime() > idleMs) {
repo.revokeSession(sid); await repo.revokeSession(sid);
return null; return null;
} }
const user = repo.getUserById(session.user_id); const user = await repo.getUserById(session.user_id);
if (!user || !user.is_active) return null; if (!user || !user.is_active) return null;
repo.touchSession(sid, now.toISOString()); await repo.touchSession(sid, now.toISOString());
return { session, user }; return { session, user };
} }
function revokeSession(sid: string): void { async function revokeSession(sid: string): Promise<void> {
repo.revokeSession(sid); await repo.revokeSession(sid);
} }
// ---- API keys ------------------------------------------------------------- // ---- API keys -------------------------------------------------------------
@ -250,7 +250,7 @@ export function createAuth(
const plaintext = `bf-${randomBytes(24).toString("base64url")}`; const plaintext = `bf-${randomBytes(24).toString("base64url")}`;
const keyHash = await hashPassword(plaintext); const keyHash = await hashPassword(plaintext);
const keyPrefix = plaintext.slice(0, 8); const keyPrefix = plaintext.slice(0, 8);
const apiKey = repo.createApiKey({ const apiKey = await repo.createApiKey({
name: input.name, name: input.name,
key_hash: keyHash, key_hash: keyHash,
key_prefix: keyPrefix, key_prefix: keyPrefix,
@ -262,12 +262,12 @@ export function createAuth(
async function verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null> { async function verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null> {
const prefix = plaintext.slice(0, 8); const prefix = plaintext.slice(0, 8);
const candidates = repo.listApiKeysByPrefix(prefix); const candidates = await repo.listApiKeysByPrefix(prefix);
for (const cand of candidates) { for (const cand of candidates) {
if (cand.revoked_at) continue; if (cand.revoked_at) continue;
if (cand.expires_at && new Date(cand.expires_at) <= new Date()) continue; if (cand.expires_at && new Date(cand.expires_at) <= new Date()) continue;
if (await verifyPassword(plaintext, cand.key_hash)) { if (await verifyPassword(plaintext, cand.key_hash)) {
repo.touchApiKey(cand.id, ip); await repo.touchApiKey(cand.id, ip);
return cand; return cand;
} }
} }
@ -277,7 +277,7 @@ export function createAuth(
async function verifyKioskKey(plaintext: string): Promise<{ id: number } | null> { async function verifyKioskKey(plaintext: string): Promise<{ id: number } | null> {
if (plaintext.length < 8) return null; if (plaintext.length < 8) return null;
const prefix = plaintext.slice(0, 8); const prefix = plaintext.slice(0, 8);
const candidates = repo.listKiosksByKeyPrefix(prefix); const candidates = await repo.listKiosksByKeyPrefix(prefix);
for (const cand of candidates) { for (const cand of candidates) {
if (await verifyPassword(plaintext, cand.key_hash)) { if (await verifyPassword(plaintext, cand.key_hash)) {
return { id: cand.id }; return { id: cand.id };

View file

@ -106,13 +106,13 @@ export interface KioskBundle {
version: string; version: string;
} }
export function generateBundle( export async function generateBundle(
repo: Repository, repo: Repository,
secrets: SecretsApi, secrets: SecretsApi,
kioskId: number, kioskId: number,
clusterKey: string | undefined, clusterKey: string | undefined,
): KioskBundle | null { ): Promise<KioskBundle | null> {
const kiosk = repo.getKioskById(kioskId); const kiosk = await repo.getKioskById(kioskId);
if (!kiosk) return null; if (!kiosk) return null;
// Per-kiosk encryption key (preferred) — decrypt from server storage. // Per-kiosk encryption key (preferred) — decrypt from server storage.
@ -126,11 +126,11 @@ export function generateBundle(
} }
// Find all displays for this kiosk (displays now point to kiosks via kiosk_id) // Find all displays for this kiosk (displays now point to kiosks via kiosk_id)
const kioskDisplays = repo.listDisplaysForKiosk(kioskId); const kioskDisplays = await repo.listDisplaysForKiosk(kioskId);
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet // Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
const allDisplays = kioskDisplays.length > 0 const allDisplays = kioskDisplays.length > 0
? kioskDisplays ? kioskDisplays
: (kiosk.display_id ? [repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []); : (kiosk.display_id ? [await repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []);
// Admin can disable a display — kiosk must never open a window on it. // Admin can disable a display — kiosk must never open a window on it.
const displays = allDisplays.filter((d) => d.is_enabled); const displays = allDisplays.filter((d) => d.is_enabled);
@ -139,14 +139,15 @@ export function generateBundle(
// Collect camera IDs across ALL displays' layouts (de-duped). // Collect camera IDs across ALL displays' layouts (de-duped).
const allLayoutIds = new Set<number>(); const allLayoutIds = new Set<number>();
for (const d of displays) { for (const d of displays) {
for (const l of repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id); for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
} }
const cameras = repo.camerasForLayoutIds([...allLayoutIds]); const cameras = await repo.camerasForLayoutIds([...allLayoutIds]);
function buildLayouts(displayId: number, defaultLayoutId: number | null): BundleLayout[] { async function buildLayouts(displayId: number, defaultLayoutId: number | null): Promise<BundleLayout[]> {
const layouts = repo.layoutsForDisplayId(displayId); const layouts = await repo.layoutsForDisplayId(displayId);
return layouts.map((l) => { const result: BundleLayout[] = [];
const cells = repo.layoutCells(l.id); for (const l of layouts) {
const cells = await repo.layoutCells(l.id);
let gridCols = 1; let gridCols = 1;
let gridRows = 1; let gridRows = 1;
for (const c of cells) { for (const c of cells) {
@ -155,17 +156,8 @@ export function generateBundle(
if (right > gridCols) gridCols = right; if (right > gridCols) gridCols = right;
if (bottom > gridRows) gridRows = bottom; if (bottom > gridRows) gridRows = bottom;
} }
return { const bundleCells: BundleCell[] = [];
id: l.id, for (const c of cells) {
name: l.name,
grid_cols: gridCols,
grid_rows: gridRows,
priority: l.priority,
cooling_timeout_seconds: l.cooling_timeout_seconds,
preload_camera_ids: l.preload_camera_ids,
resets_idle_timer: l.resets_idle_timer,
is_default: defaultLayoutId === l.id,
cells: cells.map((c) => {
// If the cell has an entity, prefer its current content so admin // If the cell has an entity, prefer its current content so admin
// edits to the entity propagate without forcing a cell-touch. The // edits to the entity propagate without forcing a cell-touch. The
// bundle still ships the legacy camera_id/web_url/html_content shape // bundle still ships the legacy camera_id/web_url/html_content shape
@ -175,7 +167,7 @@ export function generateBundle(
let webUrl = c.web_url; let webUrl = c.web_url;
let htmlContent = c.html_content; let htmlContent = c.html_content;
if (c.entity_id != null) { if (c.entity_id != null) {
const ent = repo.getEntityById(c.entity_id); const ent = await repo.getEntityById(c.entity_id);
if (ent) { if (ent) {
// Dashboard entities are surfaced to the kiosk as `web` cells // Dashboard entities are surfaced to the kiosk as `web` cells
// pointing at /dash/<dashboard_id> — kiosk WebKit handles them // pointing at /dash/<dashboard_id> — kiosk WebKit handles them
@ -189,7 +181,7 @@ export function generateBundle(
htmlContent = ent.type === "html" ? ent.html_content : null; htmlContent = ent.type === "html" ? ent.html_content : null;
} }
} }
return { bundleCells.push({
row: c.row, row: c.row,
col: c.col, col: c.col,
row_span: c.row_span, row_span: c.row_span,
@ -201,13 +193,27 @@ export function generateBundle(
html_content: htmlContent, html_content: htmlContent,
cooling_timeout_seconds: c.cooling_timeout_seconds, cooling_timeout_seconds: c.cooling_timeout_seconds,
fit: c.fit, fit: c.fit,
};
}),
};
}); });
} }
result.push({
id: l.id,
name: l.name,
grid_cols: gridCols,
grid_rows: gridRows,
priority: l.priority,
cooling_timeout_seconds: l.cooling_timeout_seconds,
preload_camera_ids: l.preload_camera_ids,
resets_idle_timer: l.resets_idle_timer,
is_default: defaultLayoutId === l.id,
cells: bundleCells,
});
}
return result;
}
const bundleDisplays: BundleDisplayWithLayouts[] = displays.map((display) => ({ const bundleDisplays: BundleDisplayWithLayouts[] = [];
for (const display of displays) {
bundleDisplays.push({
id: display.id, id: display.id,
name: display.name, name: display.name,
width_px: display.width_px, width_px: display.width_px,
@ -215,11 +221,13 @@ export function generateBundle(
idle_timeout_seconds: display.idle_timeout_seconds, idle_timeout_seconds: display.idle_timeout_seconds,
sleep_timeout_seconds: display.sleep_timeout_seconds, sleep_timeout_seconds: display.sleep_timeout_seconds,
default_layout_id: display.default_layout_id, default_layout_id: display.default_layout_id,
layouts: buildLayouts(display.id, display.default_layout_id), layouts: await buildLayouts(display.id, display.default_layout_id),
})); });
}
const bundleCameras: BundleCamera[] = cameras.map((cam) => { const bundleCameras: BundleCamera[] = [];
const streams = repo.listCameraStreams(cam.id); for (const cam of cameras) {
const streams = await repo.listCameraStreams(cam.id);
const effectiveStreams = streams.length > 0 ? streams : ( const effectiveStreams = streams.length > 0 ? streams : (
cam.type === "rtsp" && cam.rtsp_url cam.type === "rtsp" && cam.rtsp_url
? [{ ? [{
@ -243,7 +251,7 @@ export function generateBundle(
if (cam.onvif_password && encryptKey) { if (cam.onvif_password && encryptKey) {
onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, encryptKey); onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, encryptKey);
} }
return { bundleCameras.push({
id: cam.id, id: cam.id,
name: cam.name, name: cam.name,
type: cam.type, type: cam.type,
@ -265,10 +273,10 @@ export function generateBundle(
encoding: s.encoding, encoding: s.encoding,
framerate: s.framerate, framerate: s.framerate,
})), })),
};
}); });
}
const gpioBindings: BundleGpioBinding[] = repo.listGpioBindings(kioskId).map((g) => ({ const gpioBindings: BundleGpioBinding[] = (await repo.listGpioBindings(kioskId)).map((g) => ({
id: g.id, id: g.id,
chip: g.chip, chip: g.chip,
pin: g.pin, pin: g.pin,

View file

@ -31,7 +31,7 @@ export function startCameraHealthChecker(
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
async function checkAll(): Promise<void> { async function checkAll(): Promise<void> {
const cameras = repo.listCameras().filter((c) => c.enabled); const cameras = (await repo.listCameras()).filter((c) => c.enabled);
for (const cam of cameras) { for (const cam of cameras) {
const host = cam.type === "onvif" const host = cam.type === "onvif"
? cam.onvif_host ? cam.onvif_host
@ -48,12 +48,12 @@ export function startCameraHealthChecker(
: false; : false;
if (reachable) { if (reachable) {
repo.updateCamera(cam.id, { last_seen_at: new Date().toISOString() } as any); await repo.updateCamera(cam.id, { last_seen_at: new Date().toISOString() } as any);
} else if (wasOnline) { } else if (wasOnline) {
// Camera just went offline — log event for Node-RED / admin visibility. // Camera just went offline — log event for Node-RED / admin visibility.
log.warn(`camera ${cam.id} (${cam.name}) went offline`); log.warn(`camera ${cam.id} (${cam.name}) went offline`);
try { try {
repo.insertEvent({ await repo.insertEvent({
source_kiosk_id: null, source_kiosk_id: null,
source_camera_id: cam.id, source_camera_id: cam.id,
source_type: "system", source_type: "system",

View file

@ -38,21 +38,21 @@ export interface PairingInitiateResult {
expiresAt: string; expiresAt: string;
} }
export function initiatePairing( export async function initiatePairing(
repo: Repository, repo: Repository,
input: PairingInitiateInput, input: PairingInitiateInput,
): PairingInitiateResult { ): Promise<PairingInitiateResult> {
let code: string; let code: string;
let attempts = 0; let attempts = 0;
do { do {
code = generateCode(); code = generateCode();
attempts++; attempts++;
if (attempts > 20) throw new Error("failed to generate unique pairing code"); if (attempts > 20) throw new Error("failed to generate unique pairing code");
} while (repo.getPairingCode(code) !== null); } while (await repo.getPairingCode(code) !== null);
const expiresAt = new Date(Date.now() + input.codeTtlSeconds * 1000).toISOString(); const expiresAt = new Date(Date.now() + input.codeTtlSeconds * 1000).toISOString();
repo.createPairingCode({ await repo.createPairingCode({
code, code,
kiosk_proposed_name: input.proposedName, kiosk_proposed_name: input.proposedName,
kiosk_hardware_model: input.hardwareModel, kiosk_hardware_model: input.hardwareModel,
@ -73,11 +73,11 @@ export interface PairingClaimResult {
bundleUrl?: string; bundleUrl?: string;
} }
export function claimPairing( export async function claimPairing(
repo: Repository, repo: Repository,
code: string, code: string,
): PairingClaimResult { ): Promise<PairingClaimResult> {
const pc = repo.getPairingCode(code); const pc = await repo.getPairingCode(code);
if (!pc) return { status: "pending" }; if (!pc) return { status: "pending" };
if (new Date(pc.expires_at) < new Date()) return { status: "pending" }; if (new Date(pc.expires_at) < new Date()) return { status: "pending" };
if (!pc.consumed_at) return { status: "pending" }; if (!pc.consumed_at) return { status: "pending" };
@ -87,11 +87,11 @@ export function claimPairing(
if (!kioskKey || !pc.consumed_by_kiosk_id) return { status: "pending" }; if (!kioskKey || !pc.consumed_by_kiosk_id) return { status: "pending" };
const kiosk = repo.getKioskById(pc.consumed_by_kiosk_id); const kiosk = await repo.getKioskById(pc.consumed_by_kiosk_id);
const clusterKey = extras["cluster_key"] as string | undefined; const clusterKey = extras["cluster_key"] as string | undefined;
// Wipe plaintext key from extras after first claim // Wipe plaintext key from extras after first claim
repo.updatePairingCodeExtras(code, { ...extras, kiosk_key_plaintext: undefined, cluster_key: undefined }); await repo.updatePairingCodeExtras(code, { ...extras, kiosk_key_plaintext: undefined, cluster_key: undefined });
return { return {
status: "claimed", status: "claimed",
@ -124,7 +124,7 @@ export async function confirmPairing(
secrets: SecretsApi, secrets: SecretsApi,
input: PairingConfirmInput, input: PairingConfirmInput,
): Promise<{ kioskId: number; kioskName: string }> { ): Promise<{ kioskId: number; kioskName: string }> {
const pc = repo.getPairingCode(input.code); const pc = await repo.getPairingCode(input.code);
if (!pc) throw new Error("pairing code not found"); if (!pc) throw new Error("pairing code not found");
if (pc.consumed_at) throw new Error("pairing code already used"); if (pc.consumed_at) throw new Error("pairing code already used");
if (new Date(pc.expires_at) < new Date()) throw new Error("pairing code expired"); if (new Date(pc.expires_at) < new Date()) throw new Error("pairing code expired");
@ -137,7 +137,7 @@ export async function confirmPairing(
let kioskName: string; let kioskName: string;
if (input.replaceKioskId != null) { if (input.replaceKioskId != null) {
const existing = repo.getKioskById(input.replaceKioskId); const existing = await repo.getKioskById(input.replaceKioskId);
if (!existing) throw new Error("replacement target kiosk not found"); if (!existing) throw new Error("replacement target kiosk not found");
// Sanity-check the incoming device matches the slot it's replacing. // Sanity-check the incoming device matches the slot it's replacing.
@ -166,7 +166,7 @@ export async function confirmPairing(
} }
} }
repo.replaceKioskKey(existing.id, { await repo.replaceKioskKey(existing.id, {
key_hash: kioskKeyHash, key_hash: kioskKeyHash,
key_prefix: kioskKeyPrefix, key_prefix: kioskKeyPrefix,
capabilities: pc.kiosk_capabilities, capabilities: pc.kiosk_capabilities,
@ -176,7 +176,7 @@ export async function confirmPairing(
// capabilities/hw, but the explicit column is updated separately because // capabilities/hw, but the explicit column is updated separately because
// replaceKioskKey doesn't touch it). // replaceKioskKey doesn't touch it).
if (existing.managed_image !== (pc.extras?.["managed_image"] === true)) { if (existing.managed_image !== (pc.extras?.["managed_image"] === true)) {
repo.updateKiosk(existing.id, { managed_image: pc.extras?.["managed_image"] === true } as any); await repo.updateKiosk(existing.id, { managed_image: pc.extras?.["managed_image"] === true } as any);
} }
kioskId = existing.id; kioskId = existing.id;
kioskName = existing.name; kioskName = existing.name;
@ -184,13 +184,13 @@ export async function confirmPairing(
const baseName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`; const baseName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`;
let candidate = baseName; let candidate = baseName;
let suffix = 2; let suffix = 2;
while (repo.getKioskByName(candidate)) { while (await repo.getKioskByName(candidate)) {
candidate = `${baseName}-${suffix}`; candidate = `${baseName}-${suffix}`;
suffix++; suffix++;
if (suffix > 100) throw new Error("could not generate unique kiosk name"); if (suffix > 100) throw new Error("could not generate unique kiosk name");
} }
const kiosk = repo.createKiosk({ const kiosk = await repo.createKiosk({
name: candidate, name: candidate,
key_hash: kioskKeyHash, key_hash: kioskKeyHash,
key_prefix: kioskKeyPrefix, key_prefix: kioskKeyPrefix,
@ -199,7 +199,7 @@ export async function confirmPairing(
managed_image: pc.extras?.["managed_image"] === true, managed_image: pc.extras?.["managed_image"] === true,
}); });
repo.createDisplayForKiosk(kiosk.id, { await repo.createDisplayForKiosk(kiosk.id, {
name: `${candidate} HDMI-0`, name: `${candidate} HDMI-0`,
}); });
@ -207,8 +207,8 @@ export async function confirmPairing(
for (const labelName of input.initialLabels) { for (const labelName of input.initialLabels) {
const trimmed = labelName.trim().toLowerCase(); const trimmed = labelName.trim().toLowerCase();
if (!trimmed) continue; if (!trimmed) continue;
const label = repo.ensureLabel(trimmed); const label = await repo.ensureLabel(trimmed);
repo.attachKioskLabel(kiosk.id, label.id, "consume"); await repo.attachKioskLabel(kiosk.id, label.id, "consume");
} }
} }
@ -221,15 +221,15 @@ export async function confirmPairing(
// kiosk (one-time). Replaces shared cluster_key for bundle encryption. // kiosk (one-time). Replaces shared cluster_key for bundle encryption.
const kioskEncryptKey = randomBytes(32).toString("base64url"); const kioskEncryptKey = randomBytes(32).toString("base64url");
const kioskEncryptKeyEncrypted = secrets.encryptString(kioskEncryptKey, "kiosk-encrypt"); const kioskEncryptKeyEncrypted = secrets.encryptString(kioskEncryptKey, "kiosk-encrypt");
repo.updateKiosk(kioskId, { encrypt_key_encrypted: kioskEncryptKeyEncrypted } as any); await repo.updateKiosk(kioskId, { encrypt_key_encrypted: kioskEncryptKeyEncrypted } as any);
// Still deliver cluster_key for backward compat (old kiosk binaries // Still deliver cluster_key for backward compat (old kiosk binaries
// that don't understand encrypt_key yet). Remove once all kiosks are // that don't understand encrypt_key yet). Remove once all kiosks are
// on the new binary. // on the new binary.
const clusterKeyEncrypted = repo.getSetupExtra("cluster_key_encrypted") as string | undefined; const clusterKeyEncrypted = await repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined; const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined;
repo.markPairingCodeClaimed(input.code, kioskId, { await repo.markPairingCodeClaimed(input.code, kioskId, {
kiosk_key_plaintext: kioskKeyPlaintext, kiosk_key_plaintext: kioskKeyPlaintext,
cluster_key: clusterKey, cluster_key: clusterKey,
encrypt_key: kioskEncryptKey, encrypt_key: kioskEncryptKey,