ticket: site metadata header + scannable Albanian ticket; widen barcode
- site_config gains optional park identity (park_name, operator_name, nius, address, phone, email); additive Drizzle migration 0001. GET/PUT /api/site-config read/write the full config (PUT partial patch, admin only); SiteSettings + SetupWizard expose the fields. - renderTicket() prints an Albanian header sourced from site_config, the all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits, and a lost-ticket footer. CP852 codepage so ë/ç render. - Widen the Code128 module width 2->3 and height 80->100 dots so the short-range "Simple" QR/barcode reader decodes reliably (was barely reading at module width 2 on the 80mm head). See wiki/concepts/site-metadata.md and ticket-encoding.md.
This commit is contained in:
+172
-82
@@ -51,6 +51,127 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
||||
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): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
@@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const fullConfig: Record<string, unknown> = { ...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 reply.code(400).send({ error: (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 reply.code(502).send({
|
||||
error: `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 reply.code(400).send({
|
||||
error: `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 reply
|
||||
.code(502)
|
||||
.send({ error: `device configuration failed: ${(err as Error).message}` });
|
||||
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: fullConfig,
|
||||
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(fullConfig),
|
||||
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
|
||||
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 } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user