diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index b517783..6fba9f6 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -1213,3 +1213,6 @@ {"id":"evt-s05mgh083ghz","type":"verification.recorded","subject":"ver-6qnn6cd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"getCameraScopes and setCameraScopes in src/lib/camera.ts: SetScopes replaces every configurable scope, so the others are read and sent back, fixed scopes are left out, and values are URL-encoded; the result is re-read from the camera. saveCameraNames in the camera page's actions writes it, reports applied or adjusted per field, and appends to the audit log. Tests against the fake camera (camera.test.ts \"camera name and location over ONVIF\") cover keeping other scopes, encoding, changing only what was given, and a refusal; actions.test.ts covers the action. Full suite 991 tests pass; tsc, eslint and a build clean. Not yet tried against a real camera: that write needs the user's go-ahead."},"at":"2026-09-20T12:42:11.869Z","parents":["evt-f16r6p9a97rb"],"hash":"8c2fbbfabf5962ce3eb4f291b4273166c6a39309688542a05faff3be2a9c5e52"} {"id":"evt-kaxnd2x25951","type":"node.status_changed","subject":"iss-t2bmvgj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T12:42:13.219Z","parents":["evt-s05mgh083ghz"],"hash":"62808924dff0c7b84d9ef3d4bb4a4df0d0ad062241ae980859cbecee798b8269"} {"id":"evt-pvbrzehmt1bc","type":"node.status_changed","subject":"iss-h1qwke5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T12:42:14.303Z","parents":["evt-kaxnd2x25951"],"hash":"a04519d7428434e42e4d99da3ee04d4c29bd1510f71ec6f8adfe8c9cb47e535c"} +{"id":"evt-h1trqzdyf5pc","type":"node.created","subject":"fnd-egj8kk9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Renaming a camera over ONVIF is refused for the Operator login: ter:NotAuthorized, and only the name scope is configurable","body":"Probed on camera …af2e on 2026-09-20 after the user's rename attempt failed. Sending the camera's current scopes back unchanged, so nothing would have changed either way, produced a SOAP fault: env:Sender / ter:NotAuthorized, \"The action requested requires authorization and the sender is not authorized\". So SetScopes needs more than the Operator level this app holds, the same wall ISAPI imaging writes hit (fnd-vfrgk0m). Reading scopes is allowed.\n\nTwo other things the probe showed. The camera has exactly one configurable scope, onvif://www.onvif.org/name/I91ET; Profile/G, Profile/T, type/audio_encoder, MAC and hardware are all Fixed, so location would have to be added rather than changed, and whether the camera accepts a new scope name is untested. And the onvif library reports every SetScopes failure as \"Wrong `SetScopes` response\": its check treats any reply that isn't the exact expected shape, including a fault, as that one error, which hides the real cause. The app now keeps the raw reply and turns a NotAuthorized fault into its own error, so the page says the login may not rename the camera rather than something generic.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-h1qwke5","note":"Camera …af2e, address redacted per dec-kgm44qw; GetScopes then SetScopes with the same scopes, raw SOAP captured"}],"as_of":"2026-09-20"}},"at":"2026-09-20T18:48:07.551Z","parents":["evt-pvbrzehmt1bc"],"hash":"9c313bede66a1eabaaa016885f7357085a5bd6365bed9bb054237627b0494bd9"} +{"id":"evt-ey4kq1gfj73t","type":"edge.added","subject":"fnd-egj8kk9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-egj8kk9","to":"iss-h1qwke5"},"at":"2026-09-20T18:48:07.553Z","parents":["evt-h1trqzdyf5pc"],"hash":"2b4c7418eca5aa42242843c7c63655625e58eab07b31c33f346c1f38c89c3ee1"} +{"id":"evt-0wxwjen8y0gg","type":"edge.added","subject":"fnd-egj8kk9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-egj8kk9","to":"gol-6x4ctm2"},"at":"2026-09-20T18:48:07.554Z","parents":["evt-ey4kq1gfj73t"],"hash":"cecfff0a53da570fad7223086789ca5c76ca4044897f24a77a4a06a470b26bcc"} diff --git a/src/app/cameras/[id]/actions.test.ts b/src/app/cameras/[id]/actions.test.ts index 42c263d..082705b 100644 --- a/src/app/cameras/[id]/actions.test.ts +++ b/src/app/cameras/[id]/actions.test.ts @@ -182,11 +182,21 @@ describe("saveCameraNames (iss-t2bmvgj, iss-h1qwke5)", () => { it("explains a camera that refuses the rename", async () => { vi.spyOn(console, "error").mockImplementation(() => {}); - m.setCameraScopes.mockRejectedValue(new Error("Sender not authorized")); + m.setCameraScopes.mockRejectedValue(new Error("socket hang up")); const result = await saveCameraNames(ID, { name: "Porch" }); expect(result).toEqual({ ok: false, reason: "camera", message: SAVE_MESSAGES.unreachable }); }); + it("says plainly when the camera won't let this login rename it", async () => { + const { CameraNotPermittedError } = await import("@/lib/camera"); + m.setCameraScopes.mockRejectedValue(new CameraNotPermittedError("nope")); + expect(await saveCameraNames(ID, { name: "Porch" })).toEqual({ + ok: false, + reason: "not-permitted", + message: SAVE_MESSAGES.notPermitted, + }); + }); + it("refuses when signed out", async () => { m.access.mockResolvedValue("signed-out"); expect(await saveCameraNames(ID, { nickname: "Front door" })).toMatchObject({ reason: "signed-out" }); diff --git a/src/app/cameras/[id]/actions.ts b/src/app/cameras/[id]/actions.ts index f0d46b2..a51d8d4 100644 --- a/src/app/cameras/[id]/actions.ts +++ b/src/app/cameras/[id]/actions.ts @@ -5,6 +5,7 @@ import { access } from "@/lib/access"; import { CameraAuthError, CameraInactiveError, + CameraNotPermittedError, isAllowedHost, setCameraScopes, type CameraTarget, @@ -47,6 +48,9 @@ async function actor(): Promise { } function cameraProblem(err: unknown): SaveResult { + if (err instanceof CameraNotPermittedError) { + return { ok: false, reason: "not-permitted", message: SAVE_MESSAGES.notPermitted }; + } if (err instanceof CameraAuthError) { return { ok: false, diff --git a/src/app/cameras/[id]/save-result.ts b/src/app/cameras/[id]/save-result.ts index 72c1ec4..e4f3680 100644 --- a/src/app/cameras/[id]/save-result.ts +++ b/src/app/cameras/[id]/save-result.ts @@ -12,5 +12,7 @@ export const SAVE_MESSAGES = { missingLogin: "No login is saved for this camera. Set it from the dashboard card first.", rejectedLogin: "The camera rejected its saved login. Update it from the dashboard card.", inactive: "This camera hasn't been activated yet.", + notPermitted: + "The camera won't let this login change that. Renaming the camera itself needs an administrator account on the camera; the name kept here still works.", unreachable: "Couldn't reach the camera. See the server log for details.", } as const; diff --git a/src/lib/camera.test.ts b/src/lib/camera.test.ts index 2a6d03e..b114a75 100644 --- a/src/lib/camera.test.ts +++ b/src/lib/camera.test.ts @@ -20,7 +20,7 @@ const fake = await vi.hoisted(async () => { snapshotUri: (options: { profileToken?: string }, done: Callback<{ uri?: string }>) => void; streamUri?: (options: { protocol: string; profileToken?: string }, done: Callback<{ uri?: string }>) => void; scopes?: () => { ScopeDef: string; ScopeItem: string }[]; - setScopes?: (uris: string[]) => Error | null; + setScopes?: (uris: string[], cam: FakeCam) => Error | null; } class FakeCam extends EventEmitter { static instances: FakeCam[] = []; @@ -46,7 +46,7 @@ const fake = await vi.hoisted(async () => { cb(null, FakeCam.script.scopes!()); } setScopes(uris: string[], cb: (err: Error | null) => void) { - cb(FakeCam.script.setScopes!(uris)); + cb(FakeCam.script.setScopes!(uris, this)); } digestAuth(challenges: string[], req: { method: string; path: string }) { return `Digest from=${challenges.length} ${req.method} ${req.path}`; @@ -132,7 +132,7 @@ function healthyCamera() { streamUri: (options, done) => done(null, { uri: `rtsp://camera.internal:554/Streaming/Channels/${options.profileToken}?transportmode=unicast` }), scopes: () => [...scopes], - setScopes: (uris) => { + setScopes: (uris: string[]) => { scopes = [ ...scopes.filter((s) => s.ScopeDef === "Fixed"), ...uris.map((ScopeItem) => ({ ScopeDef: "Configurable", ScopeItem })), @@ -530,7 +530,23 @@ describe("camera name and location over ONVIF (iss-h1qwke5)", () => { }); it("passes a refusal from the camera to the caller", async () => { - FakeCam.script.setScopes = () => new Error("Sender not authorized"); - await expect(camera.setCameraScopes(target(), { name: "Porch" })).rejects.toThrow("Sender not authorized"); + FakeCam.script.setScopes = () => new Error("Wrong `SetScopes` response"); + await expect(camera.setCameraScopes(target(), { name: "Porch" })).rejects.toThrow("Wrong `SetScopes` response"); + }); + + it("calls a refusal what it is, since the library reports every failure the same way", async () => { + // What these cameras actually answer an Operator: a SOAP fault the library hides + // behind its own message (fnd on iss-h1qwke5). + FakeCam.script.setScopes = (_uris, cam) => { + cam.emit( + "rawResponse", + 'ter:NotAuthorized' + + "", + ); + return new Error("Wrong `SetScopes` response"); + }; + await expect(camera.setCameraScopes(target(), { name: "Porch" })).rejects.toBeInstanceOf( + camera.CameraNotPermittedError, + ); }); }); diff --git a/src/lib/camera.ts b/src/lib/camera.ts index d606a56..ff02c81 100644 --- a/src/lib/camera.ts +++ b/src/lib/camera.ts @@ -52,6 +52,11 @@ export class CameraAuthError extends Error { * The camera hasn't been activated (first admin password never set). Vendors such as * Hikvision refuse every ONVIF request, even unauthenticated ones, until then. */ +/** The camera understood, and said this login may not do it (ONVIF ter:NotAuthorized). */ +export class CameraNotPermittedError extends Error { + name = "CameraNotPermittedError"; +} + export class CameraInactiveError extends Error { name = "CameraInactiveError"; } @@ -371,11 +376,25 @@ export async function setCameraScopes(target: CameraTarget, patch: CameraScopes) if (name) wanted.push(scopeUri("name", name)); if (location) wanted.push(scopeUri("location", location)); - await new Promise((resolve, reject) => - (cam as unknown as { setScopes(uris: string[], cb: (err: Error | null) => void): void }).setScopes( - wanted, - (err) => (err ? reject(err) : resolve()), - ), - ); + // The library reports one generic error however the camera refused, so the raw reply is + // kept to tell "you may not" apart from "that didn't work". + let raw = ""; + const capture = (xml: string) => (raw = xml); + cam.on("rawResponse", capture); + try { + await new Promise((resolve, reject) => + (cam as unknown as { setScopes(uris: string[], cb: (err: Error | null) => void): void }).setScopes( + wanted, + (err) => (err ? reject(err) : resolve()), + ), + ); + } catch (err) { + if (/NotAuthorized/i.test(raw)) { + throw new CameraNotPermittedError("The camera refused: this login may not rename it"); + } + throw err; + } finally { + cam.removeListener("rawResponse", capture); + } return getCameraScopes(target); } diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 59541f6..72dda63 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -3,7 +3,7 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node: import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { credentialsFile } from "./paths"; +import { credentialsFile } from "./paths.ts"; export interface Credentials { username: string;