From ed2050cfd88f1a070d0638ca6aa3d43597ba4d0d Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sat, 23 May 2026 02:07:44 +0200 Subject: [PATCH] feat(db): full async Repository conversion for PostgreSQL support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - 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. --- .../src/plugins/service-admin-http/index.ts | 12 +- .../plugins/service-admin-http/middleware.ts | 4 +- .../service-admin-http/routes-admin.ts | 635 +++---- .../plugins/service-admin-http/routes-auth.ts | 36 +- .../service-admin-http/routes-firmware.ts | 42 +- .../service-admin-http/routes-os-updates.ts | 38 +- .../service-admin-http/routes-setup.ts | 14 +- server/src/plugins/service-api-http/index.ts | 72 +- .../plugins/service-coordinator-ws/index.ts | 4 +- server/src/plugins/service-store/index.ts | 16 +- .../src/plugins/service-store/repository.ts | 1468 +++++++++-------- .../plugins/service-store/sqlite-adapter.ts | 9 + server/src/shared/audit.ts | 6 +- server/src/shared/auth.ts | 30 +- server/src/shared/bundle.ts | 144 +- server/src/shared/camera-health.ts | 6 +- server/src/shared/pairing.ts | 42 +- 17 files changed, 1329 insertions(+), 1249 deletions(-) diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 1561b1b..f3e78b6 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -170,7 +170,7 @@ export class Plugin extends BSBService, typeof Event // Auth-check endpoint for Angie auth_request subrequest. // 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"); if (authz?.startsWith("Bearer ")) { 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, typeof Event const cookie = event.req.headers.get("cookie") ?? ""; const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`)); 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) { return new Response(null, { status: 401 }); } @@ -199,9 +199,9 @@ export class Plugin extends BSBService, typeof Event }); app.get("/healthz", () => ({ status: "ok" })); - app.get("/readyz", () => { + app.get("/readyz", async () => { try { - deps.repo.isSetupComplete(); + await deps.repo.isSetupComplete(); return { status: "ready" }; } catch { return { status: "not_ready" }; @@ -290,7 +290,7 @@ export class Plugin extends BSBService, typeof Event auth: AuthApi, ): Promise { const KEY = "nodered_api_key"; - const stored = repo.getSetupExtra(KEY); + const stored = await repo.getSetupExtra(KEY); if (typeof stored === "string" && stored.length > 0) { return secrets.decryptString(stored, "nodered_api_key"); } @@ -299,7 +299,7 @@ export class Plugin extends BSBService, typeof Event scopes: ["admin"], expiresAt: null, }); - repo.setSetupExtra(KEY, secrets.encryptString(plaintext, "nodered_api_key")); + await repo.setSetupExtra(KEY, secrets.encryptString(plaintext, "nodered_api_key")); return plaintext; } diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index a79da34..8502116 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -61,7 +61,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { return; } - if (!deps.repo.isSetupComplete()) { + if (!(await deps.repo.isSetupComplete())) { if (!path.startsWith("/auth/")) { return new Response(null, { status: 302, headers: { location: "/setup" } }); } @@ -102,7 +102,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { if (!cookie) { 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) { return new Response(null, { status: 302, headers: { location: "/auth/login" } }); } diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 5cc8625..88e2eda 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -101,11 +101,11 @@ function sanitizeRtspUrl(raw: string): string { return `${scheme}${user}:${pass}@${rest}`; } -function uniqueCameraName(deps: AdminDeps, rawName: string): string { +async function uniqueCameraName(deps: AdminDeps, rawName: string): Promise { let name = rawName; - if (deps.repo.getCameraByName(name)) { + if (await deps.repo.getCameraByName(name)) { let i = 2; - while (deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1; + while (await deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1; name = `${rawName} (${String(i)})`; } return name; @@ -171,7 +171,7 @@ function parseDiscoveredStreams(raw: string): DiscoverAddStream[] { } } -function importDiscoveredCamera( +async function importDiscoveredCamera( deps: AdminDeps, rawName: string, onvifHost: string, @@ -179,13 +179,13 @@ function importDiscoveredCamera( username: string, password: string, streams: DiscoverAddStream[], -): number | null { +): Promise { if (streams.length === 0) return null; const main = streams.find((s) => s.role === "main") ?? streams[0]!; const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password); - const name = uniqueCameraName(deps, rawName || "ONVIF camera"); + const name = await uniqueCameraName(deps, rawName || "ONVIF camera"); - const cam = deps.repo.createCamera({ + const cam = await deps.repo.createCamera({ name, type: "onvif", rtsp_url: mainRtspUrl, @@ -198,7 +198,7 @@ function importDiscoveredCamera( const width = stream.width == null ? null : Number(stream.width); const height = stream.height == null ? null : Number(stream.height); const framerate = stream.framerate == null ? null : Number(stream.framerate); - deps.repo.createCameraStream({ + await deps.repo.createCameraStream({ camera_id: cam.id, role: stream.role === "main" || stream.role === "sub" ? stream.role : "other", name: stream.profile_name || stream.role, @@ -238,13 +238,13 @@ interface CellPos { col_span: number; } -function resolveOverlaps( +async function resolveOverlaps( deps: AdminDeps, layoutId: number, anchorId: number, pushAxis: "row" | "col", -): void { - const all = deps.repo.layoutCells(layoutId); +): Promise { + const all = await deps.repo.layoutCells(layoutId); const positions = new Map(); for (const c of all) { positions.set(c.id, { id: c.id, row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span }); @@ -294,52 +294,52 @@ function resolveOverlaps( for (const pos of positions.values()) { const orig = all.find((c) => c.id === pos.id)!; if (orig.row !== pos.row || orig.col !== pos.col) { - deps.repo.updateLayoutCell(pos.id, { row: pos.row, col: pos.col }); + await deps.repo.updateLayoutCell(pos.id, { row: pos.row, col: pos.col }); } } } -function shiftCellsForExpansion( +async function shiftCellsForExpansion( deps: AdminDeps, layoutId: number, cellId: number, direction: "left" | "right" | "above" | "bottom", -): void { - const cell = deps.repo.getLayoutCellById(cellId); +): Promise { + const cell = await deps.repo.getLayoutCellById(cellId); if (!cell || cell.layout_id !== layoutId) return; if (direction === "right") { - deps.repo.updateLayoutCell(cell.id, { col_span: cell.col_span + 1 }); - resolveOverlaps(deps, layoutId, cell.id, "col"); + await deps.repo.updateLayoutCell(cell.id, { col_span: cell.col_span + 1 }); + await resolveOverlaps(deps, layoutId, cell.id, "col"); } else if (direction === "bottom") { - deps.repo.updateLayoutCell(cell.id, { row_span: cell.row_span + 1 }); - resolveOverlaps(deps, layoutId, cell.id, "row"); + await deps.repo.updateLayoutCell(cell.id, { row_span: cell.row_span + 1 }); + await resolveOverlaps(deps, layoutId, cell.id, "row"); } else if (direction === "left") { const newCol = Math.max(0, cell.col - 1); - deps.repo.updateLayoutCell(cell.id, { col: newCol, col_span: cell.col_span + 1 }); - resolveOverlaps(deps, layoutId, cell.id, "col"); + await deps.repo.updateLayoutCell(cell.id, { col: newCol, col_span: cell.col_span + 1 }); + await resolveOverlaps(deps, layoutId, cell.id, "col"); } else if (direction === "above") { const newRow = Math.max(0, cell.row - 1); - deps.repo.updateLayoutCell(cell.id, { row: newRow, row_span: cell.row_span + 1 }); - resolveOverlaps(deps, layoutId, cell.id, "row"); + await deps.repo.updateLayoutCell(cell.id, { row: newRow, row_span: cell.row_span + 1 }); + await resolveOverlaps(deps, layoutId, cell.id, "row"); } } -function shiftCellsForInsertion( +async function shiftCellsForInsertion( deps: AdminDeps, layoutId: number, axis: "row" | "col", fromIndex: number, crossStart: number, crossEnd: number, -): void { - for (const c of deps.repo.layoutCells(layoutId)) { +): Promise { + for (const c of await deps.repo.layoutCells(layoutId)) { if (axis === "col") { if (c.col >= fromIndex && rangesOverlap(c.row, c.row + c.row_span, crossStart, crossEnd)) { - deps.repo.updateLayoutCell(c.id, { col: c.col + 1 }); + await deps.repo.updateLayoutCell(c.id, { col: c.col + 1 }); } } else if (c.row >= fromIndex && rangesOverlap(c.col, c.col + c.col_span, crossStart, crossEnd)) { - deps.repo.updateLayoutCell(c.id, { row: c.row + 1 }); + await deps.repo.updateLayoutCell(c.id, { row: c.row + 1 }); } } } @@ -347,12 +347,12 @@ function shiftCellsForInsertion( export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Overview ------------------------------------------------------------- - app.get("/admin/", (event) => { + app.get("/admin/", async (event) => { const user = event.context.user!; - const cameras = deps.repo.listCameras(); - const kiosks = deps.repo.listKiosks(); - const layouts = deps.repo.listDisplays(); // for count - const events = deps.repo.recentEvents(10); + const cameras = await deps.repo.listCameras(); + const kiosks = await deps.repo.listKiosks(); + const layouts = await deps.repo.listDisplays(); // for count + const events = await deps.repo.recentEvents(10); const onlineKiosks = kiosks.filter((k) => { if (!k.last_seen_at) return false; return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000; @@ -369,13 +369,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); // Redirect /admin to /admin/ - app.get("/admin", () => { + app.get("/admin", async () => { return new Response(null, { status: 301, headers: { location: "/admin/" } }); }); // ---- Backup / restore ----------------------------------------------------- - app.get("/admin/backup", (event) => { + app.get("/admin/backup", async (event) => { const user = event.context.user!; return htmlPage(BackupPage({ user: user.username })); }); @@ -387,12 +387,12 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { try { res = createBackup(deps.dataDir, pass); } catch (err) { - audit(deps.repo, event as any, "backup.create", { + await audit(deps.repo, event as any, "backup.create", { result: "failed", metadata: { error: (err as Error).message }, }); return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message })); } - audit(deps.repo, event as any, "backup.create", { + await audit(deps.repo, event as any, "backup.create", { metadata: { file_count: res.fileCount, size: res.blob.length }, }); return new Response(new Uint8Array(res.blob), { @@ -415,7 +415,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { try { const buf = Buffer.from(await file.arrayBuffer()); const res = restoreBackup(deps.dataDir, pass, buf); - audit(deps.repo, event as any, "backup.restore", { + await audit(deps.repo, event as any, "backup.restore", { metadata: { file_count: res.fileCount, files: res.files }, }); return htmlPage(BackupPage({ @@ -423,7 +423,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { success: `Restored ${String(res.fileCount)} files: ${res.files.join(", ")}. RESTART THE SERVER NOW for changes to take effect.`, })); } catch (err) { - audit(deps.repo, event as any, "backup.restore", { + await audit(deps.repo, event as any, "backup.restore", { result: "failed", metadata: { error: (err as Error).message }, }); return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message })); @@ -432,12 +432,12 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Audit log ------------------------------------------------------------ - app.get("/admin/audit", (event) => { + app.get("/admin/audit", async (event) => { const user = event.context.user!; const url = new URL(event.req.url); const filterAction = url.searchParams.get("action")?.trim() || undefined; const filterActorType = url.searchParams.get("actor_type")?.trim() || undefined; - const entries = deps.repo.listAudit({ + const entries = await deps.repo.listAudit({ limit: 300, action_prefix: filterAction, actor_type: filterActorType as any || undefined, @@ -447,57 +447,58 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- System Health -------------------------------------------------------- - app.get("/admin/health", (event) => { + app.get("/admin/health", async (event) => { const user = event.context.user!; - const kiosks = deps.repo.listKiosks(); + const kiosks = await deps.repo.listKiosks(); const now = Date.now(); let clusterKey: string | undefined; try { - const enc = deps.repo.getSetupExtra("cluster_key_encrypted") as string | undefined; + const enc = await deps.repo.getSetupExtra("cluster_key_encrypted") as string | undefined; if (enc) clusterKey = deps.secrets.decryptString(enc, "cluster"); } catch { /* ignore */ } - const rows = kiosks.map((k) => { + const rows = []; + for (const k of kiosks) { const online = k.last_seen_at ? now - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000 : false; - const displays = deps.repo.listDisplaysForKiosk(k.id); + const displays = await deps.repo.listDisplaysForKiosk(k.id); let expectedBundleVersion: string | null = null; try { - const b = generateBundle(deps.repo, deps.secrets, k.id, clusterKey); + const b = await generateBundle(deps.repo, deps.secrets, k.id, clusterKey); expectedBundleVersion = b?.version ?? null; } catch { /* ignore */ } const bundleMismatch = expectedBundleVersion != null && k.last_bundle_version != null && k.last_bundle_version !== expectedBundleVersion; - return { + rows.push({ kiosk: k, online, bundleMismatch, expectedBundleVersion, displays, - }; - }); + }); + } return htmlPage(SystemHealthPage({ user: user.username, rows })); }); // ---- Cameras -------------------------------------------------------------- - app.get("/admin/cameras", (event) => { + app.get("/admin/cameras", async (event) => { const user = event.context.user!; - const cameras = deps.repo.listCameras(); + const cameras = await deps.repo.listCameras(); const streamCounts = new Map(); const activeKiosks = new Map(); // camera_id → count of kiosks rendering for (const cam of cameras) { - streamCounts.set(cam.id, deps.repo.listCameraStreams(cam.id).length); - activeKiosks.set(cam.id, deps.repo.listKiosksRenderingCamera(cam.id).length); + streamCounts.set(cam.id, (await deps.repo.listCameraStreams(cam.id)).length); + activeKiosks.set(cam.id, (await deps.repo.listKiosksRenderingCamera(cam.id)).length); } return htmlPage(CamerasPage({ user: user.username, cameras, streamCounts, activeKiosks })); }); - app.get("/admin/cameras/new", (event) => { + app.get("/admin/cameras/new", async (event) => { const user = event.context.user!; return htmlPage(CameraNewPage({ user: user.username })); }); @@ -510,7 +511,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (!name || name.length > 128) { errors.push("Name required (max 128 chars)."); - } else if (deps.repo.getCameraByName(name)) { + } else if (await deps.repo.getCameraByName(name)) { errors.push("Camera name already in use."); } @@ -536,14 +537,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { })); } - const cam = deps.repo.createCamera({ + const cam = await deps.repo.createCamera({ name, type: "rtsp", rtsp_url: rtspUrl ?? null, }); if (rtspUrl) { - deps.repo.createCameraStream({ + await deps.repo.createCameraStream({ camera_id: cam.id, role: "main", name: "Main", @@ -561,11 +562,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Camera ONVIF discovery ------------------------------------------------ - app.get("/admin/cameras/discover", (event) => { + app.get("/admin/cameras/discover", async (event) => { const user = event.context.user!; return htmlPage(CameraDiscoverPage({ user: user.username, - kiosks: deps.repo.listKiosks(), + kiosks: await deps.repo.listKiosks(), })); }); @@ -581,7 +582,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (!host) { return htmlPage(CameraDiscoverPage({ user: user.username, - kiosks: deps.repo.listKiosks(), + kiosks: await deps.repo.listKiosks(), error: "Host required.", values: body, })); @@ -603,7 +604,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } catch (err) { return htmlPage(CameraDiscoverPage({ user: user.username, - kiosks: deps.repo.listKiosks(), + kiosks: await deps.repo.listKiosks(), error: `Discovery failed: ${(err as Error).message}`, values: body, })); @@ -624,7 +625,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const rawName = formValue(body?.[`camera_${idx}_name`]).trim() || "ONVIF camera"; const streams = parseDiscoveredStreams(formValue(body?.[`camera_${idx}_streams_json`])); if (streams.length === 0) continue; - const camId = importDiscoveredCamera(deps, rawName, onvifHost, onvifPort, username, password, streams); + const camId = await importDiscoveredCamera(deps, rawName, onvifHost, onvifPort, username, password, streams); if (camId != null) { deps.nodered.forward("camera.changed", { camera_id: camId, event: "created", source: "server" }); } @@ -634,7 +635,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const rawName = formValue(body?.["name"]).trim() || "ONVIF camera"; const streams = parseDiscoveredStreams(formValue(body?.["streams_json"])); if (streams.length > 0) { - const camId = importDiscoveredCamera(deps, rawName, onvifHost, onvifPort, username, password, streams); + const camId = await importDiscoveredCamera(deps, rawName, onvifHost, onvifPort, username, password, streams); if (camId != null) { deps.nodered.forward("camera.changed", { camera_id: camId, event: "created", source: "server" }); } @@ -652,20 +653,20 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Entities -------------------------------------------------------------- - app.get("/admin/entities", (event) => { + app.get("/admin/entities", async (event) => { const user = event.context.user!; syncDashboardsFromNodered(deps).catch(() => {}); return htmlPage(EntitiesPage({ user: user.username, - entities: deps.repo.listEntities(), + entities: await deps.repo.listEntities(), })); }); - app.get("/admin/entities/new", (event) => { + app.get("/admin/entities/new", async (event) => { const user = event.context.user!; return htmlPage(EntityNewPage({ user: user.username, - cameras: deps.repo.listCameras(), + cameras: await deps.repo.listCameras(), })); }); @@ -679,7 +680,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (!name || name.length > 128) { errors.push("Name required (max 128 chars)."); - } else if (deps.repo.getEntityByName(name)) { + } else if (await deps.repo.getEntityByName(name)) { errors.push("Entity name already in use."); } if (type !== "camera" && type !== "html" && type !== "web") { @@ -702,13 +703,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (errors.length > 0) { return htmlPage(EntityNewPage({ user: user.username, - cameras: deps.repo.listCameras(), + cameras: await deps.repo.listCameras(), error: errors.join(" "), values: body, })); } - deps.repo.createEntity({ + await deps.repo.createEntity({ name, type: type!, description, @@ -720,21 +721,21 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); }); - app.get("/admin/entities/:id", (event) => { + app.get("/admin/entities/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); - const ent = deps.repo.getEntityById(id); + const ent = await deps.repo.getEntityById(id); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); return htmlPage(EntityEditPage({ user: user.username, entity: ent, - cameras: deps.repo.listCameras(), + cameras: await deps.repo.listCameras(), })); }); app.post("/admin/entities/:id", async (event) => { const id = Number(getRouterParam(event, "id")); - const ent = deps.repo.getEntityById(id); + const ent = await deps.repo.getEntityById(id); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); const body = await readBody>(event); const patch: { @@ -754,14 +755,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } else if (ent.type === "web") { patch.web_url = (body?.["web_url"] ?? "").trim() || null; } - deps.repo.updateEntity(id, patch); + await deps.repo.updateEntity(id, patch); notifyKiosks(); return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } }); }); - app.post("/admin/entities/:id/delete", (event) => { + app.post("/admin/entities/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); - deps.repo.deleteEntity(id); + await deps.repo.deleteEntity(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); }); @@ -772,7 +773,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // attempt times out). Used by the EntityEditPage "Test" preview. app.get("/admin/entities/:id/snapshot", async (event) => { const id = Number(getRouterParam(event, "id")); - const ent = deps.repo.getEntityById(id); + const ent = await deps.repo.getEntityById(id); if (!ent || ent.type !== "camera" || ent.camera_id == null) { return new Response("Not a camera entity", { status: 404 }); } @@ -782,7 +783,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Even if the camera isn't on screen right now, the kiosk is on the // same LAN and can open a one-shot RTSP connection for the snapshot. // Only fall through to server-direct when NO kiosk has it at all. - const candidates = deps.repo.listKiosksWithCameraInBundle(cameraId); + const candidates = await deps.repo.listKiosksWithCameraInBundle(cameraId); const STALE_MS = 2 * 60 * 1000; // kiosk silent > 2 min → don't bother const now = Date.now(); for (const k of candidates) { @@ -811,9 +812,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } // 2. Fall back to server-direct RTSP pull (ffmpeg/gst). - const streams = deps.repo.listCameraStreams(cameraId); + const streams = await deps.repo.listCameraStreams(cameraId); const main = streams.find((s) => s.role === "main") ?? streams[0]; - const cam = deps.repo.getCameraById(cameraId); + const cam = await deps.repo.getCameraById(cameraId); const rtsp = main?.rtsp_uri ?? cam?.rtsp_url ?? null; if (!rtsp) return new Response("No RTSP URL", { status: 404 }); @@ -833,10 +834,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Kiosks --------------------------------------------------------------- - app.get("/admin/kiosks", (event) => { + app.get("/admin/kiosks", async (event) => { const user = event.context.user!; - const kiosks = deps.repo.listKiosks(); - const pending = deps.repo.listPendingPairingCodes(); + const kiosks = await deps.repo.listKiosks(); + const pending = await deps.repo.listPendingPairingCodes(); return htmlPage(KiosksPage({ user: user.username, kiosks, pendingCodes: pending })); }); @@ -858,15 +859,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { replaceKioskId, force, }); - audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", { + await audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", { resource_type: "kiosk", resource_id: result.kioskId, metadata: { name: result.kioskName, code, replaced: !!replaceKioskId }, }); } catch (err) { const user = event.context.user!; - const kiosks = deps.repo.listKiosks(); - const pending = deps.repo.listPendingPairingCodes(); + const kiosks = await deps.repo.listKiosks(); + const pending = await deps.repo.listPendingPairingCodes(); return htmlPage(KiosksPage({ user: user.username, kiosks, @@ -880,18 +881,18 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Layouts --------------------------------------------------------------- - app.get("/admin/layouts", (event) => { + app.get("/admin/layouts", async (event) => { const user = event.context.user!; - const layouts = deps.repo.listLayouts(); + const layouts = await deps.repo.listLayouts(); // For each layout, how many displays use it (for the list view). const displayCounts = new Map(); for (const l of layouts) { - displayCounts.set(l.id, deps.repo.listDisplaysForLayout(l.id).length); + displayCounts.set(l.id, (await deps.repo.listDisplaysForLayout(l.id)).length); } return htmlPage(LayoutsPage({ user: user.username, layouts, displayCounts })); }); - app.get("/admin/layouts/new", (event) => { + app.get("/admin/layouts/new", async (event) => { const user = event.context.user!; return htmlPage(LayoutNewPage({ user: user.username })); }); @@ -918,7 +919,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { })); } - const layout = deps.repo.createLayout({ + const layout = await deps.repo.createLayout({ name, description, priority, @@ -928,15 +929,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layout.id}` } }); }); - app.get("/admin/layouts/:id", (event) => { + app.get("/admin/layouts/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); - const layout = deps.repo.getLayoutById(id); + const layout = await deps.repo.getLayoutById(id); if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); - const cells = deps.repo.layoutCells(id); - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); - const displays = deps.repo.listDisplaysForLayout(id); + const cells = await deps.repo.layoutCells(id); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); + const displays = await deps.repo.listDisplaysForLayout(id); return htmlPage(LayoutEditPage({ user: user.username, layout, @@ -952,7 +953,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const body = await readBody>(event); const coolingStr = body?.["cooling_timeout_seconds"] ?? ""; const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10); - deps.repo.updateLayout(id, { + await deps.repo.updateLayout(id, { name: body?.["name"], description: body?.["description"] || null, priority: (body?.["priority"] ?? "normal") as any, @@ -980,12 +981,12 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (afterCellIdRaw && direction) { const afterId = Number(afterCellIdRaw); - const cells = deps.repo.layoutCells(layoutId); + const cells = await deps.repo.layoutCells(layoutId); const ref = cells.find((c) => c.id === afterId); if (!ref) { if (isHtmxRequest(event)) { - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); @@ -993,19 +994,19 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (direction === "right") { row = ref.row; col = ref.col + ref.col_span; - shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1); + await shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1); } else if (direction === "bottom") { row = ref.row + ref.row_span; col = ref.col; - shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1); + await shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1); } else if (direction === "left") { row = ref.row; col = Math.max(0, ref.col - 1); - shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1); + await shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1); } else if (direction === "above") { col = ref.col; row = Math.max(0, ref.row - 1); - shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1); + await shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1); } } else { // Explicit position — accept top-level row/col or nested position. @@ -1019,7 +1020,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } } - deps.repo.createLayoutCell({ + await deps.repo.createLayoutCell({ layout_id: layoutId, row, col, @@ -1030,37 +1031,37 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { notifyKiosks(); if (isHtmxRequest(event)) { - const cells = deps.repo.layoutCells(layoutId); - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); + const cells = await deps.repo.layoutCells(layoutId); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); // GET a single cell in read mode (used by htmx Cancel button in inline edit). - app.get("/admin/layouts/:id/cells/:cellId", (event) => { + app.get("/admin/layouts/:id/cells/:cellId", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); - const cell = deps.repo.getLayoutCellById(cellId); + const cell = await deps.repo.getLayoutCellById(cellId); if (!cell || cell.layout_id !== layoutId) { return new Response("Not Found", { status: 404 }); } - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); return htmlFragment(renderCell(layoutId, cell, entities, cameras, "read")); }); // GET a single cell in edit mode (htmx swap target for cell click). - app.get("/admin/layouts/:id/cells/:cellId/edit", (event) => { + app.get("/admin/layouts/:id/cells/:cellId/edit", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); - const cell = deps.repo.getLayoutCellById(cellId); + const cell = await deps.repo.getLayoutCellById(cellId); if (!cell || cell.layout_id !== layoutId) { return new Response("Not Found", { status: 404 }); } - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); return htmlFragment(renderCell(layoutId, cell, entities, cameras, "edit")); }); @@ -1074,7 +1075,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const entityIdRaw = body?.["entity_id"]; const entityId = entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null; - deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null); + await deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null); // stream_selector + spans + fit are still settable on the cell. const dimsPatch: Record = {}; @@ -1096,20 +1097,20 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } let spansChanged = false; if (Object.keys(dimsPatch).length > 0) { - deps.repo.updateLayoutCell(cellId, dimsPatch as any); + await deps.repo.updateLayoutCell(cellId, dimsPatch as any); if ("col_span" in dimsPatch || "row_span" in dimsPatch) { spansChanged = true; const axis = "col_span" in dimsPatch ? "col" as const : "row" as const; - resolveOverlaps(deps, layoutId, cellId, axis); + await resolveOverlaps(deps, layoutId, cellId, axis); } } notifyKiosks(); if (isHtmxRequest(event)) { if (spansChanged) { - const cells = deps.repo.layoutCells(layoutId); - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); + const cells = await deps.repo.layoutCells(layoutId); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); const body = String(renderGrid(layoutId, cells, entities, cameras)); return new Response(body, { headers: { @@ -1119,10 +1120,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }, }); } - const cell = deps.repo.getLayoutCellById(cellId); + const cell = await deps.repo.getLayoutCellById(cellId); if (!cell) return new Response("", { headers: { "content-type": "text/html; charset=utf-8" } }); - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); return htmlFragment(renderCell(layoutId, cell, entities, cameras, "read")); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); @@ -1137,78 +1138,78 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const delta = Number(body?.["delta"] ?? 0) || 0; const direction = String(body?.["direction"] ?? ""); - const cell = deps.repo.getLayoutCellById(cellId); + const cell = await deps.repo.getLayoutCellById(cellId); if ( cell && cell.layout_id === layoutId && (direction === "left" || direction === "right" || direction === "above" || direction === "bottom") ) { - shiftCellsForExpansion(deps, layoutId, cellId, direction); + await shiftCellsForExpansion(deps, layoutId, cellId, direction); notifyKiosks(); } else if (cell && cell.layout_id === layoutId && (dim === "row_span" || dim === "col_span") && delta !== 0) { const current = dim === "row_span" ? cell.row_span : cell.col_span; const next = Math.max(1, current + delta); if (next !== current) { - deps.repo.updateLayoutCell(cellId, { [dim]: next } as any); - resolveOverlaps(deps, layoutId, cellId, dim === "col_span" ? "col" : "row"); + await deps.repo.updateLayoutCell(cellId, { [dim]: next } as any); + await resolveOverlaps(deps, layoutId, cellId, dim === "col_span" ? "col" : "row"); notifyKiosks(); } } - const cells = deps.repo.layoutCells(layoutId); - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); + const cells = await deps.repo.layoutCells(layoutId); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); if (isHtmxRequest(event)) { return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); - app.post("/admin/layouts/:id/cells/:cellId/delete", (event) => { + app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); - deps.repo.deleteLayoutCell(cellId); + await deps.repo.deleteLayoutCell(cellId); notifyKiosks(); if (isHtmxRequest(event)) { - const cells = deps.repo.layoutCells(layoutId); - const cameras = deps.repo.listCameras(); - const entities = deps.repo.listEntities(); + const cells = await deps.repo.layoutCells(layoutId); + const cameras = await deps.repo.listCameras(); + const entities = await deps.repo.listEntities(); return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); - app.post("/admin/layouts/:id/clone", (event) => { + app.post("/admin/layouts/:id/clone", async (event) => { const id = Number(getRouterParam(event, "id")); - const clone = deps.repo.cloneLayout(id); + const clone = await deps.repo.cloneLayout(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: `/admin/layouts/${clone.id}` } }); }); - app.post("/admin/layouts/:id/delete", (event) => { + app.post("/admin/layouts/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); - deps.repo.deleteLayout(id); + await deps.repo.deleteLayout(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); }); // ---- Displays -------------------------------------------------------------- - app.get("/admin/displays", (event) => { + app.get("/admin/displays", async (event) => { const user = event.context.user!; - const displays = deps.repo.listDisplays(); + const displays = await deps.repo.listDisplays(); return htmlPage(DisplaysPage({ user: user.username, displays })); }); - app.get("/admin/displays/:id", (event) => { + app.get("/admin/displays/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); - const display = deps.repo.getDisplayById(id); + const display = await deps.repo.getDisplayById(id); if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } }); - const attachedLayouts = deps.repo.listLayoutsForDisplay(id); + const attachedLayouts = await deps.repo.listLayoutsForDisplay(id); const attachedIds = new Set(attachedLayouts.map((l) => l.id)); - const availableLayouts = deps.repo.listLayouts().filter((l) => !attachedIds.has(l.id)); - const kiosk = display.kiosk_id ? deps.repo.getKioskById(display.kiosk_id) : null; + const availableLayouts = (await deps.repo.listLayouts()).filter((l) => !attachedIds.has(l.id)); + const kiosk = display.kiosk_id ? await deps.repo.getKioskById(display.kiosk_id) : null; return htmlPage(DisplayEditPage({ user: user.username, display, @@ -1227,7 +1228,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Validate default_layout_id is actually attached to this display. let validatedDefault: number | null = defaultLayoutId; if (defaultLayoutId != null) { - const attached = deps.repo.listLayoutsForDisplay(id); + const attached = await deps.repo.listLayoutsForDisplay(id); if (!attached.some((l) => l.id === defaultLayoutId)) { validatedDefault = null; } @@ -1235,7 +1236,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // width/height are no longer admin-editable — they come from the kiosk's // hardware report. Just update the editable fields. - deps.repo.updateDisplay(id, { + await deps.repo.updateDisplay(id, { name: body?.["name"], default_layout_id: validatedDefault, idle_timeout_seconds: parseInt(body?.["idle_timeout_seconds"] ?? "0", 10), @@ -1247,11 +1248,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); // Render the attached + available layouts region for a display. - const renderDisplayLayoutsFragment = (displayId: number): Response => { - const display = deps.repo.getDisplayById(displayId); - const attached = deps.repo.listLayoutsForDisplay(displayId); + const renderDisplayLayoutsFragment = async (displayId: number): Promise => { + const display = await deps.repo.getDisplayById(displayId); + const attached = await deps.repo.listLayoutsForDisplay(displayId); const attachedIds = new Set(attached.map((l) => l.id)); - const available = deps.repo.listLayouts().filter((l) => !attachedIds.has(l.id)); + const available = (await deps.repo.listLayouts()).filter((l) => !attachedIds.has(l.id)); return htmlFragment( renderDisplayLayouts(displayId, display?.default_layout_id ?? null, attached, available) + renderDefaultLayoutSelect(display?.default_layout_id ?? null, attached, true), @@ -1264,30 +1265,30 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const body = await readBody>(event); const layoutId = body?.["layout_id"] ? Number(body["layout_id"]) : null; if (layoutId && Number.isFinite(layoutId)) { - deps.repo.attachLayoutToDisplay(displayId, layoutId); + await deps.repo.attachLayoutToDisplay(displayId, layoutId); notifyKiosks(); } if (isHtmxRequest(event)) { - return renderDisplayLayoutsFragment(displayId); + return await renderDisplayLayoutsFragment(displayId); } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); }); // Detach a layout from a display. - app.post("/admin/displays/:id/layouts/:layoutId/remove", (event) => { + app.post("/admin/displays/:id/layouts/:layoutId/remove", async (event) => { const displayId = Number(getRouterParam(event, "id")); const layoutId = Number(getRouterParam(event, "layoutId")); - deps.repo.detachLayoutFromDisplay(displayId, layoutId); + await deps.repo.detachLayoutFromDisplay(displayId, layoutId); notifyKiosks(); if (isHtmxRequest(event)) { - return renderDisplayLayoutsFragment(displayId); + return await renderDisplayLayoutsFragment(displayId); } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); }); - app.get("/admin/labels", (event) => { + app.get("/admin/labels", async (event) => { const user = event.context.user!; - return htmlPage(LabelsPage({ user: user.username, labels: deps.repo.listLabels() })); + return htmlPage(LabelsPage({ user: user.username, labels: await deps.repo.listLabels() })); }); app.post("/admin/labels/new", async (event) => { @@ -1297,57 +1298,58 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (!name || !/^[a-z0-9][a-z0-9_-]*$/.test(name)) { return htmlPage(LabelsPage({ user: event.context.user!.username, - labels: deps.repo.listLabels(), + labels: await deps.repo.listLabels(), error: "Label name must start with letter/digit and contain only lowercase, digits, hyphens, underscores.", })); } - deps.repo.createLabel({ name, color }); + await deps.repo.createLabel({ name, color }); return new Response(null, { status: 302, headers: { location: "/admin/labels" } }); }); - app.post("/admin/labels/:id/delete", (event) => { + app.post("/admin/labels/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); - deps.repo.deleteLabel(id); + await deps.repo.deleteLabel(id); return new Response(null, { status: 302, headers: { location: "/admin/labels" } }); }); // ---- Camera edit/delete/labels -------------------------------------------- - app.get("/admin/cameras/:id", (event) => { + app.get("/admin/cameras/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); - const camera = deps.repo.getCameraById(id); + const camera = await deps.repo.getCameraById(id); if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); // Build subscription list: which kiosks have this camera in any layout? - const bundleKiosks = deps.repo.listKiosksWithCameraInBundle(id); - const activeKiosks = new Set(deps.repo.listKiosksRenderingCamera(id).map((k) => k.id)); - const subscriptions = bundleKiosks.map((k) => { + const bundleKiosks = await deps.repo.listKiosksWithCameraInBundle(id); + const activeKiosks = new Set((await deps.repo.listKiosksRenderingCamera(id)).map((k) => k.id)); + const subscriptions = []; + for (const k of bundleKiosks) { // Find layout names that reference this camera on this kiosk's displays - const displays = deps.repo.listDisplaysForKiosk(k.id); + const displays = await deps.repo.listDisplaysForKiosk(k.id); const layoutNames: string[] = []; for (const d of displays) { - const layouts = deps.repo.listLayoutsForDisplay(d.id); + const layouts = await deps.repo.listLayoutsForDisplay(d.id); for (const l of layouts) { - const cells = deps.repo.listLayoutCells(l.id); + const cells = await deps.repo.listLayoutCells(l.id); if (cells.some((c) => c.camera_id === id)) { layoutNames.push(l.name); } } } - return { + subscriptions.push({ kiosk: k, layouts: layoutNames, active: activeKiosks.has(k.id), - }; - }); + }); + } return htmlPage(CameraEditPage({ user: user.username, camera, - labels: deps.repo.cameraLabelIds(id), - allLabels: deps.repo.listLabels(), - streams: deps.repo.listCameraStreams(id), + labels: await deps.repo.cameraLabelIds(id), + allLabels: await deps.repo.listLabels(), + streams: await deps.repo.listCameraStreams(id), subscriptions, })); }); @@ -1355,7 +1357,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/cameras/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const body = await readBody>(event); - const cam = deps.repo.getCameraById(id); + const cam = await deps.repo.getCameraById(id); let rtspUrl: string | null = null; if (cam?.type === "rtsp") { @@ -1395,16 +1397,16 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Event routing config if (body?.["event_source"] != null) patch["event_source"] = body["event_source"] || "auto"; if (body?.["event_sink"] != null) patch["event_sink"] = body["event_sink"] || "auto"; - deps.repo.updateCamera(id, patch as any); + await deps.repo.updateCamera(id, patch as any); // Also update main stream URI for RTSP cameras if (cam?.type === "rtsp" && rtspUrl) { - const streams = deps.repo.listCameraStreams(id); + const streams = await deps.repo.listCameraStreams(id); const mainStream = streams.find((s) => s.role === "main"); if (mainStream) { - deps.repo.updateCameraStream(mainStream.id, { rtsp_uri: rtspUrl }); + await deps.repo.updateCameraStream(mainStream.id, { rtsp_uri: rtspUrl }); } else { - deps.repo.createCameraStream({ + await deps.repo.createCameraStream({ camera_id: id, role: "main", name: "Main", @@ -1414,9 +1416,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } // Sync entity name when camera name changes. if (patch["name"]) { - const ent = deps.repo.getEntityForCamera(id); + const ent = await deps.repo.getEntityForCamera(id); if (ent && ent.name !== patch["name"]) { - deps.repo.updateEntity(ent.id, { name: patch["name"] } as any); + await deps.repo.updateEntity(ent.id, { name: patch["name"] } as any); } } @@ -1433,14 +1435,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; if (newLabel) { - const label = deps.repo.ensureLabel(newLabel); + const label = await deps.repo.ensureLabel(newLabel); labelId = label.id; } if (labelId) { - deps.repo.attachCameraLabel(camId, labelId); + await deps.repo.attachCameraLabel(camId, labelId); } if (isHtmxRequest(event)) { - return htmlFragment(renderCameraLabels(camId, deps.repo.cameraLabelIds(camId), deps.repo.listLabels())); + return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels())); } return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); }); @@ -1449,9 +1451,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const camId = Number(getRouterParam(event, "id")); const body = await readBody>(event); const labelId = Number(body?.["label_id"]); - deps.repo.detachCameraLabel(camId, labelId); + await deps.repo.detachCameraLabel(camId, labelId); if (isHtmxRequest(event)) { - return htmlFragment(renderCameraLabels(camId, deps.repo.cameraLabelIds(camId), deps.repo.listLabels())); + return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels())); } return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); }); @@ -1459,19 +1461,22 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Refresh supported ONVIF event topics from the camera. app.post("/admin/cameras/:id/refresh-events", async (event) => { const id = Number(getRouterParam(event, "id")); - const cam = deps.repo.getCameraById(id); + const cam = await deps.repo.getCameraById(id); if (!cam || cam.type !== "onvif" || !cam.onvif_host) { return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); } // Determine which kiosk (or server) to run the SOAP call through. - const runner = cam.event_source === "server" ? "server" - : cam.event_source.startsWith("kiosk:") ? cam.event_source - : (() => { - // Auto: pick a kiosk that has this camera in its bundle. - const kiosks = deps.repo.listKiosksWithCameraInBundle(id); - const online = kiosks.find((k) => k.last_seen_at && Date.now() - new Date(k.last_seen_at).getTime() < 120_000); - return online ? `kiosk:${online.id}` : "server"; - })(); + let runner: string; + if (cam.event_source === "server") { + runner = "server"; + } else if (cam.event_source.startsWith("kiosk:")) { + runner = cam.event_source; + } else { + // Auto: pick a kiosk that has this camera in its bundle. + const kiosks = await deps.repo.listKiosksWithCameraInBundle(id); + const online = kiosks.find((k) => k.last_seen_at && Date.now() - new Date(k.last_seen_at).getTime() < 120_000); + runner = online ? `kiosk:${online.id}` : "server"; + } const soapTransport = runner.startsWith("kiosk:") ? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length))) : undefined; @@ -1483,16 +1488,16 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { password: cam.onvif_password ?? "", soapTransport, }); - deps.repo.updateCamera(id, { supported_event_topics: JSON.stringify(topics) } as any); + await deps.repo.updateCamera(id, { supported_event_topics: JSON.stringify(topics) } as any); } catch { // Camera offline or events not supported — leave existing topics. } return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); }); - app.post("/admin/cameras/:id/delete", (event) => { + app.post("/admin/cameras/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); - deps.repo.deleteCamera(id); + await deps.repo.deleteCamera(id); notifyKiosks(); deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" }); return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); @@ -1504,9 +1509,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { catch { return iso; } }; const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - app.get("/admin/cameras/:id/events", (event) => { + app.get("/admin/cameras/:id/events", async (event) => { const id = Number(getRouterParam(event, "id")); - const { events } = deps.repo.queryEvents({ + const { events } = await deps.repo.queryEvents({ camera_id: id, limit: 20, }); @@ -1532,30 +1537,33 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Kiosk edit/delete/labels --------------------------------------------- - app.get("/admin/kiosks/:id", (event) => { + app.get("/admin/kiosks/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); - const kiosk = deps.repo.getKioskById(id); + const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); - const kioskLabels = deps.repo.listKioskLabels(id).map((kl) => ({ + const kioskLabels = (await deps.repo.listKioskLabels(id)).map((kl) => ({ label_id: kl.label_id, name: kl.name, role: kl.role, })); - const displays = deps.repo.listDisplaysForKiosk(id); - const displayLayouts = displays.map((display) => ({ - display, - layouts: deps.repo.listLayoutsForDisplay(display.id), - })); - const gpioBindings = deps.repo.listGpioBindings(id); - const firmwareReleases = deps.repo.listFirmwareReleases(); - const osReleases = deps.repo.listOsUpdateReleases(); - const logResult = deps.repo.queryKioskLogs({ kiosk_id: id, limit: 50 }); + const displays = await deps.repo.listDisplaysForKiosk(id); + const displayLayouts = []; + for (const display of displays) { + displayLayouts.push({ + display, + layouts: await deps.repo.listLayoutsForDisplay(display.id), + }); + } + const gpioBindings = await deps.repo.listGpioBindings(id); + const firmwareReleases = await deps.repo.listFirmwareReleases(); + const osReleases = await deps.repo.listOsUpdateReleases(); + const logResult = await deps.repo.queryKioskLogs({ kiosk_id: id, limit: 50 }); return htmlPage(KioskEditPage({ user: user.username, kiosk, labels: kioskLabels, - allLabels: deps.repo.listLabels(), + allLabels: await deps.repo.listLabels(), displays, displayLayouts, gpioBindings, @@ -1579,7 +1587,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const chip = (body?.["chip"] ?? "gpiochip0").trim() || "gpiochip0"; const topic = (body?.["topic"] ?? "").trim(); if (Number.isFinite(pin) && topic) { - deps.repo.createGpioBinding({ + await deps.repo.createGpioBinding({ kiosk_id: kioskId, chip, pin, @@ -1593,10 +1601,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); - app.post("/admin/kiosks/:id/gpio/:bindingId/delete", (event) => { + app.post("/admin/kiosks/:id/gpio/:bindingId/delete", async (event) => { const kioskId = Number(getRouterParam(event, "id")); const bindingId = Number(getRouterParam(event, "bindingId")); - deps.repo.deleteGpioBinding(bindingId); + await deps.repo.deleteGpioBinding(bindingId); notifyKiosks(); if (isHtmxRequest(event)) { // Row is swapped via hx-target="closest tr" hx-swap="outerHTML" — empty @@ -1609,8 +1617,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/kiosks/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const body = await readBody>(event); - const kiosk = deps.repo.getKioskById(id); - deps.repo.updateKiosk(id, { + const kiosk = await deps.repo.getKioskById(id); + await deps.repo.updateKiosk(id, { name: body?.["name"], enabled: body?.["enabled"] === "1", } as any); @@ -1618,7 +1626,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const cfg = kiosk.managed_config_json ? JSON.parse(kiosk.managed_config_json) : {}; const hostname = hostnameFromName(body["name"]); if (cfg?.hostname !== hostname) { - deps.repo.updateKiosk(id, { + await deps.repo.updateKiosk(id, { managed_config_json: JSON.stringify({ ...cfg, hostname }), managed_config_version: kiosk.managed_config_version + 1, managed_config_error: null, @@ -1636,7 +1644,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // so the next heartbeat ships it to the kiosk. app.post("/admin/kiosks/:id/managed-config", async (event) => { const id = Number(getRouterParam(event, "id")); - const kiosk = deps.repo.getKioskById(id); + const kiosk = await deps.repo.getKioskById(id); if (!kiosk) throw new Error("kiosk not found"); if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image"); const body = await readBody>(event); @@ -1688,13 +1696,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } } - deps.repo.updateKiosk(id, { + await deps.repo.updateKiosk(id, { managed_config_json: JSON.stringify(cfg), managed_config_version: kiosk.managed_config_version + 1, managed_config_error: null, } as any); - audit(deps.repo, event as any, "kiosk.managed_config.update", { + await audit(deps.repo, event as any, "kiosk.managed_config.update", { resource_type: "kiosk", resource_id: String(id), metadata: { version: kiosk.managed_config_version + 1 }, @@ -1711,19 +1719,19 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; if (newLabel) { - const label = deps.repo.ensureLabel(newLabel); + const label = await deps.repo.ensureLabel(newLabel); labelId = label.id; } if (labelId) { - deps.repo.attachKioskLabel(kioskId, labelId, role); + await deps.repo.attachKioskLabel(kioskId, labelId, role); } if (isHtmxRequest(event)) { - const kioskLabels = deps.repo.listKioskLabels(kioskId).map((kl) => ({ + const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({ label_id: kl.label_id, name: kl.name, role: kl.role, })); - return htmlFragment(renderKioskLabels(kioskId, kioskLabels, deps.repo.listLabels())); + return htmlFragment(renderKioskLabels(kioskId, kioskLabels, await deps.repo.listLabels())); } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); @@ -1732,21 +1740,21 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const kioskId = Number(getRouterParam(event, "id")); const body = await readBody>(event); const labelId = Number(body?.["label_id"]); - deps.repo.detachKioskLabel(kioskId, labelId); + await deps.repo.detachKioskLabel(kioskId, labelId); if (isHtmxRequest(event)) { - const kioskLabels = deps.repo.listKioskLabels(kioskId).map((kl) => ({ + const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({ label_id: kl.label_id, name: kl.name, role: kl.role, })); - return htmlFragment(renderKioskLabels(kioskId, kioskLabels, deps.repo.listLabels())); + return htmlFragment(renderKioskLabels(kioskId, kioskLabels, await deps.repo.listLabels())); } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); - app.post("/admin/kiosks/:id/delete", (event) => { + app.post("/admin/kiosks/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); - deps.repo.deleteKiosk(id); + await deps.repo.deleteKiosk(id); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); }); @@ -1754,9 +1762,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // These are simple HTML pages that connect to the admin debug WS at // /ws/admin/debug/:kioskId and render output. The WS connection is // authenticated via the admin's API key. - app.get("/admin/kiosks/:id/logs", (event) => { + app.get("/admin/kiosks/:id/logs", async (event) => { const id = Number(getRouterParam(event, "id")); - const kiosk = deps.repo.getKioskById(id); + const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); const user = event.context.user!; // Get or create an API key for the WS connection. @@ -1802,9 +1810,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { `); }); - app.get("/admin/kiosks/:id/terminal", (event) => { + app.get("/admin/kiosks/:id/terminal", async (event) => { const id = Number(getRouterParam(event, "id")); - const kiosk = deps.repo.getKioskById(id); + const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); // WS auth: browser sends session cookie automatically on WS upgrade. // Coordinator WS endpoint validates via resolveSession. @@ -1934,8 +1942,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); // ---- Layout switch ---------------------------------------------------- - const emitLayoutChanged = (displayId: number | null, kioskId: number | null, layoutId: number) => { - const layout = deps.repo.getLayoutById(layoutId); + const emitLayoutChanged = async (displayId: number | null, kioskId: number | null, layoutId: number) => { + const layout = await deps.repo.getLayoutById(layoutId); deps.nodered.forward("layout.changed", { display_id: displayId, kiosk_id: kioskId, @@ -1953,8 +1961,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { layoutId = Number(body?.["layout_id"]); } if (Number.isFinite(displayId) && Number.isFinite(layoutId)) { - const display = deps.repo.getDisplayById(displayId); - const attached = deps.repo.listLayoutsForDisplay(displayId); + const display = await deps.repo.getDisplayById(displayId); + const attached = await deps.repo.listLayoutsForDisplay(displayId); const isAttached = attached.some((l) => l.id === layoutId); if (display?.kiosk_id && isAttached) { getCoordinator().sendToKiosk(display.kiosk_id, { @@ -1962,7 +1970,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { display_id: displayId, layout_id: layoutId, }); - emitLayoutChanged(displayId, display.kiosk_id, layoutId); + await emitLayoutChanged(displayId, display.kiosk_id, layoutId); } } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); @@ -1971,15 +1979,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); - const displayPower = (event: any, state: "on" | "standby") => { + const displayPower = async (event: any, state: "on" | "standby") => { const id = Number(getRouterParam(event, "id")); - const display = deps.repo.getDisplayById(id); + const display = await deps.repo.getDisplayById(id); if (display?.kiosk_id) { getCoordinator().sendToKiosk(display.kiosk_id, { type: state === "on" ? "wake" : "standby", display_id: id, }); - deps.repo.updateDisplay(id, { + await deps.repo.updateDisplay(id, { actual_power_state: state === "on" ? "awake" : "standby", actual_power_state_at: new Date().toISOString(), } as any); @@ -1996,19 +2004,19 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/displays/:id/power/wake", (event) => displayPower(event, "on")); // Node-RED embedded page - app.get("/admin/nodered", (event) => { + app.get("/admin/nodered", async (event) => { const user = event.context.user!; return htmlPage(NoderedEmbedPage({ user: user.username })); }); // ---- CEC power commands ----------------------------------------------- - const emitDisplayPower = (kioskId: number, state: "on" | "standby") => { - const displays = deps.repo.listDisplaysForKiosk(kioskId); + const emitDisplayPower = async (kioskId: number, state: "on" | "standby") => { + const displays = await deps.repo.listDisplaysForKiosk(kioskId); const displayId = displays[0]?.id ?? null; const actual = state === "on" ? "awake" : "standby"; const at = new Date().toISOString(); for (const display of displays) { - deps.repo.updateDisplay(display.id, { + await deps.repo.updateDisplay(display.id, { actual_power_state: actual, actual_power_state_at: at, } as any); @@ -2021,19 +2029,19 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); }; - app.post("/admin/kiosks/:id/power/standby", (event) => { + app.post("/admin/kiosks/:id/power/standby", async (event) => { const id = Number(getRouterParam(event, "id")); getCoordinator().sendToKiosk(id, { type: "standby" }); - emitDisplayPower(id, "standby"); - audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id }); + await emitDisplayPower(id, "standby"); + await audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); - app.post("/admin/kiosks/:id/power/wake", (event) => { + app.post("/admin/kiosks/:id/power/wake", async (event) => { const id = Number(getRouterParam(event, "id")); getCoordinator().sendToKiosk(id, { type: "wake" }); - emitDisplayPower(id, "on"); - audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id }); + await emitDisplayPower(id, "on"); + await audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); @@ -2058,43 +2066,46 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // automation clients. List shapes are kept thin (id/name/type/enabled + // labels where useful); detail shapes return the full row minus secrets. - app.get("/api/admin/cameras", (_event) => { - const cameras = deps.repo.listCameras(); - const payload = cameras.map((c) => ({ - id: c.id, - name: c.name, - type: c.type, - enabled: c.enabled, - labels: deps.repo.cameraLabelNames(c.id), - })); + app.get("/api/admin/cameras", async (_event) => { + const cameras = await deps.repo.listCameras(); + const payload = []; + for (const c of cameras) { + payload.push({ + id: c.id, + name: c.name, + type: c.type, + enabled: c.enabled, + labels: await deps.repo.cameraLabelNames(c.id), + }); + } return jsonResponse({ cameras: payload }); }); - app.get("/api/admin/cameras/:id", (event) => { + app.get("/api/admin/cameras/:id", async (event) => { const id = Number(getRouterParam(event, "id")); - const cam = deps.repo.getCameraById(id); + const cam = await deps.repo.getCameraById(id); if (!cam) return jsonResponse({ error: "not_found" }, 404); - const streams = deps.repo.listCameraStreams(id); + const streams = await deps.repo.listCameraStreams(id); return jsonResponse({ - camera: { ...cam, labels: deps.repo.cameraLabelNames(id), streams }, + camera: { ...cam, labels: await deps.repo.cameraLabelNames(id), streams }, }); }); - app.get("/api/admin/displays", (_event) => { - const displays = deps.repo.listDisplays(); + app.get("/api/admin/displays", async (_event) => { + const displays = await deps.repo.listDisplays(); return jsonResponse({ displays }); }); - app.get("/api/admin/displays/:id", (event) => { + app.get("/api/admin/displays/:id", async (event) => { const id = Number(getRouterParam(event, "id")); - const display = deps.repo.getDisplayById(id); + const display = await deps.repo.getDisplayById(id); if (!display) return jsonResponse({ error: "not_found" }, 404); - const attachedLayouts = deps.repo.listLayoutsForDisplay(id); + const attachedLayouts = await deps.repo.listLayoutsForDisplay(id); return jsonResponse({ display: { ...display, attached_layouts: attachedLayouts } }); }); - app.get("/api/admin/kiosks", (_event) => { - const kiosks = deps.repo.listKiosks(); + app.get("/api/admin/kiosks", async (_event) => { + const kiosks = await deps.repo.listKiosks(); const now = Date.now(); const payload = kiosks.map((k) => ({ id: k.id, @@ -2112,12 +2123,12 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return jsonResponse({ kiosks: payload }); }); - app.get("/api/admin/kiosks/:id", (event) => { + app.get("/api/admin/kiosks/:id", async (event) => { const id = Number(getRouterParam(event, "id")); - const kiosk = deps.repo.getKioskById(id); + const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return jsonResponse({ error: "not_found" }, 404); - const displays = deps.repo.listDisplaysForKiosk(id); - const labels = deps.repo.listKioskLabels(id).map((kl) => ({ + const displays = await deps.repo.listDisplaysForKiosk(id); + const labels = (await deps.repo.listKioskLabels(id)).map((kl) => ({ label_id: kl.label_id, name: kl.name, role: kl.role, @@ -2125,28 +2136,28 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return jsonResponse({ kiosk: { ...kiosk, displays, labels } }); }); - app.get("/api/admin/layouts", (_event) => { - const layouts = deps.repo.listLayouts(); + app.get("/api/admin/layouts", async (_event) => { + const layouts = await deps.repo.listLayouts(); return jsonResponse({ layouts }); }); - app.get("/api/admin/layouts/:id", (event) => { + app.get("/api/admin/layouts/:id", async (event) => { const id = Number(getRouterParam(event, "id")); - const layout = deps.repo.getLayoutById(id); + const layout = await deps.repo.getLayoutById(id); if (!layout) return jsonResponse({ error: "not_found" }, 404); - const cells = deps.repo.layoutCells(id); - const displays = deps.repo.listDisplaysForLayout(id); + const cells = await deps.repo.layoutCells(id); + const displays = await deps.repo.listDisplaysForLayout(id); return jsonResponse({ layout: { ...layout, cells, displays } }); }); - app.get("/api/admin/entities", (_event) => { - const entities = deps.repo.listEntities(); + app.get("/api/admin/entities", async (_event) => { + const entities = await deps.repo.listEntities(); return jsonResponse({ entities }); }); - app.get("/api/admin/entities/:id", (event) => { + app.get("/api/admin/entities/:id", async (event) => { const id = Number(getRouterParam(event, "id")); - const entity = deps.repo.getEntityById(id); + const entity = await deps.repo.getEntityById(id); if (!entity) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ entity }); }); @@ -2165,14 +2176,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return jsonResponse({ error: "invalid_value" }, 400); } if (layoutId != null) { - const attached = deps.repo.listLayoutsForDisplay(id); + const attached = await deps.repo.listLayoutsForDisplay(id); if (!attached.some((l) => l.id === layoutId)) { return jsonResponse({ error: "layout_not_attached" }, 400); } } - deps.repo.updateDisplay(id, { default_layout_id: layoutId } as any); + await deps.repo.updateDisplay(id, { default_layout_id: layoutId } as any); notifyKiosks(); - const display = deps.repo.getDisplayById(id); + const display = await deps.repo.getDisplayById(id); return jsonResponse({ display }); }); @@ -2180,8 +2191,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const id = Number(getRouterParam(event, "id")); const body = (await readBody>(event)) ?? {}; const enabled = Boolean(body["value"] ?? body["enabled"]); - deps.repo.updateKiosk(id, { enabled } as any); - const kiosk = deps.repo.getKioskById(id); + await deps.repo.updateKiosk(id, { enabled } as any); + const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ kiosk }); }); @@ -2190,10 +2201,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const id = Number(getRouterParam(event, "id")); const body = (await readBody>(event)) ?? {}; const enabled = Boolean(body["value"] ?? body["enabled"]); - deps.repo.updateCamera(id, { enabled } as any); + await deps.repo.updateCamera(id, { enabled } as any); notifyKiosks(); deps.nodered.forward("camera.changed", { camera_id: id, event: "updated", source: "server" }); - const camera = deps.repo.getCameraById(id); + const camera = await deps.repo.getCameraById(id); if (!camera) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ camera }); }); @@ -2205,9 +2216,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (value !== "hot" && value !== "normal" && value !== "cold") { return jsonResponse({ error: "invalid_priority" }, 400); } - deps.repo.updateLayout(id, { priority: value } as any); + await deps.repo.updateLayout(id, { priority: value } as any); notifyKiosks(); - const layout = deps.repo.getLayoutById(id); + const layout = await deps.repo.getLayoutById(id); if (!layout) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ layout }); }); @@ -2219,13 +2230,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (!name || name.length > 128) { return jsonResponse({ error: "invalid_name" }, 400); } - const existing = deps.repo.getEntityByName(name); + const existing = await deps.repo.getEntityByName(name); if (existing && existing.id !== id) { return jsonResponse({ error: "name_in_use" }, 400); } - deps.repo.updateEntity(id, { name }); + await deps.repo.updateEntity(id, { name }); notifyKiosks(); - const entity = deps.repo.getEntityById(id); + const entity = await deps.repo.getEntityById(id); if (!entity) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ entity }); }); @@ -2256,25 +2267,25 @@ async function syncDashboardsFromNodered( let added = 0; let updated = 0; for (const tab of tabs) { - const existing = deps.repo.getEntityForDashboard(tab.id); + const existing = await deps.repo.getEntityForDashboard(tab.id); if (existing) { if (existing.name !== tab.name) { // Avoid name collisions with non-dashboard entities of the same name. - const collision = deps.repo.getEntityByName(tab.name); + const collision = await deps.repo.getEntityByName(tab.name); const safeName = collision && collision.id !== existing.id ? `${tab.name} (dash ${tab.id.slice(0, 6)})` : tab.name; - deps.repo.updateEntity(existing.id, { name: safeName }); + await deps.repo.updateEntity(existing.id, { name: safeName }); updated += 1; } continue; } // New dashboard tab — insert. let name = tab.name || `Dashboard ${tab.id.slice(0, 6)}`; - if (deps.repo.getEntityByName(name)) { + if (await deps.repo.getEntityByName(name)) { name = `${name} (dash ${tab.id.slice(0, 6)})`; } - deps.repo.createEntity({ + await deps.repo.createEntity({ name, type: "dashboard", dashboard_id: tab.id, diff --git a/server/src/plugins/service-admin-http/routes-auth.ts b/server/src/plugins/service-admin-http/routes-auth.ts index 71c1b33..342e439 100644 --- a/server/src/plugins/service-admin-http/routes-auth.ts +++ b/server/src/plugins/service-admin-http/routes-auth.ts @@ -27,7 +27,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { ?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() ?? "anon"; if (!loginGuard.take(`login:${ip}`)) { - audit(deps.repo, event as any, "user.login", { + await audit(deps.repo, event as any, "user.login", { result: "failed", 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 })); } - const user = deps.repo.getUserByUsername(username); + const user = await deps.repo.getUserByUsername(username); if (!user || !user.is_active) { 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) { patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString(); } - deps.repo.updateUser(user.id, patch); - audit(deps.repo, event as any, "user.login", { + await deps.repo.updateUser(user.id, patch); + await audit(deps.repo, event as any, "user.login", { result: "failed", actor_type: "system", actor_label: username, @@ -74,13 +74,13 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { return htmlPage(LoginPage({ error: "Invalid credentials.", username })); } - deps.repo.updateUser(user.id, { + await deps.repo.updateUser(user.id, { failed_login_count: 0, locked_until: null, 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_id: user.id, actor_label: user.username, @@ -106,12 +106,12 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { // ---- TOTP ----------------------------------------------------------------- - app.get("/auth/totp", (event) => { + app.get("/auth/totp", async (event) => { const cookie = getCookie(event, deps.cookieName); if (!cookie) { 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) { return new Response(null, { status: 302, headers: { location: "/admin/" } }); } @@ -123,7 +123,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { if (!cookie) { 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) { 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." })); } - deps.repo.setSessionTotpPending(session.id, false); + await deps.repo.setSessionTotpPending(session.id, false); return new Response(null, { status: 302, headers: { location: "/admin/" } }); }); // ---- Recovery code -------------------------------------------------------- - app.get("/auth/recovery", (event) => { + app.get("/auth/recovery", async (event) => { const cookie = getCookie(event, deps.cookieName); if (!cookie) { 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) { return new Response(null, { status: 302, headers: { location: "/admin/" } }); } @@ -169,7 +169,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { if (!cookie) { 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) { 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." })); } - deps.repo.updateUser(user.id, { + await deps.repo.updateUser(user.id, { 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/" } }); }); // ---- Logout --------------------------------------------------------------- - app.post("/auth/logout", (event) => { + app.post("/auth/logout", async (event) => { const cookie = getCookie(event, deps.cookieName); if (cookie) { - const resolved = deps.auth.resolveSession(cookie); + const resolved = await deps.auth.resolveSession(cookie); if (resolved) { - deps.auth.revokeSession(resolved.session.id); + await deps.auth.revokeSession(resolved.session.id); } } return redirectClearCookie("/auth/login", deps.cookieName); diff --git a/server/src/plugins/service-admin-http/routes-firmware.ts b/server/src/plugins/service-admin-http/routes-firmware.ts index cdcd8f8..bd394e0 100644 --- a/server/src/plugins/service-admin-http/routes-firmware.ts +++ b/server/src/plugins/service-admin-http/routes-firmware.ts @@ -32,9 +32,9 @@ const ALLOWED_ARCHES = new Set([ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { // ---- List page ----------------------------------------------------------- - app.get("/admin/firmware", (event) => { + app.get("/admin/firmware", async (event) => { const user = event.context.user!; - const releases = deps.repo.listFirmwareReleases(); + const releases = await deps.repo.listFirmwareReleases(); return htmlPage(FirmwarePage({ user: user.username, releases, @@ -70,7 +70,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { const { sha256, signature } = deps.firmware.signBlob(buf); const artifactPath = await deps.firmware.storeBlob(buf, sha256); - const release = deps.repo.createFirmwareRelease({ + const release = await deps.repo.createFirmwareRelease({ id: randomUUID(), version, channel: channelRaw as FirmwareChannel, @@ -82,7 +82,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { release_notes: releaseNotes, 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_id: release.id, 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 artifactPath = await deps.firmware.storeBlob(buf, sha256); const id = randomUUID(); - const release = deps.repo.createFirmwareRelease({ + const release = await deps.repo.createFirmwareRelease({ id, version: body.version, channel: body.channel, @@ -140,10 +140,10 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { }); // ---- Yank --------------------------------------------------------------- - app.post("/admin/firmware/:id/yank", (event) => { + app.post("/admin/firmware/:id/yank", async (event) => { const id = String(getRouterParam(event, "id")); - deps.repo.yankFirmwareRelease(id); - audit(deps.repo, event as any, "firmware.yank", { + await deps.repo.yankFirmwareRelease(id); + await audit(deps.repo, event as any, "firmware.yank", { resource_type: "firmware_release", resource_id: id, }); @@ -160,15 +160,15 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { if (!ALLOWED_CHANNELS.has(channelRaw)) { throw createError({ statusCode: 400, statusMessage: "invalid channel" }); } - deps.repo.setKioskFirmwarePref(id, { + await deps.repo.setKioskFirmwarePref(id, { channel: channelRaw, target_version: targetRaw ? targetRaw : null, }); - const k = deps.repo.getKioskById(id); + const k = await deps.repo.getKioskById(id); if (!k) { 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 })); }); @@ -183,11 +183,11 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { // ---- Rollouts ----------------------------------------------------------- - app.get("/admin/firmware/rollouts", (event) => { + app.get("/admin/firmware/rollouts", async (event) => { const user = event.context.user!; - const rollouts = deps.repo.listFirmwareRollouts(); - const releases = deps.repo.listFirmwareReleases(); - const kiosks = deps.repo.listKiosks(); + const rollouts = await deps.repo.listFirmwareRollouts(); + const releases = await deps.repo.listFirmwareReleases(); + const kiosks = await deps.repo.listKiosks(); return htmlPage(FirmwareRolloutsPage({ user: user.username, rollouts, @@ -200,7 +200,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { const body = await readBody>(event); const releaseId = String(body?.["release_id"] ?? ""); 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" }); const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); 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)) : []; const user = event.context.user!; - const rollout = deps.repo.createFirmwareRollout({ + const rollout = await deps.repo.createFirmwareRollout({ id: randomUUID(), release_id: releaseId, target_kiosk_ids: targets, percentage, created_by: user.id ?? null, }); - deps.repo.updateFirmwareRolloutState(rollout.id, "active"); - audit(deps.repo, event as any, "firmware.rollout.create", { + await deps.repo.updateFirmwareRolloutState(rollout.id, "active"); + await audit(deps.repo, event as any, "firmware.rollout.create", { resource_type: "firmware_rollout", resource_id: rollout.id, 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). const coord = getCoordinator(); 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" }); } else { 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") { 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" } }); }); } diff --git a/server/src/plugins/service-admin-http/routes-os-updates.ts b/server/src/plugins/service-admin-http/routes-os-updates.ts index 6e0f4d5..db3b2a8 100644 --- a/server/src/plugins/service-admin-http/routes-os-updates.ts +++ b/server/src/plugins/service-admin-http/routes-os-updates.ts @@ -25,17 +25,17 @@ function clamp(n: number, lo: number, hi: number): number { export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { // ---- List page ----------------------------------------------------------- - app.get("/admin/os-updates", (event) => { + app.get("/admin/os-updates", async (event) => { const user = event.context.user!; - const releases = deps.repo.listOsUpdateReleases(); + const releases = await deps.repo.listOsUpdateReleases(); return htmlPage(OsUpdatePage({ user: user.username, releases })); }); // ---- Yank --------------------------------------------------------------- - app.post("/admin/os-updates/:id/yank", (event) => { + app.post("/admin/os-updates/:id/yank", async (event) => { const id = String(getRouterParam(event, "id")); - deps.repo.yankOsUpdateRelease(id); - audit(deps.repo, event as any, "os_update.yank", { + await deps.repo.yankOsUpdateRelease(id); + await audit(deps.repo, event as any, "os_update.yank", { resource_type: "os_update_release", resource_id: id, }); @@ -51,15 +51,15 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { if (!ALLOWED_CHANNELS.has(channelRaw)) { throw createError({ statusCode: 400, statusMessage: "invalid channel" }); } - deps.repo.setKioskOsUpdatePref(id, { + await deps.repo.setKioskOsUpdatePref(id, { channel: channelRaw, target_version: targetRaw ? targetRaw : null, }); - const k = deps.repo.getKioskById(id); + const k = await deps.repo.getKioskById(id); if (!k) { 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 })); }); @@ -72,11 +72,11 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { }); // ---- Rollouts ----------------------------------------------------------- - app.get("/admin/os-updates/rollouts", (event) => { + app.get("/admin/os-updates/rollouts", async (event) => { const user = event.context.user!; - const rollouts = deps.repo.listOsUpdateRollouts(); - const releases = deps.repo.listOsUpdateReleases(); - const kiosks = deps.repo.listKiosks(); + const rollouts = await deps.repo.listOsUpdateRollouts(); + const releases = await deps.repo.listOsUpdateReleases(); + const kiosks = await deps.repo.listKiosks(); return htmlPage(OsUpdateRolloutsPage({ user: user.username, rollouts, @@ -89,7 +89,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { const body = await readBody>(event); const releaseId = String(body?.["release_id"] ?? ""); 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" }); const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); 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)) : []; const user = event.context.user!; - const rollout = deps.repo.createOsUpdateRollout({ + const rollout = await deps.repo.createOsUpdateRollout({ id: randomUUID(), release_id: releaseId, target_kiosk_ids: targets, percentage, created_by: user.id ?? null, }); - deps.repo.updateOsUpdateRolloutState(rollout.id, "active"); - audit(deps.repo, event as any, "os_update.rollout.create", { + await deps.repo.updateOsUpdateRolloutState(rollout.id, "active"); + await audit(deps.repo, event as any, "os_update.rollout.create", { resource_type: "os_update_rollout", resource_id: rollout.id, 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") { 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" } }); }); @@ -165,7 +165,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { let release; try { - release = deps.repo.createOsUpdateRelease({ + release = await deps.repo.createOsUpdateRelease({ id: randomUUID(), version, channel, @@ -181,7 +181,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { 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_id: release.id, metadata: { diff --git a/server/src/plugins/service-admin-http/routes-setup.ts b/server/src/plugins/service-admin-http/routes-setup.ts index 78a7b0b..50bf8fa 100644 --- a/server/src/plugins/service-admin-http/routes-setup.ts +++ b/server/src/plugins/service-admin-http/routes-setup.ts @@ -7,15 +7,15 @@ import type { AdminDeps } from "./index.js"; import { SetupPage } from "../../web-templates/auth-pages.js"; export function registerSetupRoutes(app: H3, deps: AdminDeps): void { - app.get("/setup", () => { - if (deps.repo.isSetupComplete()) { + app.get("/setup", async () => { + if (await deps.repo.isSetupComplete()) { return new Response(null, { status: 302, headers: { location: "/admin/" } }); } return htmlPage(SetupPage({})); }); app.post("/setup", async (event) => { - if (deps.repo.isSetupComplete()) { + if (await deps.repo.isSetupComplete()) { 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); - 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 encryptedCluster = deps.secrets.encryptString(clusterKey, "cluster"); - deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster); - deps.repo.markClusterKeyProvisioned(); + await deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster); + await deps.repo.markClusterKeyProvisioned(); // Setup only creates admin user + cluster key. // Displays are created when kiosks are paired (kiosk reports HDMI ports). // Layouts are created by admin after pairing. - deps.repo.markSetupComplete(); + await deps.repo.markSetupComplete(); return new Response(null, { status: 302, diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 5087663..0c95e6f 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -195,8 +195,8 @@ function extractBearerToken(event: any): string | null { return null; } -function getClusterKey(repo: Repository, secrets: SecretsApi): string | undefined { - const enc = repo.getSetupExtra("cluster_key_encrypted") as string | undefined; +async function getClusterKey(repo: Repository, secrets: SecretsApi): Promise { + const enc = await repo.getSetupExtra("cluster_key_encrypted") as string | undefined; if (!enc) return undefined; return secrets.decryptString(enc, "cluster"); } @@ -230,7 +230,7 @@ function registerPairingRoutes( managed_image?: boolean; }>(event); - const result = initiatePairing(repo, { + const result = await initiatePairing(repo, { proposedName: body?.proposed_name ?? null, hardwareModel: body?.hardware_model ?? null, capabilities: body?.capabilities ?? [], @@ -254,7 +254,7 @@ function registerPairingRoutes( const code = (body?.code ?? "").trim().toUpperCase(); if (!code) throw createError({ statusCode: 400, statusMessage: "code required" }); - const result = claimPairing(repo, code); + const result = await claimPairing(repo, code); if (result.status === "pending") { return new Response(JSON.stringify({ status: "pending" }), { status: 202, @@ -296,8 +296,8 @@ function registerKioskRoutes( const kiosk = await auth.verifyKioskKey(token); if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); - const clusterKey = getClusterKey(repo, secrets); - const bundle = generateBundle(repo, secrets, kiosk.id, clusterKey); + const clusterKey = await getClusterKey(repo, secrets); + const bundle = await generateBundle(repo, secrets, kiosk.id, clusterKey); if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" }); // 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() ?? null; - repo.touchKiosk(kiosk.id, { + await repo.touchKiosk(kiosk.id, { bundle_version: body?.bundle_version ?? null, kiosk_app_version: body?.kiosk_app_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 // successful apply (kiosk omits it). verifyKioskKey returns just {id}; // 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") { const patch: Record = { managed_config_applied_version: body.managed_config_applied_version, @@ -400,7 +400,7 @@ function registerKioskRoutes( if (body.managed_config_error !== undefined) { 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). @@ -423,7 +423,7 @@ function registerKioskRoutes( // Sync displays reported by the kiosk if (Array.isArray(body?.displays)) { - const existing = repo.listDisplaysForKiosk(kiosk.id); + const existing = await repo.listDisplaysForKiosk(kiosk.id); const seenDisplayIds = new Set(); for (const [position, reported] of body.displays.entries()) { const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0 @@ -445,7 +445,7 @@ function registerKioskRoutes( || match.height_px !== reported.height_px || (powerState != null && match.actual_power_state !== powerState) ) { - repo.updateDisplay(match.id, { + await repo.updateDisplay(match.id, { name: reported.name, index: reportedIndex, width_px: reported.width_px, @@ -458,7 +458,7 @@ function registerKioskRoutes( } } else { // New display — create it - const created = repo.createDisplayForKiosk(kiosk.id, { + const created = await repo.createDisplayForKiosk(kiosk.id, { name: reported.name, index: reportedIndex, width_px: reported.width_px, @@ -470,7 +470,7 @@ function registerKioskRoutes( ? "unknown" : null; if (powerState != null) { - repo.updateDisplay(created.id, { + await repo.updateDisplay(created.id, { actual_power_state: powerState, actual_power_state_at: new Date().toISOString(), } as any); @@ -481,14 +481,14 @@ function registerKioskRoutes( for (const display of existing) { if (seenDisplayIds.has(display.id) || !display.is_enabled) continue; if (!display.name.endsWith(" HDMI-0")) continue; - if (repo.listLayoutsForDisplay(display.id).length > 0) continue; - repo.updateDisplay(display.id, { is_enabled: false } as any); + if ((await repo.listLayoutsForDisplay(display.id)).length > 0) continue; + await repo.updateDisplay(display.id, { is_enabled: false } as any); } } // Re-read kiosk so we see the freshly-persisted applied_version above when // 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; if ( fresh?.managed_image @@ -552,7 +552,7 @@ function registerKioskRoutes( } } - const eventId = repo.insertEvent({ + const eventId = await repo.insertEvent({ source_kiosk_id: kiosk.id, source_camera_id: body.camera_id ?? null, source_type: (body.source_type as any) ?? "system", @@ -569,7 +569,7 @@ function registerKioskRoutes( const layoutId = Number(body.payload?.["layout_id"]); if (Number.isInteger(displayId) && Number.isInteger(layoutId)) { try { - repo.updateDisplay(displayId, { active_layout_id: layoutId } as any); + await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any); } catch { // Display might not exist; layout.changed is best-effort telemetry. } @@ -588,7 +588,7 @@ function registerKioskRoutes( "display.power.changed", "camera.changed", ]); - const markForwarded = () => repo.markEventForwarded(eventId); + const markForwarded = () => { repo.markEventForwarded(eventId); }; if (flatTopics.has(body.topic)) { const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}), source: "kiosk" }; nodered.forward(body.topic, out, markForwarded); @@ -641,7 +641,7 @@ function registerKioskRoutes( logged_at: e.logged_at, })); - const count = repo.insertKioskLogs(kiosk.id, entries); + const count = await repo.insertKioskLogs(kiosk.id, entries); return { ok: true, count }; }); @@ -664,7 +664,7 @@ function registerKioskRoutes( if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); const verified = await auth.verifyKioskKey(token); 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" }); const url = new URL(event.req.url); @@ -677,15 +677,15 @@ function registerKioskRoutes( let release = null; // Explicit per-kiosk pin wins over all rollout / channel selection. 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; } // Active rollouts: most-recent matching, with bucket eligibility. if (!release) { - const rollouts = repo.listActiveRolloutsForKiosk(kiosk.id); + const rollouts = await repo.listActiveRolloutsForKiosk(kiosk.id); for (const rollout of rollouts) { 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.arch !== arch) continue; release = r; @@ -695,7 +695,7 @@ function registerKioskRoutes( // Channel-latest fallback. if (!release) { const channel = (kiosk.firmware_channel ?? "stable") as FirmwareChannel; - release = repo.getLatestFirmwareRelease(channel, arch); + release = await repo.getLatestFirmwareRelease(channel, arch); } if (!release || release.version === currentVersion) { @@ -732,7 +732,7 @@ function registerKioskRoutes( ?? new URL(event.req.url).pathname.split("/").pop(); 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) { throw createError({ statusCode: 404, statusMessage: "release not found" }); } @@ -765,8 +765,8 @@ function registerKioskRoutes( if (!body?.version) { throw createError({ statusCode: 400, statusMessage: "version required" }); } - repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null); - repo.insertEvent({ + await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null); + await repo.insertEvent({ source_kiosk_id: kiosk.id, source_camera_id: null, source_type: "system", @@ -792,7 +792,7 @@ function registerKioskRoutes( if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); const verified = await auth.verifyKioskKey(token); 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" }); const url = new URL(event.req.url); @@ -804,14 +804,14 @@ function registerKioskRoutes( let release = null; 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) { - const rollouts = repo.listActiveOsUpdateRolloutsForKiosk(kiosk.id); + const rollouts = await repo.listActiveOsUpdateRolloutsForKiosk(kiosk.id); for (const rollout of rollouts) { 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.compatibility !== compatibility) continue; release = r; @@ -820,7 +820,7 @@ function registerKioskRoutes( } if (!release) { 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) { @@ -852,7 +852,7 @@ function registerKioskRoutes( ?? new URL(event.req.url).pathname.split("/").pop(); 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) { throw createError({ statusCode: 404, statusMessage: "release not found" }); } @@ -908,8 +908,8 @@ function registerKioskRoutes( if (!body?.version) { throw createError({ statusCode: 400, statusMessage: "version required" }); } - repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null); - repo.insertEvent({ + await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null); + await repo.insertEvent({ source_kiosk_id: kiosk.id, source_camera_id: null, source_type: "system", diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index c19b11d..356d5a4 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -265,7 +265,7 @@ export class Plugin extends BSBService, typeof Event if (!authed && cookieHeader) { const cookieVal = parseCookieValue(cookieHeader, cookieName); if (cookieVal) { - const result = auth.resolveSession(cookieVal); + const result = await auth.resolveSession(cookieVal); if (result) authed = true; } } @@ -317,7 +317,7 @@ export class Plugin extends BSBService, typeof Event socket.destroy(); return; } - const kioskData = repo.getKioskById(kiosk.id); + const kioskData = await repo.getKioskById(kiosk.id); if (!kioskData) { socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.destroy(); diff --git a/server/src/plugins/service-store/index.ts b/server/src/plugins/service-store/index.ts index 8a46e4a..35c4331 100644 --- a/server/src/plugins/service-store/index.ts +++ b/server/src/plugins/service-store/index.ts @@ -162,7 +162,13 @@ export class Plugin extends BSBService, typeof Event 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. try { await this.events.emitBroadcast("store.changed", obs, { table, op, id }); @@ -181,12 +187,12 @@ export class Plugin extends BSBService, typeof Event obs.log.info("store ready"); } - private runPurge(obs: Observable): void { + private async runPurge(obs: Observable): Promise { if (!this._repo) return; const r = this._repo; - const kl = r.purgeKioskLogs(14); - const el = r.purgeEventLog(30, 100_000); - const al = r.purgeAuditLog(90); + const kl = await r.purgeKioskLogs(14); + const el = await r.purgeEventLog(30, 100_000); + const al = await r.purgeAuditLog(90); if (kl + el + al > 0) { obs.log.info("purge: {kl} kiosk_logs, {el} event_log, {al} audit_log", { kl, el, al }); } diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 00da114..94f069f 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -8,8 +8,8 @@ * NOT THREAD SAFE — node:sqlite is single-threaded, and so is Node. Don't * cross workers with the same handle. */ -import type { DatabaseSync, StatementSync } from "node:sqlite"; import { randomBytes } from "node:crypto"; +import type { DbAdapter, RunResult, Row } from "./db-adapter.js"; import type { ApiKey, @@ -88,127 +88,121 @@ type NotifyFn = ( ) => Promise; export class Repository { - private readonly db: DatabaseSync; + readonly adapter: DbAdapter; private readonly notify: NotifyFn; - private readonly stmts = new Map(); - constructor(db: DatabaseSync, notify: NotifyFn) { - this.db = db; + constructor(adapter: DbAdapter, notify: NotifyFn) { + this.adapter = adapter; this.notify = notify; } - /** Cached prepared statements. */ - private prep(sql: string): StatementSync { - let s = this.stmts.get(sql); - if (!s) { - s = this.db.prepare(sql); - this.stmts.set(sql, s); - } - return s; + /** Run a write statement. Params are passed as an array. */ + private _run(sql: string, params: unknown[] = []): Promise { + return this.adapter.run(sql, params as any); + } + /** Single-row query. */ + private _get(sql: string, params: unknown[] = []): Promise { + return this.adapter.get(sql, params as any); + } + /** Multi-row query. */ + private _all(sql: string, params: unknown[] = []): Promise { + return this.adapter.all(sql, params as any); + } + /** Execute DDL. */ + private _exec(sql: string): Promise { + return this.adapter.exec(sql); } /** Ad-hoc transaction. */ - transact(fn: () => T): T { - this.db.exec("BEGIN"); - try { - const out = fn(); - this.db.exec("COMMIT"); - return out; - } catch (err) { - try { - this.db.exec("ROLLBACK"); - } catch { - /* ignore */ - } - throw err; - } + async transact(fn: () => Promise): Promise { + return this.adapter.transaction(fn); } // =========================================================================== // setup_state // =========================================================================== - getSetupState(): SetupState { - const r = this.prep("SELECT * FROM setup_state WHERE id = 1").get(); + async getSetupState(): Promise { + const r = await this._get("SELECT * FROM setup_state WHERE id = 1"); if (!r) throw new Error("setup_state row missing"); return rowToSetupState(r as Record); } - isSetupComplete(): boolean { - return this.getSetupState().is_complete && this.countUsers() > 0; + async isSetupComplete(): Promise { + return (await this.getSetupState()).is_complete && (await this.countUsers()) > 0; } - markSetupComplete(): void { - this.prep( + async markSetupComplete(): Promise { + await this._run( `UPDATE setup_state SET is_complete = 1, completed_at = COALESCE(completed_at, ?) WHERE id = 1`, - ).run(isoNow()); + [isoNow()], + ); void this.notify("setup_state", "update", 1); } - setSetupExtra(key: string, value: unknown): void { - const cur = this.getSetupState().extras; + async setSetupExtra(key: string, value: unknown): Promise { + const cur = (await this.getSetupState()).extras; cur[key] = value; - this.prep("UPDATE setup_state SET extras = ? WHERE id = 1").run(J(cur)); + await this._run("UPDATE setup_state SET extras = ? WHERE id = 1", [J(cur)]); } - getSetupExtra(key: string): unknown { - return this.getSetupState().extras[key]; + async getSetupExtra(key: string): Promise { + return (await this.getSetupState()).extras[key]; } - markClusterKeyProvisioned(): void { - this.prep( + async markClusterKeyProvisioned(): Promise { + await this._run( "UPDATE setup_state SET cluster_key_provisioned = 1 WHERE id = 1", - ).run(); + ); } // =========================================================================== // users // =========================================================================== - countUsers(): number { - const r = this.prep("SELECT COUNT(*) AS c FROM users").get() as - | { c: number } - | undefined; + async countUsers(): Promise { + const r = await this._get<{ c: number }>("SELECT COUNT(*) AS c FROM users"); return r?.c ?? 0; } - getUserById(id: number): User | null { - const r = this.prep("SELECT * FROM users WHERE id = ?").get(id); + async getUserById(id: number): Promise { + const r = await this._get("SELECT * FROM users WHERE id = ?", [id]); return r ? rowToUser(r as Record) : null; } - getUserByUsername(username: string): User | null { - const r = this.prep("SELECT * FROM users WHERE username = ?").get(username); + async getUserByUsername(username: string): Promise { + const r = await this._get("SELECT * FROM users WHERE username = ?", [username]); return r ? rowToUser(r as Record) : null; } - createUser(input: { + async createUser(input: { username: string; password_hash: string; role?: UserRole; must_change_password?: boolean; - }): User { + }): Promise { const role: UserRole = input.role ?? "operator"; - const result = this.prep( + const result = await this._run( `INSERT INTO users (username, password_hash, role, is_active, must_change_password) VALUES (?, ?, ?, 1, ?)`, - ).run( - input.username, - input.password_hash, - role, - B(Boolean(input.must_change_password)), + [ + input.username, + input.password_hash, + role, + B(Boolean(input.must_change_password)), + ], ); const id = Number(result.lastInsertRowid); void this.notify("users", "create", id); - const u = this.getUserById(id); + const u = await this.getUserById(id); if (!u) throw new Error("user vanished after insert"); return u; } - updateUser(id: number, patch: Partial): void { + async updateUser(id: number, patch: Partial): Promise { const cols: string[] = []; const vals: unknown[] = []; if ("password_hash" in patch) { @@ -249,7 +243,7 @@ export class Repository { } if (cols.length === 0) return; vals.push(id); - this.db.prepare(`UPDATE users SET ${cols.join(", ")} WHERE id = ?`).run(...(vals as never[])); + await this._run(`UPDATE users SET ${cols.join(", ")} WHERE id = ?`, vals); void this.notify("users", "update", id); } @@ -257,7 +251,7 @@ export class Repository { // sessions // =========================================================================== - createSession(input: { + async createSession(input: { id: string; user_id: number; csrf_token: string; @@ -265,156 +259,163 @@ export class Repository { user_agent: string | null; ip_address: string | null; expires_at: string; // absolute - }): Session { - this.prep( + }): Promise { + await this._run( `INSERT INTO sessions (id, user_id, csrf_token, totp_pending, user_agent, ip_address, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run( - input.id, - input.user_id, - input.csrf_token, - B(input.totp_pending), - input.user_agent, - input.ip_address, - input.expires_at, + [ + input.id, + input.user_id, + input.csrf_token, + B(input.totp_pending), + input.user_agent, + input.ip_address, + input.expires_at, + ], ); - const s = this.getSessionById(input.id); + const s = await this.getSessionById(input.id); if (!s) throw new Error("session vanished after insert"); return s; } - getSessionById(id: string): Session | null { - const r = this.prep("SELECT * FROM sessions WHERE id = ?").get(id); + async getSessionById(id: string): Promise { + const r = await this._get("SELECT * FROM sessions WHERE id = ?", [id]); return r ? rowToSession(r as Record) : null; } - touchSession(id: string, lastSeenAt: string): void { - this.prep("UPDATE sessions SET last_seen_at = ? WHERE id = ?").run( + async touchSession(id: string, lastSeenAt: string): Promise { + await this._run("UPDATE sessions SET last_seen_at = ? WHERE id = ?", [ lastSeenAt, id, - ); + ]); } - setSessionTotpPending(id: string, pending: boolean): void { - this.prep("UPDATE sessions SET totp_pending = ? WHERE id = ?").run( + async setSessionTotpPending(id: string, pending: boolean): Promise { + await this._run("UPDATE sessions SET totp_pending = ? WHERE id = ?", [ B(pending), id, - ); + ]); } - revokeSession(id: string): void { - this.prep("UPDATE sessions SET revoked_at = ? WHERE id = ?").run(isoNow(), id); + async revokeSession(id: string): Promise { + await this._run("UPDATE sessions SET revoked_at = ? WHERE id = ?", [isoNow(), id]); } - revokeAllSessionsForUser(userId: number): void { - this.prep( + async revokeAllSessionsForUser(userId: number): Promise { + await this._run( `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, - ).run(isoNow(), userId); + [isoNow(), userId], + ); } // =========================================================================== // api_keys // =========================================================================== - createApiKey(input: { + async createApiKey(input: { name: string; key_hash: string; key_prefix: string; scopes: ApiKeyScope[]; expires_at: string | null; - }): ApiKey { - const result = this.prep( + }): Promise { + const result = await this._run( `INSERT INTO api_keys (name, key_hash, key_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?)`, - ).run( - input.name, - input.key_hash, - input.key_prefix, - J(input.scopes), - input.expires_at, + [ + input.name, + input.key_hash, + input.key_prefix, + J(input.scopes), + input.expires_at, + ], ); const id = Number(result.lastInsertRowid); void this.notify("api_keys", "create", id); - const k = this.getApiKeyById(id); + const k = await this.getApiKeyById(id); if (!k) throw new Error("api_key vanished after insert"); return k; } - getApiKeyById(id: number): ApiKey | null { - const r = this.prep("SELECT * FROM api_keys WHERE id = ?").get(id); + async getApiKeyById(id: number): Promise { + const r = await this._get("SELECT * FROM api_keys WHERE id = ?", [id]); return r ? rowToApiKey(r as Record) : null; } /** Lookup all candidates for a given prefix (typically returns 0 or 1). */ - listApiKeysByPrefix(prefix: string): ApiKey[] { - const rs = this.prep( + async listApiKeysByPrefix(prefix: string): Promise { + const rs = await this._all( "SELECT * FROM api_keys WHERE key_prefix = ? AND revoked_at IS NULL", - ).all(prefix); + [prefix], + ); return rs.map((r) => rowToApiKey(r as Record)); } - touchApiKey(id: number, ip: string | null): void { - this.prep( + async touchApiKey(id: number, ip: string | null): Promise { + await this._run( "UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE id = ?", - ).run(isoNow(), ip, id); + [isoNow(), ip, id], + ); } // =========================================================================== // displays // =========================================================================== - listDisplays(): Display[] { - const rs = this.prep('SELECT * FROM displays ORDER BY "index"').all(); + async listDisplays(): Promise { + const rs = await this._all('SELECT * FROM displays ORDER BY "index"'); return rs.map((r) => rowToDisplay(r as Record)); } - getDisplayById(id: number): Display | null { - const r = this.prep("SELECT * FROM displays WHERE id = ?").get(id); + async getDisplayById(id: number): Promise { + const r = await this._get("SELECT * FROM displays WHERE id = ?", [id]); return r ? rowToDisplay(r as Record) : null; } - createDefaultDisplay(): Display { - const result = this.prep( + async createDefaultDisplay(): Promise { + const result = await this._run( `INSERT INTO displays (name, "index", is_primary) VALUES ('primary', 0, 0)`, - ).run(); + ); const id = Number(result.lastInsertRowid); void this.notify("displays", "create", id); - const d = this.getDisplayById(id); + const d = await this.getDisplayById(id); if (!d) throw new Error("display vanished after insert"); return d; } - createDisplayForKiosk(kioskId: number, input: { + async createDisplayForKiosk(kioskId: number, input: { name: string; index?: number; width_px?: number; height_px?: number; - }): Display { - const idx = input.index ?? this.nextDisplayIndexForKiosk(kioskId); - const result = this.prep( + }): Promise { + const idx = input.index ?? await this.nextDisplayIndexForKiosk(kioskId); + const result = await this._run( `INSERT INTO displays (name, "index", is_primary, kiosk_id, width_px, height_px) VALUES (?, ?, 0, ?, ?, ?)`, - ).run( - input.name, - idx, - kioskId, - input.width_px ?? 1920, - input.height_px ?? 1080, + [ + input.name, + idx, + kioskId, + input.width_px ?? 1920, + input.height_px ?? 1080, + ], ); const id = Number(result.lastInsertRowid); void this.notify("displays", "create", id); - const d = this.getDisplayById(id); + const d = await this.getDisplayById(id); if (!d) throw new Error("display vanished after insert"); return d; } - listDisplaysForKiosk(kioskId: number): Display[] { - const rs = this.prep( + async listDisplaysForKiosk(kioskId: number): Promise { + const rs = await this._all( 'SELECT * FROM displays WHERE kiosk_id = ? ORDER BY "index"', - ).all(kioskId); + [kioskId], + ); return rs.map((r) => rowToDisplay(r as Record)); } @@ -422,8 +423,8 @@ export class Repository { * Kiosks currently rendering this camera (active layout has a cell * pointing at it). Subset of listKiosksWithCameraInBundle. */ - listKiosksRenderingCamera(cameraId: number): Kiosk[] { - const rs = this.prep( + async listKiosksRenderingCamera(cameraId: number): Promise { + const rs = await this._all( `SELECT DISTINCT k.* FROM kiosks k JOIN displays d ON d.kiosk_id = k.id @@ -431,7 +432,8 @@ export class Repository { WHERE lc.camera_id = ? AND d.active_layout_id IS NOT NULL AND k.enabled = 1`, - ).all(cameraId); + [cameraId], + ); return rs.map((r) => rowToKiosk(r as Record)); } @@ -443,8 +445,8 @@ export class Repository { * LAN position. Only when NO kiosk has the camera should the server * fall back to pulling the stream itself. */ - listKiosksWithCameraInBundle(cameraId: number): Kiosk[] { - const rs = this.prep( + async listKiosksWithCameraInBundle(cameraId: number): Promise { + const rs = await this._all( `SELECT DISTINCT k.* FROM kiosks k JOIN displays d ON d.kiosk_id = k.id @@ -452,16 +454,17 @@ export class Repository { JOIN layout_cells lc ON lc.layout_id = dl.layout_id WHERE lc.camera_id = ? AND k.enabled = 1`, - ).all(cameraId); + [cameraId], + ); return rs.map((r) => rowToKiosk(r as Record)); } - private nextDisplayIndexForKiosk(kioskId: number): number { - const r = this.prep('SELECT MAX("index") AS m FROM displays WHERE kiosk_id = ?').get(kioskId) as { m: number | null } | undefined; + private async nextDisplayIndexForKiosk(kioskId: number): Promise { + const r = await this._get<{ m: number | null }>('SELECT MAX("index") AS m FROM displays WHERE kiosk_id = ?', [kioskId]); return (r?.m ?? -1) + 1; } - updateDisplay(id: number, patch: Partial): void { + async updateDisplay(id: number, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { @@ -472,7 +475,7 @@ export class Repository { } if (sets.length === 0) return; vals.push(id); - this.db.prepare(`UPDATE displays SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]); + await this._run(`UPDATE displays SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("displays", "update", id); } @@ -484,13 +487,13 @@ export class Repository { // layouts // =========================================================================== - listLayouts(): Layout[] { - const rs = this.prep("SELECT * FROM layouts ORDER BY name").all(); + async listLayouts(): Promise { + const rs = await this._all("SELECT * FROM layouts ORDER BY name"); return rs.map((r) => rowToLayout(r as Record)); } - getLayoutById(id: number): Layout | null { - const r = this.prep("SELECT * FROM layouts WHERE id = ?").get(id); + async getLayoutById(id: number): Promise { + const r = await this._get("SELECT * FROM layouts WHERE id = ?", [id]); return r ? rowToLayout(r as Record) : null; } @@ -499,82 +502,86 @@ export class Repository { * `display_layouts` join table. Kept as a thin alias for any * callers still on the old API. */ - layoutsForDisplay(displayId: number): Layout[] { + async layoutsForDisplay(displayId: number): Promise { return this.listLayoutsForDisplay(displayId); } /** All layouts attached to the given display, via display_layouts. */ - listLayoutsForDisplay(displayId: number): Layout[] { - const rs = this.prep( + async listLayoutsForDisplay(displayId: number): Promise { + const rs = await this._all( `SELECT l.* FROM layouts l JOIN display_layouts dl ON dl.layout_id = l.id WHERE dl.display_id = ? ORDER BY l.name`, - ).all(displayId); + [displayId], + ); return rs.map((r) => rowToLayout(r as Record)); } /** Inverse: all displays that have this layout attached. */ - listDisplaysForLayout(layoutId: number): Display[] { - const rs = this.prep( + async listDisplaysForLayout(layoutId: number): Promise { + const rs = await this._all( `SELECT d.* FROM displays d JOIN display_layouts dl ON dl.display_id = d.id WHERE dl.layout_id = ? ORDER BY d."index"`, - ).all(layoutId); + [layoutId], + ); return rs.map((r) => rowToDisplay(r as Record)); } /** Idempotent attach. */ - attachLayoutToDisplay(displayId: number, layoutId: number): void { - this.prep( + async attachLayoutToDisplay(displayId: number, layoutId: number): Promise { + await this._run( `INSERT OR IGNORE INTO display_layouts (display_id, layout_id) VALUES (?, ?)`, - ).run(displayId, layoutId); + [displayId, layoutId], + ); void this.notify("display_layouts", "create", layoutId); } /** Detach. If the display's default_layout_id pointed at this layout, clear it. */ - detachLayoutFromDisplay(displayId: number, layoutId: number): void { - this.db - .prepare(`DELETE FROM display_layouts WHERE display_id = ? AND layout_id = ?`) - .run(displayId, layoutId); - this.db - .prepare( - `UPDATE displays SET default_layout_id = NULL - WHERE id = ? AND default_layout_id = ?`, - ) - .run(displayId, layoutId); + async detachLayoutFromDisplay(displayId: number, layoutId: number): Promise { + await this._run( + `DELETE FROM display_layouts WHERE display_id = ? AND layout_id = ?`, + [displayId, layoutId], + ); + await this._run( + `UPDATE displays SET default_layout_id = NULL + WHERE id = ? AND default_layout_id = ?`, + [displayId, layoutId], + ); void this.notify("display_layouts", "delete", layoutId); } - createLayout(input: { + async createLayout(input: { name: string; description?: string | null; priority?: string; cooling_timeout_seconds?: number | null; preload_camera_ids?: number[]; resets_idle_timer?: boolean; - }): Layout { - const result = this.prep( + }): Promise { + const result = await this._run( `INSERT INTO layouts (name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer) VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - input.name, - input.description ?? null, - input.priority ?? "normal", - input.cooling_timeout_seconds ?? null, - J(input.preload_camera_ids ?? []), - B(input.resets_idle_timer ?? true), + [ + input.name, + input.description ?? null, + input.priority ?? "normal", + input.cooling_timeout_seconds ?? null, + J(input.preload_camera_ids ?? []), + B(input.resets_idle_timer ?? true), + ], ); const id = Number(result.lastInsertRowid); void this.notify("layouts", "create", id); - const r = this.getLayoutById(id); + const r = await this.getLayoutById(id); if (!r) throw new Error("layout vanished after insert"); return r; } - updateLayout(id: number, patch: Partial): void { + async updateLayout(id: number, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { @@ -586,22 +593,22 @@ export class Repository { } if (sets.length === 0) return; vals.push(id); - this.db.prepare(`UPDATE layouts SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]); + await this._run(`UPDATE layouts SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("layouts", "update", id); } - cloneLayout(id: number): Layout { - const src = this.getLayoutById(id); + async cloneLayout(id: number): Promise { + const src = await this.getLayoutById(id); if (!src) throw new Error("layout not found"); let cloneName = `${src.name} (copy)`; let suffix = 2; - while (this.db.prepare("SELECT 1 FROM layouts WHERE name = ?").get(cloneName)) { + while (await this._get("SELECT 1 FROM layouts WHERE name = ?", [cloneName])) { cloneName = `${src.name} (copy ${String(suffix)})`; suffix++; } - const clone = this.createLayout({ + const clone = await this.createLayout({ name: cloneName, description: src.description, priority: src.priority, @@ -610,9 +617,9 @@ export class Repository { resets_idle_timer: src.resets_idle_timer, }); - const cells = this.listLayoutCells(id); + const cells = await this.listLayoutCells(id); for (const c of cells) { - this.createLayoutCell({ + await this.createLayoutCell({ layout_id: clone.id, row: c.row, col: c.col, @@ -630,30 +637,32 @@ export class Repository { }); } - const labels = this.db.prepare( + const labels = await this._all<{ label_id: number }>( "SELECT label_id FROM layout_labels WHERE layout_id = ?", - ).all(id) as Array<{ label_id: number }>; + [id], + ); for (const ll of labels) { - this.attachLayoutLabel(clone.id, ll.label_id); + await this.attachLayoutLabel(clone.id, ll.label_id); } - const displays = this.db.prepare( + const displays = await this._all<{ display_id: number }>( "SELECT display_id FROM display_layouts WHERE layout_id = ?", - ).all(id) as Array<{ display_id: number }>; + [id], + ); for (const dl of displays) { - this.attachLayoutToDisplay(dl.display_id, clone.id); + await this.attachLayoutToDisplay(dl.display_id, clone.id); } return clone; } - deleteLayout(id: number): void { - this.db.prepare(`DELETE FROM layout_cells WHERE layout_id = ?`).run(id); - this.db.prepare(`DELETE FROM layout_labels WHERE layout_id = ?`).run(id); - this.db.prepare(`DELETE FROM display_layouts WHERE layout_id = ?`).run(id); + async deleteLayout(id: number): Promise { + await this._run(`DELETE FROM layout_cells WHERE layout_id = ?`, [id]); + await this._run(`DELETE FROM layout_labels WHERE layout_id = ?`, [id]); + await this._run(`DELETE FROM display_layouts WHERE layout_id = ?`, [id]); // Any display whose default pointed here gets cleared. - this.db.prepare(`UPDATE displays SET default_layout_id = NULL WHERE default_layout_id = ?`).run(id); - this.db.prepare(`DELETE FROM layouts WHERE id = ?`).run(id); + await this._run(`UPDATE displays SET default_layout_id = NULL WHERE default_layout_id = ?`, [id]); + await this._run(`DELETE FROM layouts WHERE id = ?`, [id]); void this.notify("layouts", "delete", id); } @@ -661,7 +670,7 @@ export class Repository { // layout cells // =========================================================================== - createLayoutCell(input: { + async createLayoutCell(input: { layout_id: number; row: number; col: number; @@ -676,7 +685,7 @@ export class Repository { options?: Record; entity_id?: number | null; fit?: "cover" | "contain" | "fill"; - }): LayoutCell { + }): Promise { // Resolve content fields from the entity (if given). The legacy columns // remain populated for backward-compatible bundle generation. Dashboard // entities materialise as web cells pointing at /dash/ so the existing @@ -686,7 +695,7 @@ export class Repository { let webUrl: string | null = input.web_url ?? null; let htmlContent: string | null = input.html_content ?? null; if (input.entity_id != null) { - const ent = this.getEntityById(input.entity_id); + const ent = await this.getEntityById(input.entity_id); if (ent) { contentType = ent.type === "dashboard" ? "web" : ent.type; cameraId = ent.type === "camera" ? ent.camera_id : null; @@ -698,28 +707,29 @@ export class Repository { } } - const result = this.prep( + const result = await this._run( `INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id, fit) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - input.layout_id, - input.row, - input.col, - input.row_span ?? 1, - input.col_span ?? 1, - contentType, - cameraId, - input.stream_selector ?? "auto", - webUrl, - htmlContent, - input.cooling_timeout_seconds ?? null, - J(input.options ?? {}), - input.entity_id ?? null, - input.fit ?? "cover", + [ + input.layout_id, + input.row, + input.col, + input.row_span ?? 1, + input.col_span ?? 1, + contentType, + cameraId, + input.stream_selector ?? "auto", + webUrl, + htmlContent, + input.cooling_timeout_seconds ?? null, + J(input.options ?? {}), + input.entity_id ?? null, + input.fit ?? "cover", + ], ); const id = Number(result.lastInsertRowid); void this.notify("layout_cells", "create", id); - const r = this.prep("SELECT * FROM layout_cells WHERE id = ?").get(id); + const r = await this._get("SELECT * FROM layout_cells WHERE id = ?", [id]); if (!r) throw new Error("layout_cell vanished after insert"); return rowToLayoutCell(r as Record); } @@ -729,51 +739,49 @@ export class Repository { * type/camera/url/html into the legacy cell columns so bundle generation stays * compatible with the existing kiosk. */ - assignCellEntity(cellId: number, entityId: number | null): void { + async assignCellEntity(cellId: number, entityId: number | null): Promise { if (entityId == null) { - this.db - .prepare( - `UPDATE layout_cells - SET entity_id = NULL, - content_type = 'none', - camera_id = NULL, - web_url = NULL, - html_content = NULL - WHERE id = ?`, - ) - .run(cellId); + await this._run( + `UPDATE layout_cells + SET entity_id = NULL, + content_type = 'none', + camera_id = NULL, + web_url = NULL, + html_content = NULL + WHERE id = ?`, + [cellId], + ); void this.notify("layout_cells", "update", cellId); return; } - const ent = this.getEntityById(entityId); + const ent = await this.getEntityById(entityId); if (!ent) return; const cellContentType = ent.type === "dashboard" ? "web" : ent.type; const cellWebUrl = ent.type === "web" ? ent.web_url : ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` : null; - this.db - .prepare( - `UPDATE layout_cells - SET entity_id = ?, - content_type = ?, - camera_id = ?, - web_url = ?, - html_content = ? - WHERE id = ?`, - ) - .run( + await this._run( + `UPDATE layout_cells + SET entity_id = ?, + content_type = ?, + camera_id = ?, + web_url = ?, + html_content = ? + WHERE id = ?`, + [ ent.id, cellContentType, ent.type === "camera" ? ent.camera_id : null, cellWebUrl, ent.type === "html" ? ent.html_content : null, cellId, - ); + ], + ); void this.notify("layout_cells", "update", cellId); } - updateLayoutCell(id: number, patch: Partial): void { + async updateLayoutCell(id: number, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { @@ -785,12 +793,12 @@ export class Repository { } if (sets.length === 0) return; vals.push(id); - this.db.prepare(`UPDATE layout_cells SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]); + await this._run(`UPDATE layout_cells SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("layout_cells", "update", id); } - deleteLayoutCell(id: number): void { - this.db.prepare(`DELETE FROM layout_cells WHERE id = ?`).run(id); + async deleteLayoutCell(id: number): Promise { + await this._run(`DELETE FROM layout_cells WHERE id = ?`, [id]); void this.notify("layout_cells", "delete", id); } @@ -800,34 +808,34 @@ export class Repository { * its row bumped by `delta`. Same for axis="col". Used by the visual * builder when adding a cell to the top/left of an existing one. */ - shiftCellsForLayout( + async shiftCellsForLayout( layoutId: number, axis: "row" | "col", fromIndex: number, delta: number, - ): void { + ): Promise { if (delta === 0) return; const colName = axis === "row" ? `"row"` : "col"; - this.db - .prepare( - `UPDATE layout_cells - SET ${colName} = ${colName} + ? - WHERE layout_id = ? - AND ${colName} >= ?`, - ) - .run(delta, layoutId, fromIndex); + await this._run( + `UPDATE layout_cells + SET ${colName} = ${colName} + ? + WHERE layout_id = ? + AND ${colName} >= ?`, + [delta, layoutId, fromIndex], + ); void this.notify("layout_cells", "update", layoutId); } - listLayoutCells(layoutId: number): LayoutCell[] { - const rs = this.prep( + async listLayoutCells(layoutId: number): Promise { + const rs = await this._all( `SELECT * FROM layout_cells WHERE layout_id = ? ORDER BY "row", col`, - ).all(layoutId); + [layoutId], + ); return rs.map((r) => rowToLayoutCell(r as Record)); } - getLayoutCellById(id: number): LayoutCell | null { - const r = this.prep("SELECT * FROM layout_cells WHERE id = ?").get(id); + async getLayoutCellById(id: number): Promise { + const r = await this._get("SELECT * FROM layout_cells WHERE id = ?", [id]); return r ? rowToLayoutCell(r as Record) : null; } @@ -836,22 +844,21 @@ export class Repository { // =========================================================================== /** Bundle generation: layouts attached to a display via display_layouts. */ - layoutsForDisplayId(displayId: number): Layout[] { + async layoutsForDisplayId(displayId: number): Promise { return this.listLayoutsForDisplay(displayId); } - camerasForLayoutIds(layoutIds: number[]): Camera[] { + async camerasForLayoutIds(layoutIds: number[]): Promise { if (layoutIds.length === 0) return []; const placeholders = layoutIds.map(() => "?").join(","); - const rs = this.db - .prepare( - `SELECT DISTINCT c.* FROM cameras c - JOIN layout_cells lc ON lc.camera_id = c.id - WHERE lc.layout_id IN (${placeholders}) - AND c.enabled = 1 - ORDER BY c.name`, - ) - .all(...(layoutIds as never[])); + const rs = await this._all( + `SELECT DISTINCT c.* FROM cameras c + JOIN layout_cells lc ON lc.camera_id = c.id + WHERE lc.layout_id IN (${placeholders}) + AND c.enabled = 1 + ORDER BY c.name`, + layoutIds, + ); return rs.map((r) => rowToCamera(r as Record)); } @@ -859,22 +866,22 @@ export class Repository { // cameras // =========================================================================== - listCameras(): Camera[] { - const rs = this.prep("SELECT * FROM cameras ORDER BY name").all(); + async listCameras(): Promise { + const rs = await this._all("SELECT * FROM cameras ORDER BY name"); return rs.map((r) => rowToCamera(r as Record)); } - getCameraById(id: number): Camera | null { - const r = this.prep("SELECT * FROM cameras WHERE id = ?").get(id); + async getCameraById(id: number): Promise { + const r = await this._get("SELECT * FROM cameras WHERE id = ?", [id]); return r ? rowToCamera(r as Record) : null; } - getCameraByName(name: string): Camera | null { - const r = this.prep("SELECT * FROM cameras WHERE name = ?").get(name); + async getCameraByName(name: string): Promise { + const r = await this._get("SELECT * FROM cameras WHERE name = ?", [name]); return r ? rowToCamera(r as Record) : null; } - createCamera(input: { + async createCamera(input: { name: string; type: CameraType; rtsp_url?: string | null; @@ -884,40 +891,42 @@ export class Repository { onvif_password?: string | null; // already-encrypted ciphertext capabilities?: string[]; stream_policy?: StreamPolicy; - }): Camera { - const result = this.prep( + }): Promise { + const result = await this._run( `INSERT INTO cameras (name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password, capabilities, stream_policy) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - input.name, - input.type, - input.rtsp_url ?? null, - input.onvif_host ?? null, - input.onvif_port ?? null, - input.onvif_username ?? null, - input.onvif_password ?? null, - J(input.capabilities ?? []), - input.stream_policy ?? "auto", + [ + input.name, + input.type, + input.rtsp_url ?? null, + input.onvif_host ?? null, + input.onvif_port ?? null, + input.onvif_username ?? null, + input.onvif_password ?? null, + J(input.capabilities ?? []), + input.stream_policy ?? "auto", + ], ); const id = Number(result.lastInsertRowid); void this.notify("cameras", "create", id); - const c = this.getCameraById(id); + const c = await this.getCameraById(id); if (!c) throw new Error("camera vanished after insert"); // Mirror this camera as a reusable entity so it's pickable in cell editors. - this.ensureCameraEntity(c); + await this.ensureCameraEntity(c); return c; } - listCameraStreams(cameraId: number): CameraStream[] { - const rs = this.prep( + async listCameraStreams(cameraId: number): Promise { + const rs = await this._all( "SELECT * FROM camera_streams WHERE camera_id = ?", - ).all(cameraId); + [cameraId], + ); return rs.map((r) => rowToCameraStream(r as Record)); } - createCameraStream(input: { + async createCameraStream(input: { camera_id: number; role: StreamRole; name: string; @@ -929,33 +938,34 @@ export class Repository { framerate?: number | null; bitrate_kbps?: number | null; is_discovered?: boolean; - }): CameraStream { - const result = this.prep( + }): Promise { + const result = await this._run( `INSERT INTO camera_streams (camera_id, role, name, profile_token, rtsp_uri, width, height, encoding, framerate, bitrate_kbps, is_discovered) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - input.camera_id, - input.role, - input.name, - input.profile_token ?? null, - input.rtsp_uri, - input.width ?? null, - input.height ?? null, - input.encoding ?? null, - input.framerate ?? null, - input.bitrate_kbps ?? null, - B(Boolean(input.is_discovered)), + [ + input.camera_id, + input.role, + input.name, + input.profile_token ?? null, + input.rtsp_uri, + input.width ?? null, + input.height ?? null, + input.encoding ?? null, + input.framerate ?? null, + input.bitrate_kbps ?? null, + B(Boolean(input.is_discovered)), + ], ); const id = Number(result.lastInsertRowid); - const r = this.prep("SELECT * FROM camera_streams WHERE id = ?").get(id); + const r = await this._get("SELECT * FROM camera_streams WHERE id = ?", [id]); if (!r) throw new Error("camera_stream vanished after insert"); void this.notify("camera_streams", "create", id); return rowToCameraStream(r as Record); } - updateCameraStream(id: number, patch: Partial): void { + async updateCameraStream(id: number, patch: Partial): Promise { const sets: string[] = []; const vals: unknown[] = []; for (const [k, v] of Object.entries(patch)) { @@ -965,7 +975,7 @@ export class Repository { } if (sets.length === 0) return; vals.push(id); - this.db.prepare(`UPDATE camera_streams SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]); + await this._run(`UPDATE camera_streams SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("camera_streams", "update", id); } @@ -973,51 +983,54 @@ export class Repository { // labels (incl. join tables) // =========================================================================== - listLabels(): Label[] { - const rs = this.prep("SELECT * FROM labels ORDER BY name").all(); + async listLabels(): Promise { + const rs = await this._all("SELECT * FROM labels ORDER BY name"); return rs.map((r) => rowToLabel(r as Record)); } - getLabelByName(name: string): Label | null { - const r = this.prep("SELECT * FROM labels WHERE name = ?").get(name); + async getLabelByName(name: string): Promise