mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat: implement kiosk API, pairing flow, and bundle generation
- service-api-http: h3 on :18081 with pairing, bundle, heartbeat, and event endpoints - shared/pairing.ts: 8-char code state machine (initiate → claim → confirm) - shared/bundle.ts: label-scoped bundle with cluster-encrypted ONVIF passwords - Admin kiosks page: POST /admin/kiosks/pair wired to confirmPairing - sec-config: api-http bound to 0.0.0.0 with auth config
This commit is contained in:
parent
3f358e5e5e
commit
94e316a207
6 changed files with 559 additions and 12 deletions
|
|
@ -50,9 +50,13 @@ default:
|
||||||
plugin: service-api-http
|
plugin: service-api-http
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
host: 127.0.0.1
|
host: 0.0.0.0
|
||||||
port: 18081
|
port: 18081
|
||||||
codeTtlSeconds: 600 # 10m pairing code TTL
|
codeTtlSeconds: 600 # 10m pairing code TTL
|
||||||
|
dataDir: /var/lib/betterframe
|
||||||
|
argon2Memory: 65536
|
||||||
|
argon2TimeCost: 3
|
||||||
|
argon2Parallelism: 2
|
||||||
|
|
||||||
# ----- Live kiosk WebSocket channel -----
|
# ----- Live kiosk WebSocket channel -----
|
||||||
service-coordinator-ws:
|
service-coordinator-ws:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { type H3, readBody } from "h3";
|
import { type H3, readBody } from "h3";
|
||||||
import { htmlPage } from "./html-response.js";
|
import { htmlPage } from "./html-response.js";
|
||||||
import type { AdminDeps } from "./index.js";
|
import type { AdminDeps } from "./index.js";
|
||||||
|
import { confirmPairing } from "../../shared/pairing.js";
|
||||||
import {
|
import {
|
||||||
OverviewPage,
|
OverviewPage,
|
||||||
CamerasPage,
|
CamerasPage,
|
||||||
|
|
@ -135,6 +136,34 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return htmlPage(KiosksPage({ user: user.username, kiosks, pendingCodes: pending }));
|
return htmlPage(KiosksPage({ user: user.username, kiosks, pendingCodes: pending }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/admin/kiosks/pair", async (event) => {
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const code = (body?.["code"] ?? "").trim().toUpperCase();
|
||||||
|
const nameOverride = (body?.["name_override"] ?? "").trim() || undefined;
|
||||||
|
const labelsStr = (body?.["initial_labels"] ?? "").trim();
|
||||||
|
const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await confirmPairing(deps.repo, deps.auth, deps.secrets, {
|
||||||
|
code,
|
||||||
|
nameOverride,
|
||||||
|
initialLabels,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const kiosks = deps.repo.listKiosks();
|
||||||
|
const pending = deps.repo.listPendingPairingCodes();
|
||||||
|
return htmlPage(KiosksPage({
|
||||||
|
user: user.username,
|
||||||
|
kiosks,
|
||||||
|
pendingCodes: pending,
|
||||||
|
error: (err as Error).message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Simple list pages (templates, layouts, displays, labels) -------------
|
// ---- Simple list pages (templates, layouts, displays, labels) -------------
|
||||||
|
|
||||||
app.get("/admin/templates", (event) => {
|
app.get("/admin/templates", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* service-api-http — h3 listener for kiosk-facing REST API.
|
* service-api-http — h3 listener for kiosk-facing REST API.
|
||||||
*
|
*
|
||||||
* Serves pairing, bundle, and kiosk management endpoints.
|
* Port 18081 behind Angie proxy. Handles pairing, bundle delivery,
|
||||||
* Port 18081 behind Angie proxy.
|
* heartbeat, and event forwarding.
|
||||||
*/
|
*/
|
||||||
import * as av from "@anyvali/js";
|
import * as av from "@anyvali/js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,12 +12,36 @@ import {
|
||||||
createEventSchemas,
|
createEventSchemas,
|
||||||
type Observable,
|
type Observable,
|
||||||
} from "@bsb/base";
|
} from "@bsb/base";
|
||||||
|
import { H3, serve, readBody, getRequestHeader, createError } from "h3";
|
||||||
|
import type { Server } from "srvx";
|
||||||
|
|
||||||
|
import { getRepo } from "../../shared/plugin-registry.js";
|
||||||
|
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";
|
||||||
|
import type { Repository } from "../service-store/repository.js";
|
||||||
|
import type { AuthApi } from "../../shared/auth.js";
|
||||||
|
import type { SecretsApi } from "../../shared/secrets.js";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
const ConfigSchema = av.object(
|
const ConfigSchema = av.object(
|
||||||
{
|
{
|
||||||
host: av.string().default("127.0.0.1"),
|
host: av.string().default("0.0.0.0"),
|
||||||
port: av.int().min(1).max(65535).default(18081),
|
port: av.int().min(1).max(65535).default(18081),
|
||||||
codeTtlSeconds: av.int().min(60).max(3600).default(600),
|
codeTtlSeconds: av.int().min(60).max(3600).default(600),
|
||||||
|
// 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"),
|
||||||
},
|
},
|
||||||
{ unknownKeys: "strip" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -40,6 +64,8 @@ export const EventSchemas = createEventSchemas({
|
||||||
onBroadcast: {},
|
onBroadcast: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
static override Config = Config;
|
static override Config = Config;
|
||||||
static override EventSchemas = EventSchemas;
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
@ -49,14 +75,196 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
runBeforePlugins?: string[];
|
runBeforePlugins?: string[];
|
||||||
runAfterPlugins?: string[];
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
private server?: Server;
|
||||||
|
|
||||||
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
super(cfg);
|
super(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(_obs: Observable): Promise<void> {
|
async init(obs: Observable): Promise<void> {
|
||||||
// TODO: create h3 app, mount kiosk + pairing routes
|
const repo = getRepo();
|
||||||
|
const secrets = initSecrets(
|
||||||
|
{ dataDir: this.config.dataDir },
|
||||||
|
{ 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,
|
||||||
|
totpIssuer: this.config.totpIssuer,
|
||||||
|
cookieName: this.config.cookieName,
|
||||||
|
});
|
||||||
|
const codeTtl = this.config.codeTtlSeconds;
|
||||||
|
|
||||||
|
const app = new H3();
|
||||||
|
|
||||||
|
registerPairingRoutes(app, repo, auth, secrets, codeTtl);
|
||||||
|
registerKioskRoutes(app, repo, auth, secrets);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(_obs: Observable): Promise<void> {}
|
async run(_obs: Observable): Promise<void> {}
|
||||||
async dispose(): Promise<void> {}
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
await this.server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function extractBearerToken(event: any): string | null {
|
||||||
|
const hdr = getRequestHeader(event, "authorization");
|
||||||
|
if (!hdr?.startsWith("Bearer ")) return null;
|
||||||
|
return hdr.slice(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClusterKey(repo: Repository, secrets: SecretsApi): string | undefined {
|
||||||
|
const enc = repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
||||||
|
if (!enc) return undefined;
|
||||||
|
return secrets.decryptString(enc, "cluster");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Pairing routes ---------------------------------------------------------
|
||||||
|
|
||||||
|
function registerPairingRoutes(
|
||||||
|
app: H3,
|
||||||
|
repo: Repository,
|
||||||
|
auth: AuthApi,
|
||||||
|
secrets: SecretsApi,
|
||||||
|
codeTtl: number,
|
||||||
|
): void {
|
||||||
|
// Kiosk initiates pairing — no auth required
|
||||||
|
app.post("/api/pair/initiate", async (event) => {
|
||||||
|
const body = await readBody<{
|
||||||
|
proposed_name?: string;
|
||||||
|
hardware_model?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
}>(event);
|
||||||
|
|
||||||
|
const result = initiatePairing(repo, {
|
||||||
|
proposedName: body?.proposed_name ?? null,
|
||||||
|
hardwareModel: body?.hardware_model ?? null,
|
||||||
|
capabilities: body?.capabilities ?? [],
|
||||||
|
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) => {
|
||||||
|
const body = await readBody<{ code?: string }>(event);
|
||||||
|
const code = (body?.code ?? "").trim().toUpperCase();
|
||||||
|
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
|
||||||
|
|
||||||
|
const result = claimPairing(repo, code);
|
||||||
|
if (result.status === "pending") {
|
||||||
|
return new Response(JSON.stringify({ status: "pending" }), {
|
||||||
|
status: 202,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) --------------------------------
|
||||||
|
|
||||||
|
function registerKioskRoutes(
|
||||||
|
app: H3,
|
||||||
|
repo: Repository,
|
||||||
|
auth: AuthApi,
|
||||||
|
secrets: SecretsApi,
|
||||||
|
): 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" });
|
||||||
|
|
||||||
|
const clusterKey = getClusterKey(repo, secrets);
|
||||||
|
const bundle = generateBundle(repo, secrets, kiosk.id, clusterKey);
|
||||||
|
if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" });
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}>(event);
|
||||||
|
|
||||||
|
repo.touchKiosk(kiosk.id, {
|
||||||
|
bundle_version: body?.bundle_version ?? null,
|
||||||
|
kiosk_app_version: body?.kiosk_app_version ?? null,
|
||||||
|
os_version: body?.os_version ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, now: new Date().toISOString() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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" });
|
||||||
|
|
||||||
|
const eventId = repo.insertEvent({
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, event_id: eventId };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,158 @@
|
||||||
/**
|
/**
|
||||||
* Label-scoped bundle generation — shared module stub.
|
* Label-scoped bundle generation — shared module.
|
||||||
* TODO: implement from old-python reference.
|
*
|
||||||
|
* Queries cameras/layouts/templates for a kiosk's label set,
|
||||||
|
* encrypts ONVIF passwords with cluster key, returns versioned bundle.
|
||||||
*/
|
*/
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { Repository } from "../plugins/service-store/repository.js";
|
||||||
|
import type { SecretsApi } from "./secrets.js";
|
||||||
|
|
||||||
|
export interface BundleCamera {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
rtsp_url: string | null;
|
||||||
|
onvif_host: string | null;
|
||||||
|
onvif_port: number | null;
|
||||||
|
onvif_username: string | null;
|
||||||
|
onvif_password_encrypted: string | null;
|
||||||
|
streams: Array<{
|
||||||
|
id: number;
|
||||||
|
role: string;
|
||||||
|
name: string;
|
||||||
|
rtsp_uri: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
encoding: string | null;
|
||||||
|
framerate: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleLayout {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
template_id: number | null;
|
||||||
|
display_id: number | null;
|
||||||
|
priority: string;
|
||||||
|
cooling_timeout_seconds: number | null;
|
||||||
|
preload_camera_ids: number[];
|
||||||
|
is_default: boolean;
|
||||||
|
resets_idle_timer: boolean;
|
||||||
|
cells: Array<{
|
||||||
|
region_name: string;
|
||||||
|
content_type: string;
|
||||||
|
camera_id: number | null;
|
||||||
|
stream_selector: string | null;
|
||||||
|
web_url: string | null;
|
||||||
|
html_content: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KioskBundle {
|
||||||
|
kiosk_id: number;
|
||||||
|
kiosk_name: string;
|
||||||
|
labels: string[];
|
||||||
|
operate_labels: string[];
|
||||||
|
cameras: BundleCamera[];
|
||||||
|
layouts: BundleLayout[];
|
||||||
|
templates: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
regions: unknown;
|
||||||
|
grid_cols: number;
|
||||||
|
grid_rows: number;
|
||||||
|
}>;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBundle(
|
||||||
|
repo: Repository,
|
||||||
|
secrets: SecretsApi,
|
||||||
|
kioskId: number,
|
||||||
|
clusterKey: string | undefined,
|
||||||
|
): KioskBundle | null {
|
||||||
|
const kiosk = repo.getKioskById(kioskId);
|
||||||
|
if (!kiosk) return null;
|
||||||
|
|
||||||
|
const scope = repo.bundleScope(kioskId);
|
||||||
|
const cameras = repo.camerasForLabelIds(scope.labelIds);
|
||||||
|
const layouts = repo.layoutsForLabelIds(scope.labelIds);
|
||||||
|
|
||||||
|
const bundleCameras: BundleCamera[] = cameras.map((cam) => {
|
||||||
|
const streams = repo.listCameraStreams(cam.id);
|
||||||
|
let onvifPwEncrypted: string | null = null;
|
||||||
|
if (cam.onvif_password && clusterKey) {
|
||||||
|
onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, clusterKey);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: cam.id,
|
||||||
|
name: cam.name,
|
||||||
|
type: cam.type,
|
||||||
|
rtsp_url: cam.rtsp_url,
|
||||||
|
onvif_host: cam.onvif_host,
|
||||||
|
onvif_port: cam.onvif_port,
|
||||||
|
onvif_username: cam.onvif_username,
|
||||||
|
onvif_password_encrypted: onvifPwEncrypted,
|
||||||
|
streams: streams.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
role: s.role,
|
||||||
|
name: s.name,
|
||||||
|
rtsp_uri: s.rtsp_uri,
|
||||||
|
width: s.width,
|
||||||
|
height: s.height,
|
||||||
|
encoding: s.encoding,
|
||||||
|
framerate: s.framerate,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateIds = [...new Set(layouts.map((l) => l.template_id).filter((id): id is number => id !== null))];
|
||||||
|
const templates = templateIds.length > 0 ? repo.layoutTemplates(templateIds) : [];
|
||||||
|
|
||||||
|
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
|
||||||
|
const cells = repo.layoutCells(l.id);
|
||||||
|
return {
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
template_id: l.template_id,
|
||||||
|
display_id: l.display_id,
|
||||||
|
priority: l.priority,
|
||||||
|
cooling_timeout_seconds: l.cooling_timeout_seconds,
|
||||||
|
preload_camera_ids: l.preload_camera_ids,
|
||||||
|
is_default: l.is_default,
|
||||||
|
resets_idle_timer: l.resets_idle_timer,
|
||||||
|
cells: cells.map((c) => ({
|
||||||
|
region_name: c.region_name,
|
||||||
|
content_type: c.content_type,
|
||||||
|
camera_id: c.camera_id,
|
||||||
|
stream_selector: c.stream_selector,
|
||||||
|
web_url: c.web_url,
|
||||||
|
html_content: c.html_content,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const bundle: KioskBundle = {
|
||||||
|
kiosk_id: kioskId,
|
||||||
|
kiosk_name: kiosk.name,
|
||||||
|
labels: scope.labelNames,
|
||||||
|
operate_labels: scope.operateLabelNames,
|
||||||
|
cameras: bundleCameras,
|
||||||
|
layouts: bundleLayouts,
|
||||||
|
templates: templates.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
regions: t.regions,
|
||||||
|
grid_cols: t.grid_cols,
|
||||||
|
grid_rows: t.grid_rows,
|
||||||
|
})),
|
||||||
|
version: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
bundle.version = createHash("sha256")
|
||||||
|
.update(JSON.stringify(bundle))
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,155 @@
|
||||||
/**
|
/**
|
||||||
* Pairing state machine — shared module stub.
|
* Pairing state machine — shared module.
|
||||||
* TODO: implement initiate/claim/poll from old-python reference.
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Kiosk calls initiate → gets 8-char code + expiry
|
||||||
|
* 2. Kiosk polls claim → 202 until admin confirms, then 200 + credentials
|
||||||
|
* 3. Admin enters code in UI → confirmPairing creates kiosk + kiosk_key
|
||||||
*/
|
*/
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import type { Repository } from "../plugins/service-store/repository.js";
|
||||||
|
import type { AuthApi } from "./auth.js";
|
||||||
|
import type { SecretsApi } from "./secrets.js";
|
||||||
|
import type { PairingCode } from "./types.js";
|
||||||
|
|
||||||
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I
|
||||||
|
const CODE_LENGTH = 8;
|
||||||
|
|
||||||
|
function generateCode(): string {
|
||||||
|
const buf = randomBytes(CODE_LENGTH);
|
||||||
|
let code = "";
|
||||||
|
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||||
|
code += CODE_ALPHABET[buf[i]! % CODE_ALPHABET.length];
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingInitiateInput {
|
||||||
|
proposedName: string | null;
|
||||||
|
hardwareModel: string | null;
|
||||||
|
capabilities: string[];
|
||||||
|
codeTtlSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingInitiateResult {
|
||||||
|
code: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initiatePairing(
|
||||||
|
repo: Repository,
|
||||||
|
input: PairingInitiateInput,
|
||||||
|
): PairingInitiateResult {
|
||||||
|
let code: string;
|
||||||
|
let attempts = 0;
|
||||||
|
do {
|
||||||
|
code = generateCode();
|
||||||
|
attempts++;
|
||||||
|
if (attempts > 20) throw new Error("failed to generate unique pairing code");
|
||||||
|
} while (repo.getPairingCode(code) !== null);
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + input.codeTtlSeconds * 1000).toISOString();
|
||||||
|
|
||||||
|
repo.createPairingCode({
|
||||||
|
code,
|
||||||
|
kiosk_proposed_name: input.proposedName,
|
||||||
|
kiosk_hardware_model: input.hardwareModel,
|
||||||
|
kiosk_capabilities: input.capabilities,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
extras: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { code, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingClaimResult {
|
||||||
|
status: "pending" | "claimed";
|
||||||
|
kioskId?: number;
|
||||||
|
kioskName?: string;
|
||||||
|
kioskKey?: string;
|
||||||
|
clusterKey?: string;
|
||||||
|
bundleUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function claimPairing(
|
||||||
|
repo: Repository,
|
||||||
|
code: string,
|
||||||
|
): PairingClaimResult {
|
||||||
|
const pc = repo.getPairingCode(code);
|
||||||
|
if (!pc) return { status: "pending" };
|
||||||
|
if (new Date(pc.expires_at) < new Date()) return { status: "pending" };
|
||||||
|
if (!pc.consumed_at) return { status: "pending" };
|
||||||
|
|
||||||
|
const extras = pc.extras as Record<string, unknown>;
|
||||||
|
const kioskKey = extras["kiosk_key_plaintext"] as string | undefined;
|
||||||
|
|
||||||
|
if (!kioskKey || !pc.consumed_by_kiosk_id) return { status: "pending" };
|
||||||
|
|
||||||
|
const kiosk = repo.getKioskById(pc.consumed_by_kiosk_id);
|
||||||
|
const clusterKey = extras["cluster_key"] as string | undefined;
|
||||||
|
|
||||||
|
// Wipe plaintext key from extras after first claim
|
||||||
|
repo.updatePairingCodeExtras(code, { ...extras, kiosk_key_plaintext: undefined, cluster_key: undefined });
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "claimed",
|
||||||
|
kioskId: pc.consumed_by_kiosk_id,
|
||||||
|
kioskName: kiosk?.name ?? pc.kiosk_proposed_name ?? "kiosk",
|
||||||
|
kioskKey,
|
||||||
|
clusterKey,
|
||||||
|
bundleUrl: "/api/kiosk/bundle",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingConfirmInput {
|
||||||
|
code: string;
|
||||||
|
nameOverride?: string;
|
||||||
|
initialLabels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmPairing(
|
||||||
|
repo: Repository,
|
||||||
|
auth: AuthApi,
|
||||||
|
secrets: SecretsApi,
|
||||||
|
input: PairingConfirmInput,
|
||||||
|
): Promise<{ kioskId: number; kioskName: string }> {
|
||||||
|
const pc = repo.getPairingCode(input.code);
|
||||||
|
if (!pc) throw new Error("pairing code not found");
|
||||||
|
if (pc.consumed_at) throw new Error("pairing code already used");
|
||||||
|
if (new Date(pc.expires_at) < new Date()) throw new Error("pairing code expired");
|
||||||
|
|
||||||
|
const kioskName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`;
|
||||||
|
const kioskKeyPlaintext = `bf-${randomBytes(24).toString("base64url")}`;
|
||||||
|
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
||||||
|
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
||||||
|
|
||||||
|
const kiosk = repo.createKiosk({
|
||||||
|
name: kioskName,
|
||||||
|
key_hash: kioskKeyHash,
|
||||||
|
key_prefix: kioskKeyPrefix,
|
||||||
|
capabilities: pc.kiosk_capabilities,
|
||||||
|
hardware_model: pc.kiosk_hardware_model,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach initial labels
|
||||||
|
if (input.initialLabels?.length) {
|
||||||
|
for (const labelName of input.initialLabels) {
|
||||||
|
const trimmed = labelName.trim().toLowerCase();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const label = repo.ensureLabel(trimmed);
|
||||||
|
repo.attachKioskLabel(kiosk.id, label.id, "consume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cluster key for kiosk
|
||||||
|
const clusterKeyEncrypted = repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
||||||
|
const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined;
|
||||||
|
|
||||||
|
// Store plaintext kiosk_key + cluster_key in extras for kiosk to claim once
|
||||||
|
repo.markPairingCodeClaimed(input.code, kiosk.id, {
|
||||||
|
kiosk_key_plaintext: kioskKeyPlaintext,
|
||||||
|
cluster_key: clusterKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { kioskId: kiosk.id, kioskName };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,11 +246,12 @@ interface KiosksProps {
|
||||||
user: string;
|
user: string;
|
||||||
kiosks: Kiosk[];
|
kiosks: Kiosk[];
|
||||||
pendingCodes: PairingCode[];
|
pendingCodes: PairingCode[];
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KiosksPage(props: KiosksProps) {
|
export function KiosksPage(props: KiosksProps) {
|
||||||
return (
|
return (
|
||||||
<Layout title="Kiosks" user={props.user} activeNav="kiosks">
|
<Layout title="Kiosks" user={props.user} activeNav="kiosks" flash={props.error ? { type: "error", message: props.error } : undefined}>
|
||||||
<div class="two-col">
|
<div class="two-col">
|
||||||
<div>
|
<div>
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue