Files
parking_solution/apps/server/src/routes/setup.ts
T
julian cd3b534e51
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 50s
Build & push images / images (push) Successful in 2m54s
feat(setup): USB printer discovery — pick a real /dev/usb device
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:

- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
  compose bind-mount) and enriches each with the printer's self-reported
  make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
  ("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
  first real device; a saved-but-unplugged path stays selectable,
  flagged "saved — not present now"; zero found falls back to free text
  + a check-the-cable hint.
- Transport option label no longer hardcodes lp0.

Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 11:35:25 +02:00

718 lines
31 KiB
TypeScript

import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, devices, setupState, type Db } from "@parking/db";
import {
hasPreconditions,
hasPushConfig,
isCamera,
isDiscoverable,
isHardenable,
isPrinter,
registerBuiltinDrivers,
registry,
setDeviceLogSink,
type CameraDevice,
type DeviceCategory,
type DeviceConfig,
} from "@parking/devices";
import { reasonPayload } from "@parking/shared";
import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
import type { VisionClient } from "../vision-client.js";
// First-run setup API. The admin reads the driver catalog and assigns devices
// per lane. See wiki/concepts/first-run-setup.md.
interface AssignBody {
category: DeviceCategory;
driverId: string;
// Driver config (opaque JSON, validated by the driver). Carries the model's
// direction/binding: access → config.relays=[{relay,direction,button?}];
// reader/camera → config.controllerId + config.relay. See entry-exit-points.md.
config: DeviceConfig;
/** Optional: the backend IP the device should push to (overrides auto-pick;
* matters on multi-NIC hosts). */
backendIp?: string;
}
interface TestBody {
driverId: string;
config: Record<string, string | number | boolean>;
/** When editing an EXISTING device, its id — so the test re-merges the stored
* machine secrets (relayPassword/pushPassword) the client never received. Without
* this, testing an edited device would send no relay password → the device ignores
* the probe → a false "offline". Omitted when testing a brand-new device. */
id?: string;
}
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
// No human ever uses these to log in: `pushPassword` is the device→backend Digest
// secret, `relayPassword` is the binary-protocol relay_pw. They stay redacted.
//
// NOTE: the device web-UI login (`webUser`/`webPassword`) is deliberately NOT
// redacted. It's an operational credential an admin needs to reach the device's
// own web page, and the whole device-management area is admin-only — so it's
// surfaced in the admin device view rather than hidden. See first-run-setup.md.
const SECRET_CONFIG_KEYS = ["pushPassword", "relayPassword"] as const;
function redactSecrets(config: Record<string, unknown>): Record<string, unknown> {
const out = { ...config };
for (const k of SECRET_CONFIG_KEYS) delete out[k];
return out;
}
/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup
* relay test needs it; a stub/reader/camera won't have it). */
function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise<void> } {
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
}
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
// secret may only be re-merged when these match the stored row — otherwise an admin
// could point a test at an attacker host while keeping a real device id and have the
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
// UDP + CGI targets; serial covers serial-bound readers.
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
* when the submitted config addresses the SAME device — same driver, and every
* connection-identity field (host/port/…) that the submitted config sets equals the
* stored value. If the admin redirected the probe (different host/port) or the driver
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
* redirected test from exfiltrating the secret to an attacker host. */
export function storedSecrets(
db: Db,
id: string,
driverId: string,
submitted: Record<string, unknown>,
): Record<string, unknown> {
const row = db.select().from(devices).where(eq(devices.id, id)).get();
if (!row || row.driverId !== driverId) return {};
const cfg = row.config as Record<string, unknown>;
// Any identity field the client SENT must equal the stored value. (A field the client
// omits falls back to the stored device, so it can't be used to redirect.)
for (const k of IDENTITY_KEYS) {
const sent = submitted[k];
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
return {};
}
}
const out: Record<string, unknown> = {};
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
return out;
}
/** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome =
| { config: Record<string, unknown>; warnings: string[] }
| { error: { code: number; message: string } };
/**
* Validate + configure a device, returning the config to persist. Runs the same
* pipeline for both create and edit: validate the driver config, fix
* preconditions, harden (relay password + protocol lockdown), and set up input
* push (Digest creds + push URLs). Each step is a device write (the device
* reboots on apply). The caller owns the DB row; this never touches the DB.
*
* `id` is the assignment id (stable across an edit) — it's baked into the push
* URL, so editing in place keeps the device pushing to the same path.
* `existingConfig` carries forward secrets the client never sees on edit
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
*/
async function configureDevice(
app: FastifyInstance,
args: {
id: string;
driverId: string;
config: DeviceConfig;
backendIp?: string;
existingConfig?: Record<string, unknown>;
},
): Promise<ConfigureOutcome> {
const { id, driverId, config, backendIp, existingConfig } = args;
// Start from any machine-only secrets already on the row (push/relay passwords
// are redacted out of the client's copy, so an edit would otherwise drop them),
// then layer the submitted config on top.
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return { error: { code: 400, message: (err as Error).message } };
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return {
error: {
code: 502,
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
},
};
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return {
error: {
code: 400,
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
},
};
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
}
return { config: fullConfig, warnings: hardenWarnings };
}
export async function setupRoutes(
app: FastifyInstance,
db: Db,
vision?: VisionClient | null,
eventLog?: EventLog | null,
): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
// Device setup is site administration — it changes which hardware the site runs
// and how readers bind to relays. Gated on site:update. See ../auth.ts.
const adminGuard = requirePermission("site:update");
// Catalog of selectable drivers per category (no secrets — schema only).
// `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
// drivers that push to the backend (and thus need a backend IP at assign time).
app.get("/api/setup/catalog", async () => {
const catalog = registry.catalog();
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
const pushCapable = registry.pushCapable();
return { ...catalog, discoverable, pushCapable };
});
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
// Each found device is health-checked so the admin sees reachability before
// assigning. Admin-only. See wiki/concepts/device-discovery.md.
app.get<{ Params: { driverId: string } }>(
"/api/setup/discover/:driverId",
{ preHandler: adminGuard },
async (req, reply) => {
const driver = registry.get(req.params.driverId);
if (!driver) return reply.code(404).send({ error: `unknown driver: ${req.params.driverId}` });
if (!isDiscoverable(driver)) {
return reply.code(400).send({ error: `driver ${driver.id} does not support discovery` });
}
try {
const found = await driver.discover();
const withHealth = await Promise.all(
found.map(async (d) => {
let health: { status: string; detail?: string };
try {
health = await driver.create(d.config).healthCheck();
} catch (err) {
health = { status: "offline", detail: (err as Error).message };
}
return { ...d, health };
}),
);
return { driverId: driver.id, devices: withHealth };
} catch (err) {
return reply.code(502).send({ error: `discovery failed: ${(err as Error).message}` });
}
},
);
// Current setup status + assignments. Secrets are stripped from each config
// (the UI lists devices; it never needs the stored push/relay/web passwords).
app.get(
"/api/setup/state",
{ preHandler: adminGuard },
async () => {
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
const rows = await db.select().from(devices).all();
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
return { completedAt: state?.completedAt ?? null, assignments };
},
);
// Test a device config WITHOUT saving or changing the device: validate the
// config, probe reachability (healthCheck), and report preconditions
// (e.g. input_link_relay state). Lets the admin verify before committing.
app.post<{ Body: TestBody }>(
"/api/setup/test",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config, id } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
// When editing an existing device, re-merge its stored machine secrets (e.g.
// relayPassword) — redacted from the client, so the submitted config omits them.
// Submitted values win (an admin can override), but a blank/0 field falls back to
// the stored secret so the probe authenticates. Without this, an edited Dingtian
// tests with no relay password → false "offline". The submitted-value-wins rule:
// only fill a secret from the store when the form didn't send a real one.
// Re-merge stored secrets ONLY when this addresses the same device at the same
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
// the secret to an attacker host. Submitted values still win.
const merged: Record<string, string | number | boolean | undefined> = { ...config };
if (id) {
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
const sent = merged[k];
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
}
}
let device;
try {
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
const health = await device.healthCheck();
const preconditions = hasPreconditions(device)
? await device.checkPreconditions()
: { ok: true, issues: [] };
return { health, preconditions };
},
);
// Test ANPR end-to-end on a camera config WITHOUT saving: capture a live snapshot
// off the camera and run it through the vision (ANPR) service, reporting whether a
// plate was extracted, the read, and how long it took. Lets the admin verify the
// camera→vision pipeline before committing the camera's `anpr` opt-in. Advisory +
// fail-soft, exactly like the runtime path (snapshot.ts): a vision failure is a
// reported "no plate", never a 500. See wiki/entities/opencv-anpr-service.md.
app.post<{ Body: TestBody }>(
"/api/setup/test-anpr",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
if (driver.category !== "camera") {
return reply.code(400).send({ error: `driver ${driverId} is not a camera` });
}
if (!vision?.enabled) {
// The vision service is off (VISION_ENABLED unset) — there's nothing to test
// against. Report it cleanly so the UI can say "enable vision first".
return reply.send({ ok: false, reason: "vision-disabled" });
}
let device;
try {
device = registry.create(driverId, config);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!isCamera(device)) {
return reply.code(400).send({ error: `driver ${driverId} cannot capture snapshots` });
}
// 1) Grab a frame off the camera. A camera/network failure here is the failure
// we're testing for — report it, don't 500.
const startedAt = Date.now();
let shot: Awaited<ReturnType<CameraDevice["captureSnapshot"]>>;
try {
shot = await device.captureSnapshot({ direction: "entry" });
} catch (err) {
return reply.send({
ok: false,
reason: "snapshot-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
// 2) Run the same advisory analyze the runtime path uses. `analyze` is fail-soft
// (null on any error/timeout) and applies the confidence floor.
const result = await vision.analyze(shot.bytes, shot.contentType);
const tookMs = Date.now() - startedAt;
if (!result || !result.plate) {
return reply.send({ ok: false, reason: "no-plate", tookMs });
}
return reply.send({
ok: true,
plate: result.plate.text.trim().toUpperCase(),
confidence: result.plate.confidence,
region: result.plate.region ?? null,
lowConfidence: result.lowConfidence,
modelVersion: result.modelVersion,
tookMs,
});
},
);
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
// and the head fires. This pushes a real short slip through the device-agnostic
// printReport(), so the admin can physically confirm the printer is live (the USB
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
// network printer still authenticates.
app.post<{ Body: TestBody }>(
"/api/setup/test-print",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config, id } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
if (driver.category !== "printer") {
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
}
const merged: Record<string, string | number | boolean | undefined> = { ...config };
if (id) {
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
const sent = merged[k];
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
}
}
let device;
try {
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!isPrinter(device)) {
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
}
const startedAt = Date.now();
try {
await device.printReport({
title: "TEST PRINT",
lines: [
"Parking System",
"Printer test slip",
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
"",
"If you can read this, the",
"printer is connected and",
"printing correctly.",
],
});
} catch (err) {
// The failure we're testing for (paper out, head fault, transport drop) —
// report it, don't 500.
return reply.send({
ok: false,
reason: "print-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
},
);
// PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any
// vehicle/session. This physically opens the barrier, so unlike the other tests it
// runs only against a SAVED controller (real id → clean attribution) and it SIGNS a
// `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the
// admin's identity. That is the whole point of doing it this way: a physical open with
// no matching signed command is the fraud signal ([[append-only-event-chain]],
// [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a
// silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is
// recorded whether or not the physical pulse then succeeds. Admin-only (site:update).
app.post<{ Body: { id: string; relay: number } }>(
"/api/setup/test-relay",
{ preHandler: adminGuard },
async (req, reply) => {
const { id, relay } = req.body;
if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" });
if (!Number.isInteger(relay) || relay < 1) {
return reply.code(400).send({ error: "relay must be a 1-based channel number" });
}
// A relay test fires REAL hardware, so it must target a persisted controller — no
// firing an unsaved/redirected config (that would let a probe open an arbitrary host's
// barrier). Load the saved row and build straight from its stored config (relayPassword
// included — it's on the row, never in the request).
const row = db.select().from(devices).where(eq(devices.id, id)).get();
if (!row) return reply.code(404).send({ error: "controller not found" });
if (row.category !== "access") {
return reply.code(400).send({ error: `device ${id} is not a controller` });
}
const cfg = (row.config ?? {}) as Record<string, unknown>;
const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : [];
if (!relays.some((r) => r.relay === relay)) {
return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` });
}
let device;
try {
device = registry.create(row.driverId, cfg as Record<string, string | number | boolean>);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!hasPulseOpen(device)) {
return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` });
}
// Sign the deliberate open FIRST — recorded whether or not the physical pulse then
// succeeds. Skip only if no ledger is wired (test/degraded), in which case we still
// refuse rather than fire an unrecorded open.
const operator = req.user?.username ?? "unknown";
if (!eventLog) {
return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" });
}
await eventLog.append({
type: "barrier_open_command",
// A deliberate human action from the admin console → "manual" (the top-level
// IdentitySource). The relayTest marker + reason distinguish it in the payload.
source: "manual",
identity: `relay-test:${id}:${relay}`,
payload: {
...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }),
relayTest: true,
controllerId: id,
relay,
operator,
},
});
const startedAt = Date.now();
try {
await device.pulseOpen(relay);
} catch (err) {
// The failure we're testing for (relay unreachable, wrong password). The open is
// already signed; report the pulse failure, don't 500.
return reply.send({
ok: false,
reason: "pulse-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
return reply.send({ ok: true, firedAt: new Date().toISOString(), tookMs: Date.now() - startedAt });
},
);
// Candidate backend IPs the device can push to, for a given device host. The
// wizard pre-fills with the on-subnet one and lets the admin override (matters
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
app.get<{ Querystring: { host?: string } }>(
"/api/setup/backend-ips",
{ preHandler: adminGuard },
async (req) => {
const candidates = backendIpCandidates(req.query.host ?? "");
return { candidates, port: backendPort() };
},
);
// USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
// container sees via the /dev/usb bind-mount) and enrich each with the printer's
// self-reported make/model from sysfs (ieee1284_id — readable through Docker's
// default ro /sys). The wizard offers these as a SELECT so the admin never has to
// shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
// park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
const { readdir, readFile } = await import("node:fs/promises");
let names: string[] = [];
try {
names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
} catch {
return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
}
const printers = await Promise.all(
names.map(async (n) => {
// ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
let description: string | null = null;
try {
const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
const mfg = pick("MFG") ?? pick("MANUFACTURER");
const mdl = pick("MDL") ?? pick("MODEL");
description = [mfg, mdl].filter(Boolean).join(" ") || null;
} catch {
/* sysfs not readable / attribute absent — path alone is still useful */
}
return { path: `/dev/usb/${n}`, description };
}),
);
return { printers };
});
// Assign a device. Validates the chosen driver + config, configures the device
// (fix preconditions + set up Digest-authenticated input push — no manual device-
// web-UI step by the admin), then persists. Fails the save if the device can't be
// configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
app.post<{ Body: AssignBody }>(
"/api/setup/assign",
{ preHandler: adminGuard },
async (req, reply) => {
const { category, driverId, config, backendIp } = req.body;
const driver = registry.get(driverId);
if (!driver || driver.category !== category) {
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
}
const id = randomUUID();
const outcome = await configureDevice(app, { id, driverId, config, backendIp });
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
const row = {
id,
category,
driverId,
config: outcome.config,
enabled: true,
};
await db.insert(devices).values(row);
// Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({
...row,
config: redactSecrets(outcome.config),
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
// Edit an assigned device in place. Same configure pipeline as assign, but it
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
// since the id is baked into the device's input-push URL
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
// break push until reconfigured; PATCH re-runs harden/push against the same id.
// The category and driver are fixed at create time (an edit can't change what
// KIND of device a slot is); only config changes. Admin-only.
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(devices)
.where(eq(devices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
const { config, backendIp } = req.body;
const outcome = await configureDevice(app, {
id: existing.id,
driverId: existing.driverId,
config,
backendIp,
// Carry forward machine-only secrets the client never received, so an
// edit that omits them doesn't blank out push/relay passwords.
existingConfig: existing.config,
});
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
return reply.code(200).send({
id: existing.id,
category: existing.category,
driverId: existing.driverId,
config: redactSecrets(outcome.config),
enabled: existing.enabled,
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
// Unassign (remove) a device instance. The schema is multi-instance — one row
// per (lane, category, instance) — so removing one is just deleting its row by
// id. Lets the admin manage a LIST of devices per category (add/remove), not a
// fixed one-per-category slot. Admin-only. See wiki/concepts/first-run-setup.md.
//
// NOTE: we only drop our row; we do NOT un-harden / un-configure the device
// itself (e.g. clear the Dingtian push URL). The device keeps its last config
// harmlessly — pushes from an unknown device id are already rejected (see
// routes/devices.ts), and re-assigning reconfigures it. A future "factory
// reset on unassign" can hook here if needed.
app.delete<{ Params: { id: string } }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(devices)
.where(eq(devices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
await db.delete(devices).where(eq(devices.id, req.params.id));
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
return reply.code(204).send();
},
);
// Mark first-run setup complete.
app.post(
"/api/setup/complete",
{ preHandler: adminGuard },
async () => {
const completedAt = new Date().toISOString();
await db
.insert(setupState)
.values({ id: 1, completedAt })
.onConflictDoUpdate({ target: setupState.id, set: { completedAt } });
return { completedAt };
},
);
}