feat(pairing): validate replace-target matches existing kiosk

Replacing a kiosk now sanity-checks the incoming device:
- hardware_model must match (Pi 5 swapping in for Pi 5, not Pi 3)
- managed_image flag must match (don't silently switch BYO-OS ↔ image)
- capabilities can narrow legitimately but a "lost capabilities" diff is
  surfaced anyway so the operator notices.

Mismatch raises an error listing what changed; "Force replace" checkbox
on the pair form bypasses for legitimate hardware upgrades. Pending codes
panel also now renders proposed_name / hw_model / capabilities /
managed-image badge so the operator can eyeball the inbound device
before picking a replace target.
This commit is contained in:
Mitchell R 2026-05-21 10:16:55 +02:00
parent 6b959755e7
commit d51e01ff0e
No known key found for this signature in database
3 changed files with 62 additions and 6 deletions

View file

@ -744,6 +744,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined; const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim(); const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim();
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined; const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined;
const force = body?.["force"] === "1";
try { try {
const result = await confirmPairing(deps.repo, deps.auth, deps.secrets, { const result = await confirmPairing(deps.repo, deps.auth, deps.secrets, {
@ -751,6 +752,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
nameOverride, nameOverride,
initialLabels, initialLabels,
replaceKioskId, replaceKioskId,
force,
}); });
audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", { audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", {
resource_type: "kiosk", resource_type: "kiosk",

View file

@ -114,6 +114,8 @@ export interface PairingConfirmInput {
* When set, nameOverride and initialLabels are ignored. * When set, nameOverride and initialLabels are ignored.
*/ */
replaceKioskId?: number; replaceKioskId?: number;
/** Bypass replacement-target sanity checks (hardware_model / capabilities / managed_image). */
force?: boolean;
} }
export async function confirmPairing( export async function confirmPairing(
@ -137,12 +139,45 @@ export async function confirmPairing(
if (input.replaceKioskId != null) { if (input.replaceKioskId != null) {
const existing = repo.getKioskById(input.replaceKioskId); const existing = repo.getKioskById(input.replaceKioskId);
if (!existing) throw new Error("replacement target kiosk not found"); if (!existing) throw new Error("replacement target kiosk not found");
// Sanity-check the incoming device matches the slot it's replacing.
// Identity-bearing fields (hardware_model, managed_image) shouldn't drift
// on a like-for-like swap. Capabilities CAN narrow legitimately, so warn
// but don't block. force=1 from the form bypasses the whole check.
if (!input.force) {
const mismatches: string[] = [];
const newHw = pc.kiosk_hardware_model ?? null;
if (existing.hardware_model && newHw && existing.hardware_model !== newHw) {
mismatches.push(`hardware_model: ${existing.hardware_model}${newHw}`);
}
const newManaged = pc.extras?.["managed_image"] === true;
if (existing.managed_image !== newManaged) {
mismatches.push(`managed_image: ${existing.managed_image}${newManaged}`);
}
const lostCaps = existing.capabilities.filter((c) => !pc.kiosk_capabilities.includes(c));
if (lostCaps.length > 0) {
mismatches.push(`lost capabilities: ${lostCaps.join(", ")}`);
}
if (mismatches.length > 0) {
throw new Error(
`replacement device differs from existing kiosk — ${mismatches.join("; ")}. `
+ `Re-submit with "force replace" to override.`,
);
}
}
repo.replaceKioskKey(existing.id, { repo.replaceKioskKey(existing.id, {
key_hash: kioskKeyHash, key_hash: kioskKeyHash,
key_prefix: kioskKeyPrefix, key_prefix: kioskKeyPrefix,
capabilities: pc.kiosk_capabilities, capabilities: pc.kiosk_capabilities,
hardware_model: pc.kiosk_hardware_model, hardware_model: pc.kiosk_hardware_model,
}); });
// managed_image flag follows the new device (handled on row above via
// capabilities/hw, but the explicit column is updated separately because
// replaceKioskKey doesn't touch it).
if (existing.managed_image !== (pc.extras?.["managed_image"] === true)) {
repo.updateKiosk(existing.id, { managed_image: pc.extras?.["managed_image"] === true } as any);
}
kioskId = existing.id; kioskId = existing.id;
kioskName = existing.name; kioskName = existing.name;
} else { } else {

View file

@ -907,6 +907,12 @@ export function KiosksPage(props: KiosksProps) {
<label for="name_override">Name Override (new kiosks only)</label> <label for="name_override">Name Override (new kiosks only)</label>
<input id="name_override" name="name_override" type="text" class="form-input" /> <input id="name_override" name="name_override" type="text" class="form-input" />
</div> </div>
<div class="form-group">
<label style="font-weight:normal">
<input type="checkbox" name="force" value="1" />
{" "}Force replace (skip hardware / capability / managed-image match check)
</label>
</div>
<div class="form-group"> <div class="form-group">
<label for="initial_labels">Initial Labels (new kiosks only)</label> <label for="initial_labels">Initial Labels (new kiosks only)</label>
<input id="initial_labels" name="initial_labels" type="text" class="form-input" placeholder="lobby, floor-1" /> <input id="initial_labels" name="initial_labels" type="text" class="form-input" placeholder="lobby, floor-1" />
@ -918,12 +924,25 @@ export function KiosksPage(props: KiosksProps) {
{props.pendingCodes.length > 0 && ( {props.pendingCodes.length > 0 && (
<div style="margin-top:1.25rem; border-top:1px solid #eee; padding-top:1rem"> <div style="margin-top:1.25rem; border-top:1px solid #eee; padding-top:1rem">
<div style="font-weight:600; font-size:0.85rem; margin-bottom:0.5rem">Pending Codes</div> <div style="font-weight:600; font-size:0.85rem; margin-bottom:0.5rem">Pending Codes</div>
{props.pendingCodes.map((pc) => ( {props.pendingCodes.map((pc) => {
<div style="display:flex; justify-content:space-between; font-size:0.85rem; padding:0.25rem 0"> const managed = pc.extras?.["managed_image"] === true;
<code>{pc.code}</code> return (
<span style="color:#666">{formatTime(pc.expires_at)}</span> <div style="font-size:0.8rem; padding:0.4rem 0; border-top:1px dashed #eee">
</div> <div style="display:flex; justify-content:space-between">
))} <code style="font-size:0.95rem">{pc.code}</code>
<span style="color:#666">expires {formatTime(pc.expires_at)}</span>
</div>
<div style="color:#666; margin-top:0.2rem">
{pc.kiosk_proposed_name ? <>name: <code>{pc.kiosk_proposed_name}</code></> : "(no name)"}
{pc.kiosk_hardware_model ? <> · hw: <code>{pc.kiosk_hardware_model}</code></> : null}
{managed ? <> · <span style="color:#080">managed image</span></> : null}
</div>
{pc.kiosk_capabilities?.length > 0 ? (
<div style="color:#666; margin-top:0.15rem">caps: {pc.kiosk_capabilities.join(", ")}</div>
) : null}
</div>
);
})}
</div> </div>
)} )}
</div> </div>