Setup: manage multiple device instances per category (add/remove)

The data model was already multi-instance (lane_devices = one row per
instance; assign always inserts) -- the limitation was UI-only. Make the
whole flow support more than one of every category:

- Backend: add DELETE /api/setup/assign/:id (unassign by id). /state now
  redacts secrets (pushPassword/webPassword/relayPassword) via a shared
  redactSecrets() also used by /assign -- it was returning raw config rows.
- Web: SetupWizard reworked from one fixed slot per category into a list of
  assigned instances (driver/role/host + Remove) plus an "Add another" form.
  select-type config fields (e.g. printer role) now render as dropdowns.
- api.ts: add fetchState(), unassignDevice(), Assignment/SetupState types.

Verified via Fastify inject: two printers assigned to one lane both list,
no secret leak, delete -> 204, delete unknown -> 404, count drops to 1.
Full repo typechecks.

Wiki: first-run-setup documents multi-instance + delete + redaction.
This commit is contained in:
2026-06-14 20:39:39 +02:00
parent b2a0471b08
commit 39d4bac419
5 changed files with 299 additions and 61 deletions
+43 -5
View File
@@ -32,6 +32,17 @@ interface TestBody {
config: Record<string, string | number | boolean>;
}
// Config keys that hold device secrets — never sent back to the client. Covers
// the push Digest password, the rotated device web-UI login, and the Dingtian
// relay password. Centralised so /state and /assign redact consistently.
const SECRET_CONFIG_KEYS = ["pushPassword", "webPassword", "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;
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
@@ -79,13 +90,15 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Current setup status + assignments.
// 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 assignments = await db.select().from(laneDevices).all();
const rows = await db.select().from(laneDevices).all();
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
return { completedAt: state?.completedAt ?? null, assignments };
},
);
@@ -215,9 +228,34 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
enabled: true,
};
await db.insert(laneDevices).values(row);
// Don't echo device secrets back (push Digest password, web-UI login).
const { pushPassword: _pw, webPassword: _wp, ...safeConfig } = fullConfig;
return reply.code(201).send({ ...row, config: safeConfig });
// Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({ ...row, config: redactSecrets(fullConfig) });
},
);
// 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(laneDevices)
.where(eq(laneDevices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
return reply.code(204).send();
},
);