d0841c8601
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
398 lines
16 KiB
TypeScript
398 lines
16 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,
|
|
isDiscoverable,
|
|
isHardenable,
|
|
registerBuiltinDrivers,
|
|
registry,
|
|
setDeviceLogSink,
|
|
type DeviceCategory,
|
|
type DeviceConfig,
|
|
} from "@parking/devices";
|
|
import { requirePermission } from "../auth.js";
|
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.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>;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/** 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));
|
|
|
|
// 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 } = req.body;
|
|
const driver = registry.get(driverId);
|
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
|
|
|
let device;
|
|
try {
|
|
device = registry.create(driverId, config);
|
|
} 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 };
|
|
},
|
|
);
|
|
|
|
// 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 };
|
|
},
|
|
);
|
|
}
|