2026-05-09 23:09:13 +00:00
|
|
|
/**
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
* service-api-http — h3 listener for kiosk-facing REST API.
|
2026-05-09 23:09:13 +00:00
|
|
|
*
|
2026-05-10 01:12:07 +00:00
|
|
|
* Port 18081 behind Angie proxy. Handles pairing, bundle delivery,
|
|
|
|
|
* heartbeat, and event forwarding.
|
2026-05-09 23:09:13 +00:00
|
|
|
*/
|
|
|
|
|
import * as av from "@anyvali/js";
|
|
|
|
|
import {
|
|
|
|
|
BSBService,
|
|
|
|
|
type BSBServiceConstructor,
|
|
|
|
|
createConfigSchema,
|
|
|
|
|
createEventSchemas,
|
|
|
|
|
type Observable,
|
|
|
|
|
} from "@bsb/base";
|
2026-05-23 09:36:49 +00:00
|
|
|
import { H3, serve, readBody, getRequestHeader, getRouterParam, createError } from "h3";
|
2026-05-10 01:12:07 +00:00
|
|
|
import type { Server } from "srvx";
|
|
|
|
|
|
2026-05-24 01:22:49 +00:00
|
|
|
import type { DbConfig } from "../../shared/db/config.js";
|
2026-05-24 00:48:32 +00:00
|
|
|
import { initDb } from "../../shared/db/init.js";
|
|
|
|
|
import type { Repository } from "../../shared/db/repository.js";
|
2026-05-10 01:12:07 +00:00
|
|
|
import { initSecrets } from "../../shared/secrets.js";
|
|
|
|
|
import { createAuth } from "../../shared/auth.js";
|
|
|
|
|
import { initiatePairing, claimPairing } from "../../shared/pairing.js";
|
|
|
|
|
import { generateBundle } from "../../shared/bundle.js";
|
2026-05-10 20:49:59 +00:00
|
|
|
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
|
2026-05-13 18:56:42 +00:00
|
|
|
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
|
2026-05-20 04:19:46 +00:00
|
|
|
import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js";
|
2026-05-14 05:40:22 +00:00
|
|
|
import { createRateLimiter } from "../../shared/rate-limit.js";
|
2026-05-14 05:46:56 +00:00
|
|
|
import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js";
|
2026-05-14 05:28:20 +00:00
|
|
|
import { createHash } from "node:crypto";
|
2026-05-10 01:12:07 +00:00
|
|
|
import type { AuthApi } from "../../shared/auth.js";
|
|
|
|
|
import type { SecretsApi } from "../../shared/secrets.js";
|
2026-05-13 18:56:42 +00:00
|
|
|
import type { FirmwareChannel } from "../../shared/types.js";
|
2026-05-10 01:12:07 +00:00
|
|
|
|
|
|
|
|
// ---- Config -----------------------------------------------------------------
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
|
|
|
const ConfigSchema = av.object(
|
|
|
|
|
{
|
2026-05-24 01:22:49 +00:00
|
|
|
db: av.object(
|
|
|
|
|
{
|
|
|
|
|
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
|
|
|
|
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
2026-05-24 03:12:53 +00:00
|
|
|
url: av.string().default(""),
|
|
|
|
|
host: av.string().default("postgres"),
|
|
|
|
|
port: av.int().min(1).max(65535).default(5432),
|
|
|
|
|
database: av.string().default("betterframe"),
|
|
|
|
|
user: av.string().default("betterframe"),
|
|
|
|
|
password: av.string().default("betterframe"),
|
|
|
|
|
poolMax: av.int().min(1).max(1000).default(10),
|
2026-05-24 01:22:49 +00:00
|
|
|
},
|
|
|
|
|
{ unknownKeys: "strip" },
|
|
|
|
|
),
|
2026-05-11 07:51:00 +00:00
|
|
|
host: av.string().default("127.0.0.1"),
|
2026-05-09 23:09:13 +00:00
|
|
|
port: av.int().min(1).max(65535).default(18081),
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
codeTtlSeconds: av.int().min(60).max(3600).default(600),
|
2026-05-10 01:12:07 +00:00
|
|
|
// Secrets + auth config (shared with admin-http for now)
|
|
|
|
|
dataDir: av.string().minLength(1).default("/var/lib/betterframe"),
|
|
|
|
|
argon2Memory: av.int().min(8).default(65536),
|
|
|
|
|
argon2TimeCost: av.int().min(1).default(3),
|
|
|
|
|
argon2Parallelism: av.int().min(1).default(2),
|
|
|
|
|
cookieName: av.string().minLength(1).default("betterframe_session"),
|
|
|
|
|
sessionIdleSeconds: av.int().min(60).default(43200),
|
|
|
|
|
sessionMaxSeconds: av.int().min(3600).default(2592000),
|
|
|
|
|
loginLockoutThreshold: av.int().min(1).default(8),
|
|
|
|
|
loginLockoutSeconds: av.int().min(1).default(900),
|
|
|
|
|
totpIssuer: av.string().minLength(1).default("BetterFrame"),
|
2026-05-10 20:49:59 +00:00
|
|
|
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
|
2026-05-23 11:22:44 +00:00
|
|
|
/** MQTT broker URL (e.g. mqtt://broker:1883). Empty = disabled. */
|
|
|
|
|
mqttUrl: av.string().default(""),
|
|
|
|
|
mqttUsername: av.string().default(""),
|
|
|
|
|
mqttPassword: av.string().default(""),
|
|
|
|
|
mqttTopicPrefix: av.string().default("betterframe"),
|
2026-05-09 23:09:13 +00:00
|
|
|
},
|
|
|
|
|
{ unknownKeys: "strip" },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const Config = createConfigSchema(
|
|
|
|
|
{
|
|
|
|
|
name: "service-api-http",
|
|
|
|
|
description: "h3 HTTP server for kiosk-facing REST API.",
|
|
|
|
|
tags: ["service", "http", "api", "kiosk"],
|
|
|
|
|
},
|
|
|
|
|
ConfigSchema,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const EventSchemas = createEventSchemas({
|
|
|
|
|
emitEvents: {},
|
|
|
|
|
onEvents: {},
|
|
|
|
|
emitReturnableEvents: {},
|
|
|
|
|
onReturnableEvents: {},
|
|
|
|
|
emitBroadcast: {},
|
|
|
|
|
onBroadcast: {},
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
// ---- Plugin -----------------------------------------------------------------
|
|
|
|
|
|
2026-05-09 23:09:13 +00:00
|
|
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
|
|
|
|
static override Config = Config;
|
|
|
|
|
static override EventSchemas = EventSchemas;
|
|
|
|
|
|
|
|
|
|
initBeforePlugins?: string[];
|
2026-05-24 00:48:32 +00:00
|
|
|
initAfterPlugins?: string[];
|
2026-05-09 23:09:13 +00:00
|
|
|
runBeforePlugins?: string[];
|
|
|
|
|
runAfterPlugins?: string[];
|
|
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
private server?: Server;
|
2026-05-24 00:48:32 +00:00
|
|
|
private dbClose?: () => Promise<void>;
|
2026-05-10 01:12:07 +00:00
|
|
|
|
2026-05-09 23:09:13 +00:00
|
|
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
|
|
|
|
super(cfg);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
async init(obs: Observable): Promise<void> {
|
2026-05-23 11:22:44 +00:00
|
|
|
const dataDir = this.config.dataDir;
|
|
|
|
|
const noderedUrl = this.config.noderedUrl;
|
|
|
|
|
const cookieName = this.config.cookieName;
|
|
|
|
|
const totpIssuer = this.config.totpIssuer;
|
2026-05-14 05:33:10 +00:00
|
|
|
|
2026-05-24 00:48:32 +00:00
|
|
|
const dbResult = await initDb(
|
|
|
|
|
this.config.db as DbConfig,
|
|
|
|
|
{
|
|
|
|
|
info: (m) => obs.log.info(m as any, {}),
|
|
|
|
|
warn: (m) => obs.log.warn(m as any, {}),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
const repo = dbResult.repo;
|
|
|
|
|
this.dbClose = dbResult.close;
|
|
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
const secrets = initSecrets(
|
2026-05-14 05:33:10 +00:00
|
|
|
{ dataDir },
|
2026-05-10 01:12:07 +00:00
|
|
|
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
|
|
|
|
|
);
|
|
|
|
|
const auth = createAuth(repo, secrets, {
|
|
|
|
|
sessionIdleSeconds: this.config.sessionIdleSeconds,
|
|
|
|
|
sessionMaxSeconds: this.config.sessionMaxSeconds,
|
|
|
|
|
loginLockoutThreshold: this.config.loginLockoutThreshold,
|
|
|
|
|
loginLockoutSeconds: this.config.loginLockoutSeconds,
|
|
|
|
|
argon2Memory: this.config.argon2Memory,
|
|
|
|
|
argon2TimeCost: this.config.argon2TimeCost,
|
|
|
|
|
argon2Parallelism: this.config.argon2Parallelism,
|
2026-05-14 05:33:10 +00:00
|
|
|
totpIssuer,
|
|
|
|
|
cookieName,
|
2026-05-10 01:12:07 +00:00
|
|
|
});
|
|
|
|
|
const codeTtl = this.config.codeTtlSeconds;
|
2026-05-10 20:49:59 +00:00
|
|
|
const nodered = initNoderedBridge(
|
2026-05-14 05:33:10 +00:00
|
|
|
{ baseUrl: noderedUrl },
|
2026-05-10 20:49:59 +00:00
|
|
|
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
|
|
|
|
|
);
|
2026-05-13 18:56:42 +00:00
|
|
|
const firmware = initFirmware(
|
2026-05-14 05:33:10 +00:00
|
|
|
{ dataDir },
|
2026-05-13 18:56:42 +00:00
|
|
|
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
|
|
|
|
|
);
|
2026-05-20 04:19:46 +00:00
|
|
|
const osUpdates = initOsUpdates({ dataDir });
|
2026-05-23 11:22:44 +00:00
|
|
|
const mqtt = initMqttBridge(
|
|
|
|
|
{
|
|
|
|
|
url: this.config.mqttUrl,
|
|
|
|
|
username: this.config.mqttUsername || undefined,
|
|
|
|
|
password: this.config.mqttPassword || undefined,
|
|
|
|
|
topicPrefix: this.config.mqttTopicPrefix,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
info: (m) => obs.log.info(m as any, {}),
|
|
|
|
|
warn: (m) => obs.log.warn(m as any, {}),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-10 01:12:07 +00:00
|
|
|
|
2026-05-25 23:02:26 +00:00
|
|
|
const self = this;
|
2026-05-25 22:55:17 +00:00
|
|
|
const app = new H3({
|
2026-05-25 23:02:26 +00:00
|
|
|
onRequest: (event) => {
|
|
|
|
|
const method = event.req.method ?? "GET";
|
|
|
|
|
const path = event.req.url ?? "/";
|
2026-05-25 23:20:17 +00:00
|
|
|
const reqObs = self.createTrace(`${method} ${path}`, {
|
2026-05-25 23:02:26 +00:00
|
|
|
"http.method": method,
|
|
|
|
|
"http.url": path,
|
|
|
|
|
});
|
2026-05-25 23:20:17 +00:00
|
|
|
reqObs.log.info("{method} {path}", { method, path });
|
|
|
|
|
event.context.obs = reqObs;
|
2026-05-25 23:02:26 +00:00
|
|
|
},
|
2026-05-25 22:55:17 +00:00
|
|
|
onError: (error, event) => {
|
2026-05-25 23:02:26 +00:00
|
|
|
const reqObs = event.context.obs ?? obs;
|
2026-05-25 22:55:17 +00:00
|
|
|
const status = error.status ?? 500;
|
|
|
|
|
const path = event.req.url ?? "unknown";
|
|
|
|
|
if (status >= 500) {
|
2026-05-25 23:02:26 +00:00
|
|
|
reqObs.log.error("HTTP {status} {path}: {err}", {
|
2026-05-25 22:55:17 +00:00
|
|
|
status,
|
|
|
|
|
path,
|
|
|
|
|
err: error.message ?? String(error),
|
|
|
|
|
});
|
2026-05-25 23:02:26 +00:00
|
|
|
} else if (status >= 400) {
|
|
|
|
|
reqObs.log.warn("HTTP {status} {path}: {err}", {
|
|
|
|
|
status,
|
|
|
|
|
path,
|
|
|
|
|
err: error.message ?? String(error),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onResponse: (_response, event) => {
|
|
|
|
|
if (event.context.obs) {
|
|
|
|
|
event.context.obs.end();
|
2026-05-25 22:55:17 +00:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-05-10 01:12:07 +00:00
|
|
|
|
2026-05-11 06:55:42 +00:00
|
|
|
app.get("/api/kiosk/_check", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) return new Response(null, { status: 401 });
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) return new Response(null, { status: 401 });
|
|
|
|
|
return new Response(null, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: { "x-betterframe-kiosk-id": String(kiosk.id) },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get("/api/key/_check", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) return new Response(null, { status: 401 });
|
|
|
|
|
const key = await auth.verifyApiKey(token, getRequestHeader(event, "x-real-ip") ?? null);
|
|
|
|
|
if (!key) return new Response(null, { status: 401 });
|
|
|
|
|
return new Response(null, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
"x-betterframe-api-key": key.key_prefix,
|
|
|
|
|
"x-betterframe-scopes": key.scopes.join(","),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
registerPairingRoutes(app, repo, auth, secrets, codeTtl);
|
2026-05-20 04:19:46 +00:00
|
|
|
registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, osUpdates, mqtt);
|
2026-05-10 01:12:07 +00:00
|
|
|
|
|
|
|
|
this.server = serve(app, {
|
|
|
|
|
port: this.config.port,
|
|
|
|
|
hostname: this.config.host,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
obs.log.info("api-http listening on {host}:{port}", {
|
|
|
|
|
host: this.config.host,
|
|
|
|
|
port: this.config.port,
|
|
|
|
|
});
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async run(_obs: Observable): Promise<void> {}
|
2026-05-10 01:12:07 +00:00
|
|
|
|
|
|
|
|
async dispose(): Promise<void> {
|
|
|
|
|
if (this.server) {
|
|
|
|
|
await this.server.close();
|
|
|
|
|
}
|
2026-05-24 00:48:32 +00:00
|
|
|
await this.dbClose?.();
|
2026-05-10 01:12:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Helpers ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function extractBearerToken(event: any): string | null {
|
|
|
|
|
const hdr = getRequestHeader(event, "authorization");
|
2026-05-22 23:23:56 +00:00
|
|
|
if (hdr?.startsWith("Bearer ")) return hdr.slice(7);
|
|
|
|
|
// Fallback: check betterframe_kiosk_key cookie (WebView sub-resource
|
|
|
|
|
// requests don't carry the Authorization header — only cookies persist).
|
|
|
|
|
const cookieHeader = getRequestHeader(event, "cookie") ?? "";
|
|
|
|
|
for (const pair of cookieHeader.split(";")) {
|
|
|
|
|
const [k, ...rest] = pair.trim().split("=");
|
|
|
|
|
if (k?.trim() === "betterframe_kiosk_key") {
|
|
|
|
|
const val = rest.join("=").trim();
|
|
|
|
|
if (val) return val;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
2026-05-10 01:12:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
async function getClusterKey(repo: Repository, secrets: SecretsApi): Promise<string | undefined> {
|
|
|
|
|
const enc = await repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
2026-05-10 01:12:07 +00:00
|
|
|
if (!enc) return undefined;
|
|
|
|
|
return secrets.decryptString(enc, "cluster");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Pairing routes ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function registerPairingRoutes(
|
|
|
|
|
app: H3,
|
|
|
|
|
repo: Repository,
|
|
|
|
|
auth: AuthApi,
|
|
|
|
|
secrets: SecretsApi,
|
|
|
|
|
codeTtl: number,
|
|
|
|
|
): void {
|
2026-05-14 05:49:57 +00:00
|
|
|
// Constructed in-function so the BSB schema extractor (which evaluates the
|
|
|
|
|
// module statically) doesn't see a top-level createRateLimiter call.
|
|
|
|
|
const pairingGuard = createRateLimiter({ windowMs: 60_000, max: 20 });
|
|
|
|
|
const claimGuard = createRateLimiter({ windowMs: 60_000, max: 60 });
|
2026-05-10 01:12:07 +00:00
|
|
|
// Kiosk initiates pairing — no auth required
|
|
|
|
|
app.post("/api/pair/initiate", async (event) => {
|
2026-05-14 05:40:22 +00:00
|
|
|
const ip = getRequestHeader(event, "x-real-ip")
|
|
|
|
|
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
|
|
|
|
|
?? "anon";
|
|
|
|
|
if (!pairingGuard.take(`pair:${ip}`)) {
|
|
|
|
|
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
const body = await readBody<{
|
|
|
|
|
proposed_name?: string;
|
|
|
|
|
hardware_model?: string;
|
|
|
|
|
capabilities?: string[];
|
2026-05-20 01:18:11 +00:00
|
|
|
managed_image?: boolean;
|
2026-05-10 01:12:07 +00:00
|
|
|
}>(event);
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
const result = await initiatePairing(repo, {
|
2026-05-10 01:12:07 +00:00
|
|
|
proposedName: body?.proposed_name ?? null,
|
|
|
|
|
hardwareModel: body?.hardware_model ?? null,
|
|
|
|
|
capabilities: body?.capabilities ?? [],
|
2026-05-20 01:18:11 +00:00
|
|
|
managedImage: body?.managed_image === true,
|
2026-05-10 01:12:07 +00:00
|
|
|
codeTtlSeconds: codeTtl,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { code: result.code, expires_at: result.expiresAt };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Kiosk polls for claim result — no auth required
|
|
|
|
|
app.post("/api/pair/claim", async (event) => {
|
2026-05-14 05:40:22 +00:00
|
|
|
const ip = getRequestHeader(event, "x-real-ip")
|
|
|
|
|
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
|
|
|
|
|
?? "anon";
|
|
|
|
|
if (!claimGuard.take(`claim:${ip}`)) {
|
|
|
|
|
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
const body = await readBody<{ code?: string }>(event);
|
|
|
|
|
const code = (body?.code ?? "").trim().toUpperCase();
|
|
|
|
|
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
|
|
|
|
|
|
2026-05-25 23:32:28 +00:00
|
|
|
const reqObs = event.context.obs!;
|
|
|
|
|
const result = await claimPairing(repo, code, reqObs);
|
2026-05-10 01:12:07 +00:00
|
|
|
if (result.status === "pending") {
|
|
|
|
|
return new Response(JSON.stringify({ status: "pending" }), {
|
|
|
|
|
status: 202,
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 23:32:28 +00:00
|
|
|
reqObs.log.info("pair/claim success for code {code} kiosk {kioskId}", {
|
|
|
|
|
code,
|
|
|
|
|
kioskId: String(result.kioskId),
|
|
|
|
|
});
|
2026-05-10 01:12:07 +00:00
|
|
|
return {
|
|
|
|
|
status: "claimed",
|
|
|
|
|
kiosk_id: result.kioskId,
|
|
|
|
|
kiosk_name: result.kioskName,
|
|
|
|
|
kiosk_key: result.kioskKey,
|
|
|
|
|
cluster_key: result.clusterKey,
|
|
|
|
|
bundle_url: result.bundleUrl,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Kiosk routes (require Bearer kiosk key) --------------------------------
|
|
|
|
|
|
2026-05-22 23:39:22 +00:00
|
|
|
// Event deduplication cache: key → last-seen timestamp (ms).
|
|
|
|
|
const eventDedupCache = new Map<string, number>();
|
|
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
function registerKioskRoutes(
|
|
|
|
|
app: H3,
|
|
|
|
|
repo: Repository,
|
|
|
|
|
auth: AuthApi,
|
|
|
|
|
secrets: SecretsApi,
|
2026-05-10 20:49:59 +00:00
|
|
|
nodered: NoderedBridge,
|
2026-05-13 18:56:42 +00:00
|
|
|
firmware: FirmwareApi,
|
2026-05-20 04:19:46 +00:00
|
|
|
osUpdates: OsUpdateApi,
|
2026-05-14 05:46:56 +00:00
|
|
|
mqtt: MqttBridge,
|
2026-05-10 01:12:07 +00:00
|
|
|
): void {
|
|
|
|
|
// Bundle delivery
|
|
|
|
|
app.get("/api/kiosk/bundle", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
const clusterKey = await getClusterKey(repo, secrets);
|
|
|
|
|
const bundle = await generateBundle(repo, secrets, kiosk.id, clusterKey);
|
2026-05-10 01:12:07 +00:00
|
|
|
if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" });
|
|
|
|
|
|
2026-05-22 23:31:38 +00:00
|
|
|
// Content-hash ETag: kiosk sends If-None-Match on subsequent fetches.
|
|
|
|
|
// If bundle hasn't changed → 304 Not Modified (no body, saves bandwidth).
|
|
|
|
|
const json = JSON.stringify(bundle);
|
|
|
|
|
const hash = createHash("sha256").update(json).digest("hex").slice(0, 16);
|
|
|
|
|
const etag = `"${hash}"`;
|
|
|
|
|
const ifNoneMatch = getRequestHeader(event, "if-none-match");
|
|
|
|
|
if (ifNoneMatch === etag) {
|
|
|
|
|
return new Response(null, { status: 304 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Response(json, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"etag": etag,
|
|
|
|
|
"x-bf-bundle-version": bundle.version,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-05-10 01:12:07 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Heartbeat
|
|
|
|
|
app.post("/api/kiosk/heartbeat", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
|
|
|
|
const body = await readBody<{
|
|
|
|
|
bundle_version?: string;
|
|
|
|
|
kiosk_app_version?: string;
|
|
|
|
|
os_version?: string;
|
2026-05-21 07:10:30 +00:00
|
|
|
displays?: Array<{
|
|
|
|
|
index?: number;
|
|
|
|
|
name: string;
|
|
|
|
|
width_px: number;
|
|
|
|
|
height_px: number;
|
|
|
|
|
power_state?: "awake" | "standby" | "unknown";
|
|
|
|
|
}>;
|
2026-05-11 09:47:07 +00:00
|
|
|
cpu_temp_c?: number | null;
|
2026-05-21 00:03:05 +00:00
|
|
|
cpu_load_percent?: number | null;
|
2026-05-11 09:47:07 +00:00
|
|
|
fan_rpm?: number | null;
|
|
|
|
|
fan_pwm?: number | null;
|
2026-05-21 00:03:05 +00:00
|
|
|
memory_total_mb?: number | null;
|
|
|
|
|
memory_used_mb?: number | null;
|
|
|
|
|
disk_total_mb?: number | null;
|
|
|
|
|
disk_free_mb?: number | null;
|
|
|
|
|
disk_used_percent?: number | null;
|
2026-05-14 05:24:21 +00:00
|
|
|
local_key?: string | null;
|
|
|
|
|
local_port?: number | null;
|
2026-05-21 07:23:50 +00:00
|
|
|
reported_hostname?: string | null;
|
|
|
|
|
network_interfaces?: Array<Record<string, unknown>>;
|
2026-05-20 01:18:11 +00:00
|
|
|
// Managed-image kiosk echoes back the version it last applied, and the
|
|
|
|
|
// last apply error (if any). Server uses these to decide whether to
|
|
|
|
|
// include pending_config in the response.
|
|
|
|
|
managed_config_applied_version?: number;
|
|
|
|
|
managed_config_error?: string | null;
|
2026-05-10 01:12:07 +00:00
|
|
|
}>(event);
|
|
|
|
|
|
2026-05-14 05:24:21 +00:00
|
|
|
// Capture the kiosk's LAN-side IP from the heartbeat connection so admin
|
|
|
|
|
// can render a copy-paste URL even when the kiosk has no DNS name.
|
|
|
|
|
const remoteIp = getRequestHeader(event, "x-real-ip")
|
|
|
|
|
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
|
|
|
|
|
?? null;
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
await repo.touchKiosk(kiosk.id, {
|
2026-05-10 01:12:07 +00:00
|
|
|
bundle_version: body?.bundle_version ?? null,
|
|
|
|
|
kiosk_app_version: body?.kiosk_app_version ?? null,
|
|
|
|
|
os_version: body?.os_version ?? null,
|
2026-05-11 09:47:07 +00:00
|
|
|
cpu_temp_c: body?.cpu_temp_c ?? null,
|
2026-05-21 00:03:05 +00:00
|
|
|
cpu_load_percent: body?.cpu_load_percent ?? null,
|
2026-05-11 09:47:07 +00:00
|
|
|
fan_rpm: body?.fan_rpm ?? null,
|
|
|
|
|
fan_pwm: body?.fan_pwm ?? null,
|
2026-05-21 00:03:05 +00:00
|
|
|
memory_total_mb: body?.memory_total_mb ?? null,
|
|
|
|
|
memory_used_mb: body?.memory_used_mb ?? null,
|
|
|
|
|
disk_total_mb: body?.disk_total_mb ?? null,
|
|
|
|
|
disk_free_mb: body?.disk_free_mb ?? null,
|
|
|
|
|
disk_used_percent: body?.disk_used_percent ?? null,
|
2026-05-14 05:24:21 +00:00
|
|
|
local_key: body?.local_key ?? null,
|
|
|
|
|
local_port: body?.local_port ?? null,
|
|
|
|
|
local_last_ip: remoteIp,
|
2026-05-21 07:23:50 +00:00
|
|
|
reported_hostname: body?.reported_hostname ?? null,
|
|
|
|
|
network_interfaces_json: Array.isArray(body?.network_interfaces)
|
|
|
|
|
? JSON.stringify(body.network_interfaces)
|
|
|
|
|
: null,
|
2026-05-10 01:12:07 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-20 01:18:11 +00:00
|
|
|
// Managed-config echo: kiosk reports the version it has successfully
|
|
|
|
|
// 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.
|
2026-05-23 00:07:44 +00:00
|
|
|
const kioskFull = await repo.getKioskById(kiosk.id);
|
2026-05-20 01:18:11 +00:00
|
|
|
if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") {
|
|
|
|
|
const patch: Record<string, unknown> = {
|
|
|
|
|
managed_config_applied_version: body.managed_config_applied_version,
|
|
|
|
|
managed_config_applied_at: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
if (body.managed_config_error !== undefined) {
|
|
|
|
|
patch["managed_config_error"] = body.managed_config_error ?? null;
|
|
|
|
|
}
|
2026-05-23 00:07:44 +00:00
|
|
|
await repo.updateKiosk(kiosk.id, patch as any);
|
2026-05-20 01:18:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:46:56 +00:00
|
|
|
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
|
|
|
|
|
mqtt.publishTelemetry(kiosk.id, {
|
|
|
|
|
kiosk_app_version: body?.kiosk_app_version,
|
|
|
|
|
bundle_version: body?.bundle_version,
|
|
|
|
|
cpu_temp_c: body?.cpu_temp_c,
|
2026-05-21 00:03:05 +00:00
|
|
|
cpu_load_percent: body?.cpu_load_percent,
|
2026-05-14 05:46:56 +00:00
|
|
|
fan_rpm: body?.fan_rpm,
|
|
|
|
|
fan_pwm: body?.fan_pwm,
|
2026-05-21 00:03:05 +00:00
|
|
|
memory_total_mb: body?.memory_total_mb,
|
|
|
|
|
memory_used_mb: body?.memory_used_mb,
|
|
|
|
|
disk_total_mb: body?.disk_total_mb,
|
|
|
|
|
disk_free_mb: body?.disk_free_mb,
|
|
|
|
|
disk_used_percent: body?.disk_used_percent,
|
2026-05-14 05:46:56 +00:00
|
|
|
ip: remoteIp,
|
2026-05-21 07:23:50 +00:00
|
|
|
reported_hostname: body?.reported_hostname,
|
|
|
|
|
network_interfaces: body?.network_interfaces,
|
2026-05-14 05:46:56 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-10 20:39:53 +00:00
|
|
|
// Sync displays reported by the kiosk
|
|
|
|
|
if (Array.isArray(body?.displays)) {
|
2026-05-23 00:07:44 +00:00
|
|
|
const existing = await repo.listDisplaysForKiosk(kiosk.id);
|
2026-05-13 01:57:12 +00:00
|
|
|
const seenDisplayIds = new Set<number>();
|
|
|
|
|
for (const [position, reported] of body.displays.entries()) {
|
|
|
|
|
const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0
|
|
|
|
|
? reported.index!
|
|
|
|
|
: position;
|
|
|
|
|
const match = existing.find((d) => d.name.endsWith(reported.name))
|
|
|
|
|
?? existing.find((d) => d.index === reportedIndex);
|
2026-05-10 20:39:53 +00:00
|
|
|
if (match) {
|
2026-05-13 01:57:12 +00:00
|
|
|
seenDisplayIds.add(match.id);
|
2026-05-21 07:10:30 +00:00
|
|
|
const powerState = reported.power_state === "awake" || reported.power_state === "standby"
|
|
|
|
|
? reported.power_state
|
|
|
|
|
: reported.power_state === "unknown"
|
|
|
|
|
? "unknown"
|
|
|
|
|
: null;
|
2026-05-13 01:57:12 +00:00
|
|
|
if (
|
|
|
|
|
match.name !== reported.name
|
|
|
|
|
|| match.index !== reportedIndex
|
|
|
|
|
|| match.width_px !== reported.width_px
|
|
|
|
|
|| match.height_px !== reported.height_px
|
2026-05-21 07:10:30 +00:00
|
|
|
|| (powerState != null && match.actual_power_state !== powerState)
|
2026-05-13 01:57:12 +00:00
|
|
|
) {
|
2026-05-23 00:07:44 +00:00
|
|
|
await repo.updateDisplay(match.id, {
|
2026-05-13 01:57:12 +00:00
|
|
|
name: reported.name,
|
|
|
|
|
index: reportedIndex,
|
2026-05-10 20:39:53 +00:00
|
|
|
width_px: reported.width_px,
|
|
|
|
|
height_px: reported.height_px,
|
2026-05-21 07:10:30 +00:00
|
|
|
...(powerState != null ? {
|
|
|
|
|
actual_power_state: powerState,
|
|
|
|
|
actual_power_state_at: new Date().toISOString(),
|
|
|
|
|
} : {}),
|
2026-05-10 20:39:53 +00:00
|
|
|
} as any);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// New display — create it
|
2026-05-23 00:07:44 +00:00
|
|
|
const created = await repo.createDisplayForKiosk(kiosk.id, {
|
2026-05-10 20:39:53 +00:00
|
|
|
name: reported.name,
|
2026-05-13 01:57:12 +00:00
|
|
|
index: reportedIndex,
|
2026-05-10 20:39:53 +00:00
|
|
|
width_px: reported.width_px,
|
|
|
|
|
height_px: reported.height_px,
|
|
|
|
|
});
|
2026-05-21 07:10:30 +00:00
|
|
|
const powerState = reported.power_state === "awake" || reported.power_state === "standby"
|
|
|
|
|
? reported.power_state
|
|
|
|
|
: reported.power_state === "unknown"
|
|
|
|
|
? "unknown"
|
|
|
|
|
: null;
|
|
|
|
|
if (powerState != null) {
|
2026-05-23 00:07:44 +00:00
|
|
|
await repo.updateDisplay(created.id, {
|
2026-05-21 07:10:30 +00:00
|
|
|
actual_power_state: powerState,
|
|
|
|
|
actual_power_state_at: new Date().toISOString(),
|
|
|
|
|
} as any);
|
|
|
|
|
}
|
2026-05-13 01:57:12 +00:00
|
|
|
seenDisplayIds.add(created.id);
|
2026-05-10 20:39:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 01:57:12 +00:00
|
|
|
for (const display of existing) {
|
|
|
|
|
if (seenDisplayIds.has(display.id) || !display.is_enabled) continue;
|
|
|
|
|
if (!display.name.endsWith(" HDMI-0")) continue;
|
2026-05-23 00:07:44 +00:00
|
|
|
if ((await repo.listLayoutsForDisplay(display.id)).length > 0) continue;
|
|
|
|
|
await repo.updateDisplay(display.id, { is_enabled: false } as any);
|
2026-05-13 01:57:12 +00:00
|
|
|
}
|
2026-05-10 20:39:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 01:18:11 +00:00
|
|
|
// Re-read kiosk so we see the freshly-persisted applied_version above when
|
|
|
|
|
// computing whether the server still has a newer config to deliver.
|
2026-05-23 00:07:44 +00:00
|
|
|
const fresh = await repo.getKioskById(kiosk.id);
|
2026-05-20 01:18:11 +00:00
|
|
|
let pendingConfig: { version: number; config: unknown } | undefined;
|
|
|
|
|
if (
|
|
|
|
|
fresh?.managed_image
|
|
|
|
|
&& fresh.managed_config_version > fresh.managed_config_applied_version
|
|
|
|
|
&& fresh.managed_config_json
|
|
|
|
|
) {
|
|
|
|
|
try {
|
|
|
|
|
pendingConfig = {
|
|
|
|
|
version: fresh.managed_config_version,
|
|
|
|
|
config: JSON.parse(fresh.managed_config_json),
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
// Corrupt JSON — leave pendingConfig undefined; admin UI will show
|
|
|
|
|
// the error. Don't break heartbeat.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
now: new Date().toISOString(),
|
2026-05-22 18:51:18 +00:00
|
|
|
firmware_channel: fresh?.firmware_channel ?? "stable",
|
|
|
|
|
os_update_channel: fresh?.os_update_channel ?? "stable",
|
2026-05-20 01:18:11 +00:00
|
|
|
...(pendingConfig ? { pending_config: pendingConfig } : {}),
|
|
|
|
|
};
|
2026-05-10 01:12:07 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Event forwarding
|
|
|
|
|
app.post("/api/kiosk/event", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
|
|
|
|
const body = await readBody<{
|
|
|
|
|
topic: string;
|
|
|
|
|
source_type?: string;
|
|
|
|
|
camera_id?: number;
|
|
|
|
|
property_op?: string;
|
|
|
|
|
payload?: Record<string, unknown>;
|
|
|
|
|
}>(event);
|
|
|
|
|
|
|
|
|
|
if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" });
|
|
|
|
|
|
2026-05-22 23:39:22 +00:00
|
|
|
// Dedup: Hikvision cameras send duplicate ONVIF events within ~1s.
|
|
|
|
|
// Key = kiosk_id:camera_id:topic:source_keys_hash. Window = 2s.
|
|
|
|
|
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(body.payload?.["source"] ?? "")}`;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (eventDedupCache.has(dedupKey)) {
|
|
|
|
|
const lastSeen = eventDedupCache.get(dedupKey)!;
|
|
|
|
|
if (now - lastSeen < 2000) {
|
|
|
|
|
return { ok: true, event_id: null, deduplicated: true };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
eventDedupCache.set(dedupKey, now);
|
|
|
|
|
// Trim cache periodically (prevent unbounded growth).
|
|
|
|
|
if (eventDedupCache.size > 10_000) {
|
|
|
|
|
const cutoff = now - 5000;
|
|
|
|
|
for (const [k, v] of eventDedupCache) {
|
|
|
|
|
if (v < cutoff) eventDedupCache.delete(k);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
const eventId = await repo.insertEvent({
|
2026-05-10 01:12:07 +00:00
|
|
|
source_kiosk_id: kiosk.id,
|
|
|
|
|
source_camera_id: body.camera_id ?? null,
|
|
|
|
|
source_type: (body.source_type as any) ?? "system",
|
|
|
|
|
topic: body.topic,
|
|
|
|
|
property_op: body.property_op ?? null,
|
|
|
|
|
payload: body.payload ?? {},
|
|
|
|
|
forwarded_to_nodered: false,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 08:19:39 +00:00
|
|
|
// Side-effect: persist active layout per display so the admin UI can
|
|
|
|
|
// surface "currently showing X" without having to query event_log.
|
|
|
|
|
if (body.topic === "layout.changed") {
|
|
|
|
|
const displayId = Number(body.payload?.["display_id"]);
|
|
|
|
|
const layoutId = Number(body.payload?.["layout_id"]);
|
|
|
|
|
if (Number.isInteger(displayId) && Number.isInteger(layoutId)) {
|
|
|
|
|
try {
|
2026-05-23 00:07:44 +00:00
|
|
|
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
|
2026-05-21 08:19:39 +00:00
|
|
|
} catch {
|
|
|
|
|
// Display might not exist; layout.changed is best-effort telemetry.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:03:51 +00:00
|
|
|
// Best-effort forward to Node-RED. Topics that have a dedicated trigger
|
|
|
|
|
// node (bf-trigger-layout-changed etc.) expect a FLAT payload matching
|
|
|
|
|
// what the admin-side emit produces — splat body.payload up to the top
|
|
|
|
|
// level and add kiosk_id. Generic camera events keep the wrapped shape
|
|
|
|
|
// the bf-kiosk-camera-event trigger consumes.
|
|
|
|
|
const flatTopics = new Set([
|
|
|
|
|
"layout.changed",
|
|
|
|
|
"kiosk.changed",
|
|
|
|
|
"kiosk.status",
|
|
|
|
|
"display.power.changed",
|
|
|
|
|
"camera.changed",
|
|
|
|
|
]);
|
2026-05-23 00:07:44 +00:00
|
|
|
const markForwarded = () => { repo.markEventForwarded(eventId); };
|
2026-05-13 11:03:51 +00:00
|
|
|
if (flatTopics.has(body.topic)) {
|
2026-05-21 09:34:29 +00:00
|
|
|
const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}), source: "kiosk" };
|
|
|
|
|
nodered.forward(body.topic, out, markForwarded);
|
2026-05-14 05:46:56 +00:00
|
|
|
mqtt.publishEvent(kiosk.id, body.topic, out);
|
2026-05-13 11:03:51 +00:00
|
|
|
} else {
|
2026-05-14 05:46:56 +00:00
|
|
|
const out = {
|
2026-05-13 11:03:51 +00:00
|
|
|
event_id: eventId,
|
|
|
|
|
kiosk_id: kiosk.id,
|
|
|
|
|
camera_id: body.camera_id ?? null,
|
|
|
|
|
source_type: body.source_type ?? "system",
|
|
|
|
|
property_op: body.property_op ?? null,
|
feat(nodered): motion + ANPR + generic ONVIF event trigger nodes
Three new Node-RED trigger nodes in BetterFrame Triggers palette:
bf-trigger-motion (red) — fires on MotionAlarm, CellMotionDetector,
VideoAnalytics/Motion, FieldDetector topics. Outputs msg.active
(true/false) for motion start/stop. Camera ID filter optional.
bf-trigger-anpr (blue) — fires on LicensePlateRecognition, Plate,
ANPR, LPR, NumberPlate topics. Extracts msg.plate (string) and
msg.confidence (number) from vendor-specific payload fields
(Hikvision PlateNumber, Dahua plateNumber, etc.). Camera ID filter.
bf-trigger-event (green) — generic catch-all. Topic substring filter
+ camera ID filter. Outputs msg.source + msg.data as key-value objects
parsed from ONVIF SimpleItems. Use for line crossing, intrusion,
digital input, tamper, audio detection, or any unknown topic.
Server side: ONVIF events (source_type=onvif) now additionally forward
to the fixed onvif.event route so all three nodes receive events without
needing per-topic Node-RED route registration.
2026-05-23 00:17:05 +00:00
|
|
|
topic: body.topic,
|
2026-05-13 11:03:51 +00:00
|
|
|
payload: body.payload ?? {},
|
|
|
|
|
timestamp: new Date().toISOString(),
|
2026-05-21 09:34:29 +00:00
|
|
|
source: "kiosk",
|
2026-05-14 05:46:56 +00:00
|
|
|
};
|
2026-05-21 09:34:29 +00:00
|
|
|
nodered.forward(body.topic, out, markForwarded);
|
2026-05-14 05:46:56 +00:00
|
|
|
mqtt.publishEvent(kiosk.id, body.topic, out);
|
feat(nodered): motion + ANPR + generic ONVIF event trigger nodes
Three new Node-RED trigger nodes in BetterFrame Triggers palette:
bf-trigger-motion (red) — fires on MotionAlarm, CellMotionDetector,
VideoAnalytics/Motion, FieldDetector topics. Outputs msg.active
(true/false) for motion start/stop. Camera ID filter optional.
bf-trigger-anpr (blue) — fires on LicensePlateRecognition, Plate,
ANPR, LPR, NumberPlate topics. Extracts msg.plate (string) and
msg.confidence (number) from vendor-specific payload fields
(Hikvision PlateNumber, Dahua plateNumber, etc.). Camera ID filter.
bf-trigger-event (green) — generic catch-all. Topic substring filter
+ camera ID filter. Outputs msg.source + msg.data as key-value objects
parsed from ONVIF SimpleItems. Use for line crossing, intrusion,
digital input, tamper, audio detection, or any unknown topic.
Server side: ONVIF events (source_type=onvif) now additionally forward
to the fixed onvif.event route so all three nodes receive events without
needing per-topic Node-RED route registration.
2026-05-23 00:17:05 +00:00
|
|
|
|
|
|
|
|
// ONVIF events: also forward to the fixed onvif.event route so the
|
|
|
|
|
// bf-trigger-motion / bf-trigger-anpr / bf-trigger-event nodes
|
|
|
|
|
// receive them without needing per-topic route registration.
|
|
|
|
|
if (body.source_type === "onvif") {
|
|
|
|
|
nodered.forward("onvif.event", out);
|
|
|
|
|
}
|
2026-05-13 11:03:51 +00:00
|
|
|
}
|
2026-05-10 20:49:59 +00:00
|
|
|
|
2026-05-10 01:12:07 +00:00
|
|
|
return { ok: true, event_id: eventId };
|
|
|
|
|
});
|
2026-05-13 18:56:42 +00:00
|
|
|
|
2026-05-21 09:34:29 +00:00
|
|
|
// ---- Kiosk log ingestion (batch) -----------------------------------------
|
|
|
|
|
app.post("/api/kiosk/logs", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
|
|
|
|
const body = await readBody<{
|
|
|
|
|
entries?: Array<{ level?: string; message?: string; context?: Record<string, unknown>; logged_at?: string }>;
|
|
|
|
|
}>(event);
|
|
|
|
|
|
|
|
|
|
const raw = body?.entries;
|
|
|
|
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: "entries array required" });
|
|
|
|
|
}
|
|
|
|
|
if (raw.length > 100) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: "max 100 entries per batch" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validLevels = new Set(["debug", "info", "warn", "error"]);
|
|
|
|
|
const entries = raw
|
|
|
|
|
.filter((e) => e.message && typeof e.message === "string")
|
|
|
|
|
.map((e) => ({
|
|
|
|
|
level: (validLevels.has(e.level ?? "") ? e.level! : "info") as "debug" | "info" | "warn" | "error",
|
|
|
|
|
message: e.message!,
|
|
|
|
|
context: e.context ?? {},
|
|
|
|
|
logged_at: e.logged_at,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
const count = await repo.insertKioskLogs(kiosk.id, entries);
|
2026-05-21 09:34:29 +00:00
|
|
|
return { ok: true, count };
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 18:56:42 +00:00
|
|
|
// ---- Firmware: kiosk checks for + downloads its assigned release -------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Kiosk polls this on heartbeat (or after a `firmware_check` WS push).
|
|
|
|
|
* Decision tree:
|
|
|
|
|
* 1. If kiosk.firmware_target_version is set → look up that version on the
|
|
|
|
|
* kiosk's arch; offer if it exists and isn't yanked.
|
|
|
|
|
* 2. Otherwise pick latest non-yanked release on the kiosk's channel + arch.
|
|
|
|
|
* 3. If chosen.version === current_version (reported via heartbeat) →
|
|
|
|
|
* "up_to_date".
|
|
|
|
|
*
|
|
|
|
|
* `arch` is supplied by the kiosk because the server has no other way to
|
|
|
|
|
* know which build target the kiosk was built against.
|
|
|
|
|
*/
|
|
|
|
|
app.get("/api/kiosk/firmware/check", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
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" });
|
2026-05-23 00:07:44 +00:00
|
|
|
const kiosk = await repo.getKioskById(verified.id);
|
2026-05-13 18:56:42 +00:00
|
|
|
if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });
|
|
|
|
|
|
|
|
|
|
const url = new URL(event.req.url);
|
|
|
|
|
const arch = url.searchParams.get("arch")?.trim();
|
|
|
|
|
if (!arch) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: "arch query param required" });
|
|
|
|
|
}
|
|
|
|
|
const currentVersion = url.searchParams.get("current")?.trim() ?? kiosk.kiosk_app_version ?? "";
|
|
|
|
|
|
|
|
|
|
let release = null;
|
2026-05-14 05:28:20 +00:00
|
|
|
// Explicit per-kiosk pin wins over all rollout / channel selection.
|
2026-05-13 18:56:42 +00:00
|
|
|
if (kiosk.firmware_target_version) {
|
2026-05-23 00:07:44 +00:00
|
|
|
release = await repo.getFirmwareReleaseByVersionArch(kiosk.firmware_target_version, arch);
|
2026-05-13 18:56:42 +00:00
|
|
|
if (release?.yanked_at) release = null;
|
|
|
|
|
}
|
2026-05-14 05:28:20 +00:00
|
|
|
// Active rollouts: most-recent matching, with bucket eligibility.
|
|
|
|
|
if (!release) {
|
2026-05-23 00:07:44 +00:00
|
|
|
const rollouts = await repo.listActiveRolloutsForKiosk(kiosk.id);
|
2026-05-14 05:28:20 +00:00
|
|
|
for (const rollout of rollouts) {
|
|
|
|
|
if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue;
|
2026-05-23 00:07:44 +00:00
|
|
|
const r = await repo.getFirmwareRelease(rollout.release_id);
|
2026-05-14 05:28:20 +00:00
|
|
|
if (!r || r.yanked_at) continue;
|
|
|
|
|
if (r.arch !== arch) continue;
|
|
|
|
|
release = r;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Channel-latest fallback.
|
2026-05-13 18:56:42 +00:00
|
|
|
if (!release) {
|
|
|
|
|
const channel = (kiosk.firmware_channel ?? "stable") as FirmwareChannel;
|
2026-05-23 00:07:44 +00:00
|
|
|
release = await repo.getLatestFirmwareRelease(channel, arch);
|
2026-05-13 18:56:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!release || release.version === currentVersion) {
|
|
|
|
|
return { up_to_date: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
up_to_date: false,
|
|
|
|
|
update: {
|
|
|
|
|
release_id: release.id,
|
|
|
|
|
version: release.version,
|
|
|
|
|
channel: release.channel,
|
|
|
|
|
sha256: release.sha256,
|
|
|
|
|
signature: release.signature,
|
|
|
|
|
size_bytes: release.size_bytes,
|
|
|
|
|
download_url: `/api/kiosk/firmware/download/${release.id}`,
|
|
|
|
|
public_key_pem: firmware.publicKeyPem(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stream the signed binary. Bearer kiosk-key auth — internal access only,
|
|
|
|
|
* Angie will not pass this externally because /api/kiosk/* is in the
|
|
|
|
|
* kiosk-key location block.
|
|
|
|
|
*/
|
|
|
|
|
app.get("/api/kiosk/firmware/download/:id", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
|
|
|
|
const id = (event.context as any).params?.id as string | undefined
|
|
|
|
|
?? new URL(event.req.url).pathname.split("/").pop();
|
|
|
|
|
if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" });
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
const release = await repo.getFirmwareRelease(id);
|
2026-05-13 18:56:42 +00:00
|
|
|
if (!release || release.yanked_at) {
|
|
|
|
|
throw createError({ statusCode: 404, statusMessage: "release not found" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const buf = await firmware.readBlob(release.artifact_path, release.sha256);
|
|
|
|
|
return new Response(buf, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/octet-stream",
|
|
|
|
|
"content-length": String(buf.length),
|
|
|
|
|
"x-bf-sha256": release.sha256,
|
|
|
|
|
"x-bf-signature": release.signature,
|
|
|
|
|
"x-bf-version": release.version,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Kiosk reports the outcome of an update attempt. On success it should
|
|
|
|
|
* also be sending its new kiosk_app_version on heartbeat. On failure
|
|
|
|
|
* the error string is surfaced on the admin kiosk page.
|
|
|
|
|
*/
|
|
|
|
|
app.post("/api/kiosk/firmware/applied", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
|
|
|
|
const body = await readBody<{ version: string; error?: string }>(event);
|
|
|
|
|
if (!body?.version) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: "version required" });
|
|
|
|
|
}
|
2026-05-23 00:07:44 +00:00
|
|
|
await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
|
|
|
|
|
await repo.insertEvent({
|
2026-05-21 07:23:50 +00:00
|
|
|
source_kiosk_id: kiosk.id,
|
|
|
|
|
source_camera_id: null,
|
|
|
|
|
source_type: "system",
|
|
|
|
|
topic: "kiosk.log",
|
|
|
|
|
property_op: null,
|
|
|
|
|
payload: {
|
|
|
|
|
level: body.error ? "error" : "info",
|
|
|
|
|
message: body.error ? "firmware update failed" : "firmware update applied",
|
|
|
|
|
context: { version: body.version, error: body.error ?? null },
|
|
|
|
|
},
|
|
|
|
|
forwarded_to_nodered: false,
|
|
|
|
|
});
|
2026-05-13 18:56:42 +00:00
|
|
|
return { ok: true };
|
|
|
|
|
});
|
2026-05-20 04:19:46 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Full OS OTA check. `compatibility` is the RAUC compatible string baked
|
|
|
|
|
* into the image, e.g. betterframe-rpi5-aarch64. The kiosk-side installer
|
|
|
|
|
* will hand the downloaded bundle to `rauc install`.
|
|
|
|
|
*/
|
|
|
|
|
app.get("/api/kiosk/os/check", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
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" });
|
2026-05-23 00:07:44 +00:00
|
|
|
const kiosk = await repo.getKioskById(verified.id);
|
2026-05-20 04:19:46 +00:00
|
|
|
if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });
|
|
|
|
|
|
|
|
|
|
const url = new URL(event.req.url);
|
|
|
|
|
const compatibility = url.searchParams.get("compatibility")?.trim();
|
|
|
|
|
if (!compatibility) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: "compatibility query param required" });
|
|
|
|
|
}
|
|
|
|
|
const currentVersion = url.searchParams.get("current")?.trim() ?? kiosk.os_version ?? "";
|
|
|
|
|
|
|
|
|
|
let release = null;
|
|
|
|
|
if (kiosk.os_update_target_version) {
|
2026-05-23 00:07:44 +00:00
|
|
|
release = await repo.getOsUpdateReleaseByVersionCompatibility(kiosk.os_update_target_version, compatibility);
|
2026-05-20 04:19:46 +00:00
|
|
|
if (release?.yanked_at) release = null;
|
|
|
|
|
}
|
|
|
|
|
if (!release) {
|
2026-05-23 00:07:44 +00:00
|
|
|
const rollouts = await repo.listActiveOsUpdateRolloutsForKiosk(kiosk.id);
|
2026-05-20 04:19:46 +00:00
|
|
|
for (const rollout of rollouts) {
|
|
|
|
|
if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue;
|
2026-05-23 00:07:44 +00:00
|
|
|
const r = await repo.getOsUpdateRelease(rollout.release_id);
|
2026-05-20 04:19:46 +00:00
|
|
|
if (!r || r.yanked_at) continue;
|
|
|
|
|
if (r.compatibility !== compatibility) continue;
|
|
|
|
|
release = r;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!release) {
|
|
|
|
|
const channel = (kiosk.os_update_channel ?? "stable") as FirmwareChannel;
|
2026-05-23 00:07:44 +00:00
|
|
|
release = await repo.getLatestOsUpdateRelease(channel, compatibility);
|
2026-05-20 04:19:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!release || release.version === currentVersion) {
|
|
|
|
|
return { up_to_date: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
up_to_date: false,
|
|
|
|
|
update: {
|
|
|
|
|
release_id: release.id,
|
|
|
|
|
version: release.version,
|
|
|
|
|
channel: release.channel,
|
|
|
|
|
compatibility: release.compatibility,
|
|
|
|
|
sha256: release.sha256,
|
|
|
|
|
size_bytes: release.size_bytes,
|
|
|
|
|
bundle_format: release.bundle_format,
|
|
|
|
|
download_url: `/api/kiosk/os/download/${release.id}`,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get("/api/kiosk/os/download/:id", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
|
|
|
|
const id = (event.context as any).params?.id as string | undefined
|
|
|
|
|
?? new URL(event.req.url).pathname.split("/").pop();
|
|
|
|
|
if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" });
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
const release = await repo.getOsUpdateRelease(id);
|
2026-05-20 04:19:46 +00:00
|
|
|
if (!release || release.yanked_at) {
|
|
|
|
|
throw createError({ statusCode: 404, statusMessage: "release not found" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bundle = await osUpdates.streamBundle(release.artifact_path);
|
2026-05-22 23:44:34 +00:00
|
|
|
const totalSize = bundle.size;
|
|
|
|
|
|
|
|
|
|
// Support Range requests for resumable downloads.
|
|
|
|
|
const rangeHeader = getRequestHeader(event, "range");
|
|
|
|
|
if (rangeHeader) {
|
|
|
|
|
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
|
|
|
|
if (match) {
|
|
|
|
|
const start = Number(match[1]);
|
|
|
|
|
const end = match[2] ? Number(match[2]) : totalSize - 1;
|
|
|
|
|
if (start >= totalSize) {
|
|
|
|
|
return new Response(null, { status: 416, headers: { "content-range": `bytes */${totalSize}` } });
|
|
|
|
|
}
|
|
|
|
|
const rangeBundle = await osUpdates.streamBundle(release.artifact_path, start, end);
|
|
|
|
|
return new Response(rangeBundle.body, {
|
|
|
|
|
status: 206,
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/vnd.rauc",
|
|
|
|
|
"content-length": String(end - start + 1),
|
|
|
|
|
"content-range": `bytes ${start}-${end}/${totalSize}`,
|
|
|
|
|
"accept-ranges": "bytes",
|
|
|
|
|
"x-bf-sha256": release.sha256,
|
|
|
|
|
"x-bf-version": release.version,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 04:19:46 +00:00
|
|
|
return new Response(bundle.body, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/vnd.rauc",
|
2026-05-22 23:44:34 +00:00
|
|
|
"content-length": String(totalSize),
|
|
|
|
|
"accept-ranges": "bytes",
|
2026-05-20 04:19:46 +00:00
|
|
|
"x-bf-sha256": release.sha256,
|
|
|
|
|
"x-bf-version": release.version,
|
|
|
|
|
"x-bf-compatibility": release.compatibility,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post("/api/kiosk/os/applied", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
|
|
|
|
const body = await readBody<{ version: string; error?: string }>(event);
|
|
|
|
|
if (!body?.version) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: "version required" });
|
|
|
|
|
}
|
2026-05-23 00:07:44 +00:00
|
|
|
await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
|
|
|
|
|
await repo.insertEvent({
|
2026-05-21 07:23:50 +00:00
|
|
|
source_kiosk_id: kiosk.id,
|
|
|
|
|
source_camera_id: null,
|
|
|
|
|
source_type: "system",
|
|
|
|
|
topic: "kiosk.log",
|
|
|
|
|
property_op: null,
|
|
|
|
|
payload: {
|
|
|
|
|
level: body.error ? "error" : "info",
|
|
|
|
|
message: body.error ? "os update failed" : "os update applied",
|
|
|
|
|
context: { version: body.version, error: body.error ?? null },
|
|
|
|
|
},
|
|
|
|
|
forwarded_to_nodered: false,
|
|
|
|
|
});
|
2026-05-20 04:19:46 +00:00
|
|
|
return { ok: true };
|
|
|
|
|
});
|
2026-05-23 09:36:49 +00:00
|
|
|
|
|
|
|
|
app.get("/api/kiosk/cameras/:id/stream", async (event) => {
|
|
|
|
|
const token = extractBearerToken(event);
|
|
|
|
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
|
|
|
|
const kiosk = await auth.verifyKioskKey(token);
|
|
|
|
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
|
|
|
|
|
|
|
|
|
const cameraId = Number(getRouterParam(event, "id"));
|
|
|
|
|
const camera = await repo.getCameraById(cameraId);
|
|
|
|
|
if (!camera || camera.type !== "cloud" || !camera.cloud_account_id || !camera.cloud_vendor_camera_id) {
|
|
|
|
|
throw createError({ statusCode: 404, statusMessage: "Cloud camera not found" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const account = await repo.getCloudAccount(camera.cloud_account_id);
|
|
|
|
|
if (!account) throw createError({ statusCode: 404, statusMessage: "Cloud account not found" });
|
|
|
|
|
|
|
|
|
|
const { getProvider: gp } = await import("../../shared/cloud-cameras/index.js");
|
|
|
|
|
const provider = gp(account.vendor as any);
|
|
|
|
|
if (!provider) throw createError({ statusCode: 500, statusMessage: "Unknown vendor" });
|
|
|
|
|
|
|
|
|
|
let creds: Record<string, string>;
|
|
|
|
|
try {
|
|
|
|
|
creds = JSON.parse(secrets.decryptString(account.credentials_encrypted, "cloud-creds"));
|
|
|
|
|
} catch {
|
|
|
|
|
throw createError({ statusCode: 500, statusMessage: "Credential decrypt failed" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = await provider.getStreamUrl(creds, camera.cloud_vendor_camera_id);
|
|
|
|
|
if (!url) throw createError({ statusCode: 503, statusMessage: "Stream URL unavailable" });
|
|
|
|
|
|
|
|
|
|
if (url !== camera.cloud_stream_url) {
|
|
|
|
|
await repo.updateCamera(camera.id, { cloud_stream_url: url } as any);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { url, stream_type: camera.cloud_stream_type ?? "hls" };
|
|
|
|
|
});
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
2026-05-14 05:28:20 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Deterministic bucket assignment for gradual rollouts. Same (kioskId,
|
|
|
|
|
* rolloutId) always lands in the same bucket, so a 50% rollout consistently
|
|
|
|
|
* targets the same half of the fleet across re-checks. Switch from 50%→100%
|
|
|
|
|
* gracefully adds the previously-excluded half rather than reshuffling.
|
|
|
|
|
*/
|
|
|
|
|
function isKioskInRolloutBucket(kioskId: number, rolloutId: string, percentage: number): boolean {
|
|
|
|
|
if (percentage >= 100) return true;
|
|
|
|
|
if (percentage <= 0) return false;
|
|
|
|
|
const h = createHash("sha256")
|
|
|
|
|
.update(`${rolloutId}:${String(kioskId)}`)
|
|
|
|
|
.digest();
|
|
|
|
|
const bucket = h.readUInt32BE(0) % 100;
|
|
|
|
|
return bucket < percentage;
|
|
|
|
|
}
|