420542ce10
The relay control password (relay_pw) was read by the driver but had NO form field, so Test connection sent it as 0 → the device ignored the probe → a controller showed "offline" even though it pinged. Add a "Relay control password" config field (secret; blank keeps the stored value). Because relayPassword is redacted from the client, the edit form can't resend it — so the test endpoint now re-merges the stored secret by device id (mirroring save). It is re-merged ONLY when the submitted config addresses the SAME device: matching driverId and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A redirected host/port or mismatched driver yields NO secret, so a probe can't exfiltrate the password to an attacker host (the booth operator is the threat-model adversary). testDevice() now passes the device id; setup-secrets.test.ts covers the identity guard. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
528 lines
22 KiB
TypeScript
528 lines
22 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,
|
|
registerBuiltinDrivers,
|
|
registry,
|
|
setDeviceLogSink,
|
|
type CameraDevice,
|
|
type DeviceCategory,
|
|
type DeviceConfig,
|
|
} from "@parking/devices";
|
|
import { requirePermission } from "../auth.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;
|
|
}
|
|
|
|
// 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,
|
|
): 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,
|
|
});
|
|
},
|
|
);
|
|
|
|
// 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() };
|
|
},
|
|
);
|
|
|
|
// 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 };
|
|
},
|
|
);
|
|
}
|