@camstack/addon-post-analysis 1.2.16 → 1.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,351 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BV2hm-VZ.js");
5
+ const require_dist = require("../dist-CjwPOJKc.js");
6
+ let node_fs = require("node:fs");
7
+ let node_path = require("node:path");
8
+ node_path = require_dist.__toESM(node_path);
6
9
  let node_crypto = require("node:crypto");
7
10
  let sharp = require("sharp");
8
11
  sharp = require_dist.__toESM(sharp);
12
+ //#region src/notification-center/artifact-url.ts
13
+ /**
14
+ * Signed, externally-reachable URLs for notification artifacts.
15
+ *
16
+ * WHY this exists: the dispatcher only ever produced attachment BYTES, and the
17
+ * degrade engine drops a bytes-only attachment for a `mode:'url'` target
18
+ * (`attachment:noUrl`) — so WhatsApp and gotify received no media at all,
19
+ * silently. A URL also lets an oversized attachment degrade to a link instead
20
+ * of being dropped on `maxBytes`.
21
+ *
22
+ * Two decisions the shape depends on:
23
+ *
24
+ * - **Signed, not authenticated.** The fetcher is a notifier BACKEND (or the
25
+ * recipient's phone) — it holds no session and no admin token. The route is
26
+ * therefore `access:'public'` and the authority is the signature: an
27
+ * unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
28
+ * its own. Nothing else about the install is reachable through it.
29
+ * - **The base URL is CHOSEN, never assumed.** A notification carrying
30
+ * `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
31
+ * ranked endpoint list the `local-network` cap already computes — public
32
+ * tunnel > mesh > LAN, loopback last — is the authority, and loopback is
33
+ * refused outright rather than shipped as a broken link.
34
+ */
35
+ /**
36
+ * Pick the base URL an artifact link should use: the LOWEST-priority-number
37
+ * endpoint that is not loopback (the list is already ranked public > mesh >
38
+ * LAN > loopback). `null` when only loopback is available — a link nobody
39
+ * outside the host could open is worse than no link, because the notification
40
+ * would arrive advertising media that 404s.
41
+ */
42
+ function pickArtifactBaseUrl(endpoints) {
43
+ return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
44
+ }
45
+ /** Sign `(id, exp)` — the exact string the verifier recomputes. */
46
+ function signArtifact(secret, id, expMs) {
47
+ return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${expMs}`).digest("hex");
48
+ }
49
+ /**
50
+ * Build the fully-qualified, signed URL for an artifact.
51
+ * `baseUrl` comes from {@link pickArtifactBaseUrl}, `routePrefix` from the
52
+ * data-plane handle (`/addon/<addonId>/nc-artifact`).
53
+ */
54
+ function buildArtifactUrl(input) {
55
+ const sig = signArtifact(input.secret, input.id, input.expMs);
56
+ return `${input.baseUrl.endsWith("/") ? input.baseUrl.slice(0, -1) : input.baseUrl}${input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
57
+ }
58
+ /**
59
+ * Verify a request's `(id, exp, sig)`. Constant-time on the signature so a
60
+ * public route cannot be probed for it byte by byte, and expiry-checked before
61
+ * the compare so an expired link is rejected even with a valid signature.
62
+ */
63
+ function verifyArtifactSignature(input) {
64
+ if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
65
+ const expected = signArtifact(input.secret, input.id, input.exp);
66
+ const a = Buffer.from(expected, "utf8");
67
+ const b = Buffer.from(input.sig, "utf8");
68
+ if (a.length !== b.length) return false;
69
+ return (0, node_crypto.timingSafeEqual)(a, b);
70
+ }
71
+ //#endregion
72
+ //#region src/notification-center/artifact-plane.ts
73
+ /**
74
+ * The hub's default HTTPS API port — the same constant `derive-hub-url.ts`
75
+ * documents. Only used for the LAN candidates; an operator who runs the hub on
76
+ * another port sets `CAMSTACK_HUB_PUBLIC_URL`, which wins outright.
77
+ */
78
+ var HUB_API_PORT = 4443;
79
+ /**
80
+ * Ranking of the public sources. All NEGATIVE: `local-network` gives its
81
+ * preferred LAN interface priority `0`, so a public candidate has to sit below
82
+ * zero to outrank it.
83
+ */
84
+ var PRIORITY_MARKED = -400;
85
+ var PRIORITY_CONFIGURED = -300;
86
+ var PRIORITY_CONNECTED = -200;
87
+ var PRIORITY_EXTERNAL_LIST = -100;
88
+ /**
89
+ * Build the ranked candidate list, in the operator's own order: an explicitly
90
+ * configured public origin, then the connected external ingress (tunnels),
91
+ * then the LAN addresses. Every source is best-effort — one that throws
92
+ * contributes nothing rather than costing the whole list.
93
+ */
94
+ async function collectArtifactEndpoints(sources) {
95
+ const out = [];
96
+ const marked = sources.markedBaseUrl;
97
+ if (marked !== void 0 && marked !== "") out.push({
98
+ baseUrl: marked,
99
+ kind: "public",
100
+ priority: PRIORITY_MARKED
101
+ });
102
+ const configured = sources.configuredPublicUrl;
103
+ if (configured !== void 0 && configured !== "" && !/127\.0\.0\.1|localhost/.test(configured)) out.push({
104
+ baseUrl: configured,
105
+ kind: "public",
106
+ priority: PRIORITY_CONFIGURED
107
+ });
108
+ try {
109
+ const connected = await sources.getConnected();
110
+ if (connected !== null && connected.protocol === "https") out.push({
111
+ baseUrl: connected.url,
112
+ kind: "public",
113
+ priority: PRIORITY_CONNECTED
114
+ });
115
+ } catch (err) {
116
+ sources.logger.debug("connected endpoint lookup failed", { meta: { error: String(err) } });
117
+ }
118
+ try {
119
+ const external = await sources.listExternal();
120
+ for (const [i, e] of external.entries()) {
121
+ if (e.protocol !== "https") continue;
122
+ out.push({
123
+ baseUrl: e.url,
124
+ kind: "public",
125
+ priority: PRIORITY_EXTERNAL_LIST + i
126
+ });
127
+ }
128
+ } catch (err) {
129
+ sources.logger.debug("external endpoint lookup failed", { meta: { error: String(err) } });
130
+ }
131
+ try {
132
+ out.push(...await sources.listLan(HUB_API_PORT));
133
+ } catch (err) {
134
+ sources.logger.debug("LAN endpoint lookup failed", { meta: { error: String(err) } });
135
+ }
136
+ return out;
137
+ }
138
+ var DEFAULT_TTL_MS = 24 * 36e5;
139
+ var DEFAULT_ENDPOINT_CACHE_MS = 6e4;
140
+ var NcArtifactPlane = class {
141
+ deps;
142
+ now;
143
+ cachedBaseUrl = null;
144
+ /** Last base URL announced in the log — so the line appears on CHANGE only. */
145
+ lastLoggedBaseUrl = null;
146
+ cachedAt = 0;
147
+ constructor(deps) {
148
+ this.deps = deps;
149
+ this.now = deps.now ?? (() => Date.now());
150
+ }
151
+ /**
152
+ * Store bytes and mint a signed, externally-reachable URL for them.
153
+ * Returns `null` when no usable base URL exists (loopback only) — the caller
154
+ * then ships bytes alone, exactly as before. Never throws: an artifact URL is
155
+ * an ENHANCEMENT, and failing to mint one must not cost the notification.
156
+ */
157
+ async publish(bytes, mime) {
158
+ try {
159
+ const baseUrl = await this.resolveBaseUrl();
160
+ if (baseUrl === null) return null;
161
+ const artifact = await this.deps.store.put(bytes, mime);
162
+ return buildArtifactUrl({
163
+ baseUrl,
164
+ routePrefix: this.deps.routePrefix,
165
+ id: artifact.id,
166
+ secret: this.deps.secret,
167
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
168
+ });
169
+ } catch (err) {
170
+ this.deps.logger.debug("artifact publish failed — shipping bytes only", { meta: { error: String(err) } });
171
+ return null;
172
+ }
173
+ }
174
+ /** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
175
+ handler = async (req, res) => {
176
+ const url = new URL(req.url ?? "/", "http://placeholder");
177
+ const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
178
+ if (!verifyArtifactSignature({
179
+ secret: this.deps.secret,
180
+ id,
181
+ exp: Number(url.searchParams.get("exp")),
182
+ sig: url.searchParams.get("sig") ?? "",
183
+ nowMs: this.now()
184
+ })) {
185
+ res.writeHead(404, { "content-type": "text/plain" });
186
+ res.end("not found");
187
+ return;
188
+ }
189
+ const found = await this.deps.store.read(id);
190
+ if (found === null) {
191
+ res.writeHead(404, { "content-type": "text/plain" });
192
+ res.end("not found");
193
+ return;
194
+ }
195
+ res.writeHead(200, {
196
+ "content-type": found.mime,
197
+ "content-length": String(found.bytes.byteLength),
198
+ "cache-control": "private, max-age=3600"
199
+ });
200
+ res.end(found.bytes);
201
+ };
202
+ /** The chosen base URL, cached briefly (the interface list barely moves). */
203
+ async resolveBaseUrl() {
204
+ const ttl = this.deps.endpointCacheMs ?? DEFAULT_ENDPOINT_CACHE_MS;
205
+ if (this.cachedBaseUrl !== null && this.now() - this.cachedAt < ttl) return this.cachedBaseUrl;
206
+ const picked = pickArtifactBaseUrl(await this.deps.listEndpoints());
207
+ if (picked === null) {
208
+ this.deps.logger.debug("no externally-reachable endpoint — artifacts ship as bytes only");
209
+ return null;
210
+ }
211
+ if (picked !== this.lastLoggedBaseUrl) {
212
+ this.deps.logger.info("artifact links will use", { meta: { baseUrl: picked } });
213
+ this.lastLoggedBaseUrl = picked;
214
+ }
215
+ this.cachedBaseUrl = picked;
216
+ this.cachedAt = this.now();
217
+ return picked;
218
+ }
219
+ };
220
+ //#endregion
221
+ //#region src/notification-center/artifact-store.ts
222
+ /**
223
+ * NcArtifactStore — the bytes behind a signed notification-artifact URL.
224
+ *
225
+ * A notification attachment is transient by nature: it exists to be fetched
226
+ * once, by a notifier backend or the recipient's phone, within minutes of the
227
+ * event. So this is a BOUNDED SCRATCH, not a media library — the durable copy
228
+ * of an event's media already lives in the media store, and duplicating it here
229
+ * permanently would grow without limit for no benefit.
230
+ *
231
+ * Two bounds, both enforced on every write (age first, then count): whichever
232
+ * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
233
+ * left behind — the directory is disposable by construction, so a lost file is
234
+ * a 404 on one link, never a corrupt state.
235
+ */
236
+ var DEFAULT_MAX_AGE_MS = 6 * 36e5;
237
+ var DEFAULT_MAX_ENTRIES = 500;
238
+ /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
239
+ function extensionFor(mime) {
240
+ if (mime === "image/jpeg") return "jpg";
241
+ if (mime === "image/gif") return "gif";
242
+ if (mime === "video/mp4") return "mp4";
243
+ if (mime === "image/png") return "png";
244
+ return "bin";
245
+ }
246
+ var NcArtifactStore = class {
247
+ options;
248
+ now;
249
+ maxAgeMs;
250
+ maxEntries;
251
+ /** id → mime, so the route can answer with the right content-type without
252
+ * re-deriving it from the extension on every request. */
253
+ mimeById = /* @__PURE__ */ new Map();
254
+ constructor(options) {
255
+ this.options = options;
256
+ this.now = options.now ?? (() => Date.now());
257
+ this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
258
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
259
+ }
260
+ /** Create the directory and sweep whatever a previous run left behind. */
261
+ async start() {
262
+ await node_fs.promises.mkdir(this.options.dir, { recursive: true });
263
+ await this.sweep();
264
+ }
265
+ /** Persist bytes and return the artifact handle (never throws on sweep). */
266
+ async put(bytes, mime) {
267
+ const id = `${this.now()}-${(0, node_crypto.randomUUID)()}`;
268
+ const file = node_path.default.join(this.options.dir, `${id}.${extensionFor(mime)}`);
269
+ await node_fs.promises.mkdir(this.options.dir, { recursive: true });
270
+ await node_fs.promises.writeFile(file, bytes);
271
+ this.mimeById.set(id, mime);
272
+ await this.sweep();
273
+ return {
274
+ id,
275
+ mime,
276
+ bytes: bytes.byteLength
277
+ };
278
+ }
279
+ /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
280
+ async read(id) {
281
+ if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
282
+ const found = (await this.list()).find((e) => e.id === id);
283
+ if (!found) return null;
284
+ try {
285
+ return {
286
+ bytes: await node_fs.promises.readFile(found.file),
287
+ mime: this.mimeById.get(id) ?? mimeFromExtension(found.file)
288
+ };
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+ /** Delete every artifact (operator cleanup / shutdown). */
294
+ async clear() {
295
+ await node_fs.promises.rm(this.options.dir, {
296
+ recursive: true,
297
+ force: true
298
+ }).catch(() => {});
299
+ this.mimeById.clear();
300
+ }
301
+ /** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
302
+ async sweep() {
303
+ const entries = await this.list();
304
+ const cutoff = this.now() - this.maxAgeMs;
305
+ const doomed = entries.filter((e) => e.mtimeMs < cutoff);
306
+ const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
307
+ doomed.push(...survivors.slice(this.maxEntries));
308
+ for (const e of doomed) try {
309
+ await node_fs.promises.rm(e.file, { force: true });
310
+ this.mimeById.delete(e.id);
311
+ } catch (err) {
312
+ this.options.logger.debug("artifact sweep failed to unlink", { meta: {
313
+ file: e.file,
314
+ error: String(err)
315
+ } });
316
+ }
317
+ }
318
+ async list() {
319
+ let names;
320
+ try {
321
+ names = await node_fs.promises.readdir(this.options.dir);
322
+ } catch {
323
+ return [];
324
+ }
325
+ const out = [];
326
+ for (const name of names) {
327
+ const file = node_path.default.join(this.options.dir, name);
328
+ try {
329
+ const st = await node_fs.promises.stat(file);
330
+ if (!st.isFile()) continue;
331
+ out.push({
332
+ id: name.slice(0, name.lastIndexOf(".")),
333
+ file,
334
+ mtimeMs: st.mtimeMs
335
+ });
336
+ } catch {}
337
+ }
338
+ return out;
339
+ }
340
+ };
341
+ function mimeFromExtension(file) {
342
+ const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
343
+ if (ext === "jpg") return "image/jpeg";
344
+ if (ext === "gif") return "image/gif";
345
+ if (ext === "mp4") return "video/mp4";
346
+ if (ext === "png") return "image/png";
347
+ return "application/octet-stream";
348
+ }
349
+ //#endregion
9
350
  //#region src/pipeline-analytics/videoclips-provider.ts
10
351
  var SOURCE = "analytics";
11
352
  function clipIdFor(eventId, startMs, endMs) {
@@ -4162,6 +4503,42 @@ function attachmentKindPreference(policy, ownerKind) {
4162
4503
  ];
4163
4504
  }
4164
4505
  /**
4506
+ * The kind ladder for an EXPLICIT frame choice. Strict by design — each option
4507
+ * stays inside its own family, because a rule that asked for the clean scene
4508
+ * and received the boxed one (or vice versa) is not "degrading gracefully", it
4509
+ * is answering a different question.
4510
+ *
4511
+ * `boxed` is the one exception: with no annotated frame stored, the CLEAN
4512
+ * scene is the honest fallback (same picture, no annotation) — never a subject
4513
+ * crop, which shows something else entirely.
4514
+ */
4515
+ function framePreference(frame, ownerKind) {
4516
+ if (frame === "cropped") return ownerKind === "track" ? [
4517
+ "thumbnail",
4518
+ "thumbnailSmall",
4519
+ "crop"
4520
+ ] : [
4521
+ "crop",
4522
+ "thumbnail",
4523
+ "thumbnailSmall"
4524
+ ];
4525
+ if (frame === "boxed") return [
4526
+ "fullFrameBoxed",
4527
+ "keyFrame",
4528
+ "keyFrameSmall",
4529
+ "fullFrame"
4530
+ ];
4531
+ return ownerKind === "track" ? [
4532
+ "keyFrame",
4533
+ "keyFrameSmall",
4534
+ "firstFrame"
4535
+ ] : [
4536
+ "fullFrame",
4537
+ "keyFrame",
4538
+ "keyFrameSmall"
4539
+ ];
4540
+ }
4541
+ /**
4165
4542
  * Derive the `best-matching` media signal from a matched rule's condition
4166
4543
  * summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
4167
4544
  * (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
@@ -4190,6 +4567,20 @@ function bestMatchingKindPreference(signal, ownerKind) {
4190
4567
  //#endregion
4191
4568
  //#region src/notification-center/dispatcher.ts
4192
4569
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
4570
+ /** GIF and MP4 are the same cut in two containers — one render request each. */
4571
+ var FOOTAGE_FORMATS = [{
4572
+ flag: "mediaGif",
4573
+ format: "gif",
4574
+ mediaType: "gif",
4575
+ mime: "image/gif",
4576
+ name: "event.gif"
4577
+ }, {
4578
+ flag: "mediaClip",
4579
+ format: "mp4",
4580
+ mediaType: "video",
4581
+ mime: "video/mp4",
4582
+ name: "event.mp4"
4583
+ }];
4193
4584
  var NcDispatcher = class {
4194
4585
  deps;
4195
4586
  targetCache = null;
@@ -4259,14 +4650,19 @@ var NcDispatcher = class {
4259
4650
  async resolveTarget(targetId) {
4260
4651
  const cached = this.cachedTarget(targetId);
4261
4652
  if (cached !== null) return cached;
4653
+ let targets;
4262
4654
  try {
4263
- const targets = await this.deps.listTargets();
4264
- this.targetCache = new Map(targets.map((t) => [t.id, t]));
4265
- this.targetCacheAt = this.now();
4655
+ targets = await this.deps.listTargets();
4266
4656
  } catch (err) {
4267
4657
  this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
4268
4658
  throw err instanceof Error ? err : new Error(String(err));
4269
4659
  }
4660
+ if (targets.length === 0) {
4661
+ this.deps.logger.warn("target catalog came back EMPTY — treating as transient", { meta: { targetId } });
4662
+ throw new Error(`target catalog empty (transient) while resolving ${targetId}`);
4663
+ }
4664
+ this.targetCache = new Map(targets.map((t) => [t.id, t]));
4665
+ this.targetCacheAt = this.now();
4270
4666
  return this.targetCache.get(targetId) ?? null;
4271
4667
  }
4272
4668
  cachedTarget(targetId) {
@@ -4280,7 +4676,7 @@ var NcDispatcher = class {
4280
4676
  const vars = buildTemplateVars(entry, deviceName);
4281
4677
  const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4282
4678
  const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4283
- const attachment = await this.resolveAttachment(entry);
4679
+ const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
4284
4680
  const params = pickParams(entry.payload.params);
4285
4681
  return {
4286
4682
  body,
@@ -4290,7 +4686,7 @@ var NcDispatcher = class {
4290
4686
  tag: entry.ruleId,
4291
4687
  deviceId: subject.deviceId,
4292
4688
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
4293
- ...attachment !== null ? { attachments: [attachment] } : {}
4689
+ ...attachments.length > 0 ? { attachments } : {}
4294
4690
  };
4295
4691
  }
4296
4692
  /**
@@ -4302,9 +4698,142 @@ var NcDispatcher = class {
4302
4698
  * explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
4303
4699
  * then degrades to the plain `best` → `keyFrame` ladders.
4304
4700
  */
4305
- async resolveAttachment(entry) {
4306
- const policy = entry.payload.media;
4701
+ /**
4702
+ * Mint a signed URL for each attachment, alongside the bytes it already
4703
+ * carries. Best-effort per attachment: a failed mint leaves that one
4704
+ * bytes-only rather than costing the notification. Without this a url-mode
4705
+ * target (WhatsApp, gotify) silently received NO media — the degrade engine
4706
+ * drops a bytes-only attachment for them (`attachment:noUrl`).
4707
+ */
4708
+ /**
4709
+ * A failed footage render is best-effort — ship the still, skip the video.
4710
+ *
4711
+ * There is nothing to wait for: the clip ring only grows FORWARD, so a window
4712
+ * it does not already cover will never be covered by retrying. (An earlier
4713
+ * revision deferred delivery here, when clips were cut from finalized
4714
+ * recording segments; retrying under the pre-buffer would just age the
4715
+ * pre-roll out of the ring.)
4716
+ */
4717
+ notePendingFootage(entry, err, what) {
4718
+ this.deps.logger.debug(`${what} attachment render failed`, {
4719
+ tags: { deviceId: entry.payload.subject.deviceId },
4720
+ meta: {
4721
+ error: String(err),
4722
+ action: "send-without-video"
4723
+ }
4724
+ });
4725
+ }
4726
+ async withArtifactUrls(attachments) {
4727
+ const publish = this.deps.publishArtifact;
4728
+ if (publish === void 0) return [...attachments];
4729
+ const out = [];
4730
+ for (const att of attachments) {
4731
+ const url = await publish(att.bytes, att.mime).catch(() => null);
4732
+ out.push(url === null ? { ...att } : {
4733
+ ...att,
4734
+ url
4735
+ });
4736
+ }
4737
+ return out;
4738
+ }
4739
+ /** The full attachment list: the policy still (zone-cropped when the rule
4740
+ * froze zone ids) plus the footage cut from the broker's clip ring when
4741
+ * requested. Each part is best-effort — a failed crop falls back to the
4742
+ * uncropped still, a failed render just omits the video. */
4743
+ async resolveAttachments(entry) {
4744
+ const out = [];
4745
+ const zoneIdsWanted = entry.payload.mediaZoneIds;
4746
+ const still = await this.resolveAttachment(entry, zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0);
4747
+ if (still !== null) {
4748
+ const zoneIds = zoneIdsWanted;
4749
+ if (zoneIds !== void 0 && zoneIds.length > 0) {
4750
+ const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
4751
+ out.push(cropped !== null ? {
4752
+ ...still,
4753
+ bytes: cropped,
4754
+ name: "zone.jpg"
4755
+ } : still);
4756
+ } else out.push(still);
4757
+ }
4758
+ for (const want of FOOTAGE_FORMATS) {
4759
+ if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
4760
+ try {
4761
+ const rendered = await this.deps.renderFootage({
4762
+ deviceId: entry.payload.subject.deviceId,
4763
+ aroundMs: entry.payload.subject.timestamp,
4764
+ format: want.format,
4765
+ ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
4766
+ ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
4767
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
4768
+ });
4769
+ if (rendered !== null && rendered.byteLength > 0) {
4770
+ const bytes = new Uint8Array(rendered.byteLength);
4771
+ bytes.set(rendered);
4772
+ out.push({
4773
+ mediaType: want.mediaType,
4774
+ bytes,
4775
+ mime: want.mime,
4776
+ name: want.name
4777
+ });
4778
+ }
4779
+ } catch (err) {
4780
+ this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
4781
+ }
4782
+ }
4783
+ return out;
4784
+ }
4785
+ /** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
4786
+ * pixel rect via sharp metadata). Null on any failure — caller falls back
4787
+ * to the uncropped still. */
4788
+ async zoneCrop(deviceId, zoneIds, jpeg) {
4789
+ try {
4790
+ const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
4791
+ if (points.length === 0) return null;
4792
+ const { default: sharp$7 } = await import("sharp");
4793
+ const img = sharp$7(Buffer.from(jpeg));
4794
+ const meta = await img.metadata();
4795
+ const W = meta.width ?? 0;
4796
+ const H = meta.height ?? 0;
4797
+ if (W === 0 || H === 0) return null;
4798
+ let minX = 1;
4799
+ let minY = 1;
4800
+ let maxX = 0;
4801
+ let maxY = 0;
4802
+ for (const p of points) {
4803
+ if (p.x < minX) minX = p.x;
4804
+ if (p.y < minY) minY = p.y;
4805
+ if (p.x > maxX) maxX = p.x;
4806
+ if (p.y > maxY) maxY = p.y;
4807
+ }
4808
+ if (maxX <= minX || maxY <= minY) return null;
4809
+ const padX = (maxX - minX) * .1;
4810
+ const padY = (maxY - minY) * .1;
4811
+ const left = Math.max(0, Math.floor((minX - padX) * W));
4812
+ const top = Math.max(0, Math.floor((minY - padY) * H));
4813
+ const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
4814
+ const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
4815
+ if (width < 16 || height < 16) return null;
4816
+ const outBuf = await img.extract({
4817
+ left,
4818
+ top,
4819
+ width,
4820
+ height
4821
+ }).jpeg({ quality: 82 }).toBuffer();
4822
+ const bytes = new Uint8Array(outBuf.byteLength);
4823
+ bytes.set(outBuf);
4824
+ return bytes;
4825
+ } catch (err) {
4826
+ this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
4827
+ deviceId,
4828
+ error: String(err)
4829
+ } });
4830
+ return null;
4831
+ }
4832
+ }
4833
+ async resolveAttachment(entry, policyOverride) {
4834
+ const policy = policyOverride ?? entry.payload.media;
4307
4835
  if (policy === "none") return null;
4836
+ const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
4308
4837
  const subject = entry.payload.subject;
4309
4838
  const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
4310
4839
  const owners = [];
@@ -4316,11 +4845,11 @@ var NcDispatcher = class {
4316
4845
  kind: "track",
4317
4846
  id: subject.trackId
4318
4847
  });
4319
- if (policy === "keyFrame") owners.reverse();
4848
+ if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
4320
4849
  for (const owner of owners) try {
4321
4850
  const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
4322
4851
  if (files.length === 0) continue;
4323
- const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4852
+ const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4324
4853
  for (const kind of preference) {
4325
4854
  const file = files.find((f) => f.kind === kind);
4326
4855
  if (file === void 0) continue;
@@ -4642,7 +5171,7 @@ var NC_OCCUPANCY_INDEXES = [{
4642
5171
  }];
4643
5172
  /** Query cap — a per-(device, zone, class) key set is small; this is a
4644
5173
  * generous ceiling that still bounds a pathological read. */
4645
- var LOAD_LIMIT = 1e5;
5174
+ var LOAD_LIMIT$1 = 1e5;
4646
5175
  var OccupancyStore = class {
4647
5176
  cache = /* @__PURE__ */ new Map();
4648
5177
  store;
@@ -4669,7 +5198,7 @@ var OccupancyStore = class {
4669
5198
  try {
4670
5199
  const records = await this.store.query.query({
4671
5200
  collection: NC_OCCUPANCY_COLLECTION,
4672
- filter: { limit: LOAD_LIMIT }
5201
+ filter: { limit: LOAD_LIMIT$1 }
4673
5202
  });
4674
5203
  this.cache.clear();
4675
5204
  let skipped = 0;
@@ -5428,25 +5957,273 @@ var NcRuleStore = class {
5428
5957
  this.byId.delete(ruleId);
5429
5958
  try {
5430
5959
  await this.store.delete.mutate({
5431
- collection: NC_RULES_COLLECTION,
5960
+ collection: NC_RULES_COLLECTION,
5961
+ key: ruleId
5962
+ });
5963
+ } catch (err) {
5964
+ this.logger.warn("notification rule delete failed", { meta: {
5965
+ ruleId,
5966
+ error: String(err)
5967
+ } });
5968
+ throw err instanceof Error ? err : new Error(String(err));
5969
+ }
5970
+ }
5971
+ async persist(rule) {
5972
+ await this.store.set.mutate({
5973
+ collection: NC_RULES_COLLECTION,
5974
+ key: rule.id,
5975
+ value: {
5976
+ name: rule.name,
5977
+ enabled: rule.enabled,
5978
+ delivery: rule.delivery,
5979
+ updatedAt: rule.updatedAt,
5980
+ rule
5981
+ }
5982
+ });
5983
+ }
5984
+ };
5985
+ //#endregion
5986
+ //#region src/notification-center/timelapse/timelapse-store.ts
5987
+ var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
5988
+ var NC_TIMELAPSE_RULES_COLUMNS = [
5989
+ {
5990
+ name: "id",
5991
+ type: "TEXT",
5992
+ primaryKey: true,
5993
+ notNull: true
5994
+ },
5995
+ {
5996
+ name: "name",
5997
+ type: "TEXT",
5998
+ notNull: true
5999
+ },
6000
+ {
6001
+ name: "enabled",
6002
+ type: "BOOLEAN",
6003
+ notNull: true
6004
+ },
6005
+ {
6006
+ name: "updatedAt",
6007
+ type: "INTEGER",
6008
+ notNull: true
6009
+ },
6010
+ (
6011
+ /** The FULL rule object (Zod-validated on read) — scalars above are
6012
+ * indexed projections only. */
6013
+ {
6014
+ name: "rule",
6015
+ type: "JSON",
6016
+ notNull: true
6017
+ })
6018
+ ];
6019
+ var NC_TIMELAPSE_RULES_INDEXES = [{
6020
+ name: "idx_nc_timelapse_rules_enabled",
6021
+ columns: ["enabled"]
6022
+ }];
6023
+ /** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
6024
+ var LOAD_LIMIT = 1e4;
6025
+ /**
6026
+ * Resolve the three-way `template` patch signal onto a merged rule, immutably:
6027
+ * `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
6028
+ * replaces it. Keeping `null` out of the persisted rule is what lets
6029
+ * `TimelapseRuleSchema` stay a plain `.optional()`.
6030
+ */
6031
+ function applyTemplatePatch(merged, template) {
6032
+ if (template === void 0) return merged;
6033
+ if (template !== null) return {
6034
+ ...merged,
6035
+ template
6036
+ };
6037
+ const { template: _cleared, ...withoutTemplate } = merged;
6038
+ return withoutTemplate;
6039
+ }
6040
+ var TimelapseStore = class {
6041
+ byId = /* @__PURE__ */ new Map();
6042
+ store;
6043
+ logger;
6044
+ now;
6045
+ newId;
6046
+ constructor(deps) {
6047
+ this.store = deps.store;
6048
+ this.logger = deps.logger;
6049
+ this.now = deps.now ?? (() => Date.now());
6050
+ this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
6051
+ }
6052
+ static async declare(store) {
6053
+ await store.declareCollection.mutate({
6054
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6055
+ columns: [...NC_TIMELAPSE_RULES_COLUMNS],
6056
+ indexes: [...NC_TIMELAPSE_RULES_INDEXES]
6057
+ });
6058
+ }
6059
+ /**
6060
+ * (Re)hydrate the FULL rule set from the store — called at boot and on the
6061
+ * periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
6062
+ * longer validates is skipped with a warning (a degraded rule must never
6063
+ * crash the scheduler).
6064
+ */
6065
+ async load() {
6066
+ try {
6067
+ const rows = await this.store.query.query({
6068
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6069
+ filter: { limit: LOAD_LIMIT }
6070
+ });
6071
+ this.byId.clear();
6072
+ let skipped = 0;
6073
+ for (const row of rows) {
6074
+ const parsed = require_dist.TimelapseRuleSchema.safeParse(row.data["rule"]);
6075
+ if (!parsed.success) {
6076
+ skipped += 1;
6077
+ continue;
6078
+ }
6079
+ this.byId.set(parsed.data.id, parsed.data);
6080
+ }
6081
+ this.logger.debug("timelapse rules loaded", { meta: {
6082
+ rules: this.byId.size,
6083
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
6084
+ } });
6085
+ } catch (err) {
6086
+ this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
6087
+ }
6088
+ }
6089
+ /** Every rule, newest-first (admin path). */
6090
+ list() {
6091
+ return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
6092
+ }
6093
+ /** The scheduler's read: only rules that should be ticked. */
6094
+ listEnabled() {
6095
+ return this.list().filter((r) => r.enabled);
6096
+ }
6097
+ get(ruleId) {
6098
+ return this.byId.get(ruleId) ?? null;
6099
+ }
6100
+ /**
6101
+ * Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
6102
+ * userId`) plus every admin/global rule (`ownerUserId` absent). Never another
6103
+ * user's personal rows. Newest-first (inherits {@link list}).
6104
+ *
6105
+ * The caller identity is server-derived; an absent/undefined caller must be
6106
+ * resolved to a fail-closed value by the bridge action BEFORE calling here —
6107
+ * this store never treats a missing caller as admin/global.
6108
+ */
6109
+ listForOwner(userId) {
6110
+ return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
6111
+ }
6112
+ /**
6113
+ * Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
6114
+ * user owns. A global rule (no `ownerUserId`) returns false — global rules
6115
+ * are admin-only, and the bridge action grants admins the mutation without
6116
+ * consulting this check. An unknown rule id is false (fail-closed).
6117
+ */
6118
+ isOwnedBy(ruleId, userId) {
6119
+ const rule = this.byId.get(ruleId);
6120
+ return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
6121
+ }
6122
+ /**
6123
+ * Create a rule. `createdBy` is the SERVER-injected caller userId;
6124
+ * `ownerUserId` is the server-derived owner (omit for an admin/global rule).
6125
+ * Neither is ever read from `input`.
6126
+ *
6127
+ * The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
6128
+ * spread — that schema carries no ownership/provenance keys, so it strips any
6129
+ * that rode in on the blob. Without it, an `ownerUserId` on `input` would
6130
+ * survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
6131
+ * path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
6132
+ * so a future "duplicate rule" action (`create(existingRule, caller)`) would
6133
+ * compile cleanly and silently clone the ORIGINAL owner.
6134
+ */
6135
+ async create(input, createdBy, ownerUserId) {
6136
+ const now = this.now();
6137
+ const rule = require_dist.TimelapseRuleSchema.parse({
6138
+ ...require_dist.TimelapseRuleInputSchema.parse(input),
6139
+ id: this.newId(),
6140
+ ...ownerUserId !== void 0 ? { ownerUserId } : {},
6141
+ createdBy,
6142
+ createdAt: now,
6143
+ updatedAt: now
6144
+ });
6145
+ await this.persist(rule);
6146
+ this.byId.set(rule.id, rule);
6147
+ return rule;
6148
+ }
6149
+ /**
6150
+ * Apply a partial patch. Immutable: returns the NEW rule object. Identity,
6151
+ * ownership and generation state are re-pinned from the existing rule AFTER
6152
+ * the spread — the patch schema carries none of them, and this makes a
6153
+ * hand-built (unparsed) patch object equally unable to re-own a rule.
6154
+ *
6155
+ * `template` is the one clearable field: an absent key leaves it unchanged,
6156
+ * an explicit `null` CLEARS it (the persisted rule loses the key — `null`
6157
+ * never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
6158
+ * note.
6159
+ */
6160
+ async update(ruleId, patch) {
6161
+ const existing = this.byId.get(ruleId);
6162
+ if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
6163
+ const { template, ...rest } = patch;
6164
+ const merged = {
6165
+ ...existing,
6166
+ ...rest,
6167
+ id: existing.id,
6168
+ ownerUserId: existing.ownerUserId,
6169
+ lastGeneratedAt: existing.lastGeneratedAt,
6170
+ createdBy: existing.createdBy,
6171
+ createdAt: existing.createdAt,
6172
+ updatedAt: this.now()
6173
+ };
6174
+ return this.write(applyTemplatePatch(merged, template));
6175
+ }
6176
+ async setEnabled(ruleId, enabled) {
6177
+ return this.update(ruleId, { enabled });
6178
+ }
6179
+ /**
6180
+ * Record a successful generation. `at` is the generation epoch-ms — the
6181
+ * durable state behind the 1-hour re-generation guard. Does NOT bump
6182
+ * `updatedAt` (generation is not an edit of the rule definition).
6183
+ */
6184
+ async markGenerated(ruleId, at) {
6185
+ const existing = this.byId.get(ruleId);
6186
+ if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
6187
+ return this.write({
6188
+ ...existing,
6189
+ lastGeneratedAt: at
6190
+ });
6191
+ }
6192
+ /** Idempotent delete — unknown ids are a no-op. */
6193
+ async delete(ruleId) {
6194
+ this.byId.delete(ruleId);
6195
+ try {
6196
+ await this.store.delete.mutate({
6197
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
5432
6198
  key: ruleId
5433
6199
  });
5434
6200
  } catch (err) {
5435
- this.logger.warn("notification rule delete failed", { meta: {
6201
+ this.logger.warn("timelapse rule delete failed", { meta: {
5436
6202
  ruleId,
5437
6203
  error: String(err)
5438
6204
  } });
5439
6205
  throw err instanceof Error ? err : new Error(String(err));
5440
6206
  }
5441
6207
  }
6208
+ /**
6209
+ * Re-validate a merged candidate, persist it, then cache it. Re-validating
6210
+ * means a patch can never persist a rule that would be skipped at the next
6211
+ * `load()`; provenance/ownership survive because they are spread from the
6212
+ * existing rule and never present on a patch.
6213
+ */
6214
+ async write(candidate) {
6215
+ const rule = require_dist.TimelapseRuleSchema.parse(candidate);
6216
+ await this.persist(rule);
6217
+ this.byId.set(rule.id, rule);
6218
+ return rule;
6219
+ }
5442
6220
  async persist(rule) {
5443
6221
  await this.store.set.mutate({
5444
- collection: NC_RULES_COLLECTION,
6222
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
5445
6223
  key: rule.id,
5446
6224
  value: {
5447
6225
  name: rule.name,
5448
6226
  enabled: rule.enabled,
5449
- delivery: rule.delivery,
5450
6227
  updatedAt: rule.updatedAt,
5451
6228
  rule
5452
6229
  }
@@ -5548,7 +6325,7 @@ function outboxEntryToHistory(entry) {
5548
6325
  }
5549
6326
  };
5550
6327
  }
5551
- var NotificationCenter = class {
6328
+ var NotificationCenter = class NotificationCenter {
5552
6329
  logger;
5553
6330
  rules;
5554
6331
  outbox;
@@ -5610,11 +6387,19 @@ var NotificationCenter = class {
5610
6387
  get ruleStore() {
5611
6388
  return this.rules;
5612
6389
  }
5613
- /** Declare every Notification Center collection (idempotent, boot-time). */
6390
+ /**
6391
+ * Declare every Notification Center collection (idempotent, boot-time).
6392
+ *
6393
+ * EVERY store the module can touch belongs here, including ones whose
6394
+ * feature is not wired yet: an UNDECLARED collection answers 412 on first
6395
+ * use and takes the whole runner down with it. The timelapse store shipped
6396
+ * without this line and was one enable away from doing exactly that.
6397
+ */
5614
6398
  static async declare(store) {
5615
6399
  await NcRuleStore.declare(store);
5616
6400
  await NcOutbox.declare(store);
5617
6401
  await OccupancyStore.declare(store);
6402
+ await TimelapseStore.declare(store);
5618
6403
  }
5619
6404
  /**
5620
6405
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -5799,6 +6584,7 @@ var NotificationCenter = class {
5799
6584
  listRules: async () => ({ rules: [...this.rules.list()] }),
5800
6585
  getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
5801
6586
  createRule: async ({ rule, caller }) => {
6587
+ NotificationCenter.assertHasAddressee(rule.targets, rule.targetUsers);
5802
6588
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
5803
6589
  const created = await this.rules.create(rule, caller.userId);
5804
6590
  this.logger.info("notification rule created", { meta: {
@@ -5810,6 +6596,10 @@ var NotificationCenter = class {
5810
6596
  },
5811
6597
  updateRule: async ({ ruleId, patch, caller }) => {
5812
6598
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
6599
+ if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
6600
+ const existing = this.rules.get(ruleId);
6601
+ NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
6602
+ }
5813
6603
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
5814
6604
  const updated = await this.rules.update(ruleId, safePatch);
5815
6605
  this.logger.info("notification rule updated", { meta: {
@@ -5827,7 +6617,10 @@ var NotificationCenter = class {
5827
6617
  return { success: true };
5828
6618
  },
5829
6619
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
5830
- getConditionCatalog: async () => ({ catalog: [...require_dist.NC_CONDITION_CATALOG] }),
6620
+ getConditionCatalog: async () => ({
6621
+ catalog: [...require_dist.NC_CONDITION_CATALOG],
6622
+ taxonomy: require_dist.NC_TAXONOMY
6623
+ }),
5831
6624
  getHistory: async ({ filter }) => {
5832
6625
  const entries = await this.outbox.queryHistory({
5833
6626
  ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
@@ -5868,26 +6661,76 @@ var NotificationCenter = class {
5868
6661
  async evaluateAndEnqueue(subject, kind) {
5869
6662
  const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
5870
6663
  const candidates = this.rules.listEnabled(delivery);
5871
- if (candidates.length === 0) return;
6664
+ if (candidates.length === 0) {
6665
+ this.logger.debug("no enabled rule for this trigger", {
6666
+ tags: { deviceId: subject.deviceId },
6667
+ meta: {
6668
+ delivery,
6669
+ kind,
6670
+ rulesLoaded: this.rules.list().length
6671
+ }
6672
+ });
6673
+ return;
6674
+ }
5872
6675
  const now = this.now();
5873
6676
  for (const rule of candidates) {
5874
6677
  const evaluation = evaluateRule(rule, subject);
5875
- if (!evaluation.matched) continue;
6678
+ if (!evaluation.matched) {
6679
+ this.logger.debug("rule did not match", {
6680
+ tags: { deviceId: subject.deviceId },
6681
+ meta: {
6682
+ ruleId: rule.id,
6683
+ rule: rule.name,
6684
+ kind,
6685
+ failed: evaluation.failedCondition,
6686
+ classes: subject.classNames
6687
+ }
6688
+ });
6689
+ continue;
6690
+ }
5876
6691
  const key = cooldownKey(rule, subject);
5877
- if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) continue;
5878
- if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn)) > 0) this.lastFiredAt.set(key, now);
6692
+ if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
6693
+ this.logger.debug("rule matched but is cooling down", {
6694
+ tags: { deviceId: subject.deviceId },
6695
+ meta: {
6696
+ ruleId: rule.id,
6697
+ rule: rule.name,
6698
+ key
6699
+ }
6700
+ });
6701
+ continue;
6702
+ }
6703
+ this.logger.info("rule matched — enqueueing", {
6704
+ tags: { deviceId: subject.deviceId },
6705
+ meta: {
6706
+ ruleId: rule.id,
6707
+ rule: rule.name,
6708
+ kind,
6709
+ targets: rule.targets.length
6710
+ }
6711
+ });
6712
+ const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6713
+ if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
5879
6714
  }
5880
6715
  }
5881
- buildEntries(rule, subject, kind, matchedOn) {
6716
+ buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
5882
6717
  const hasEventMedia = kind === "object-event" || kind === "package-event";
5883
6718
  const isTrackScoped = kind === "object-event" || kind === "track-end";
5884
- return rule.targets.filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
6719
+ const direct = new Set(rule.targets.map((t) => t.targetId));
6720
+ return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
5885
6721
  const payload = {
5886
6722
  ruleName: rule.name,
5887
6723
  delivery: rule.delivery,
5888
6724
  priority: rule.priority,
5889
6725
  ...rule.template !== void 0 ? { template: rule.template } : {},
5890
6726
  media: rule.media.attach,
6727
+ ...rule.media.zoneCrop === true && rule.conditions.zones !== void 0 ? { mediaZoneIds: [...rule.conditions.zones.ids] } : {},
6728
+ ...rule.media.frame !== void 0 ? { mediaFrame: rule.media.frame } : {},
6729
+ ...rule.media.profile !== void 0 ? { mediaProfile: rule.media.profile } : {},
6730
+ ...rule.media.gif === true ? { mediaGif: true } : {},
6731
+ ...rule.media.clip === true ? { mediaClip: true } : {},
6732
+ ...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
6733
+ ...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
5891
6734
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
5892
6735
  ...target.params !== void 0 ? { params: target.params } : {},
5893
6736
  subject: {
@@ -5913,6 +6756,63 @@ var NotificationCenter = class {
5913
6756
  };
5914
6757
  });
5915
6758
  }
6759
+ /** rule→user fan-out caches (60 s users/targets; device routes immutable). */
6760
+ userGrantsCache = null;
6761
+ userGrantsAt = 0;
6762
+ ownedTargetsCache = null;
6763
+ ownedTargetsAt = 0;
6764
+ deviceRouteCache = /* @__PURE__ */ new Map();
6765
+ userFanoutUnavailableLogged = false;
6766
+ /**
6767
+ * The personal targets a rule's `targetUsers` resolve to for THIS device:
6768
+ * each addressed user contributes the enabled targets they own — but only
6769
+ * when their `allowedDevices` grant covers the firing camera (admin users
6770
+ * always pass). A user must never be notified about a device they cannot
6771
+ * open.
6772
+ */
6773
+ async resolveUserTargets(rule, deviceId) {
6774
+ const users = rule.targetUsers;
6775
+ if (users === void 0 || users.length === 0) return [];
6776
+ const { listUsers, getDeviceRoute } = this.deps;
6777
+ if (listUsers === void 0 || getDeviceRoute === void 0) {
6778
+ if (!this.userFanoutUnavailableLogged) {
6779
+ this.userFanoutUnavailableLogged = true;
6780
+ this.logger.warn("rule addresses users but the user fan-out deps are not wired", { meta: { ruleId: rule.id } });
6781
+ }
6782
+ return [];
6783
+ }
6784
+ const now = this.now();
6785
+ if (this.userGrantsCache === null || now - this.userGrantsAt > 6e4) {
6786
+ this.userGrantsCache = await listUsers();
6787
+ this.userGrantsAt = now;
6788
+ }
6789
+ if (this.ownedTargetsCache === null || now - this.ownedTargetsAt > 6e4) {
6790
+ const targets = await this.deps.dispatcher.listTargets();
6791
+ const owned = /* @__PURE__ */ new Map();
6792
+ for (const t of targets) {
6793
+ if (t.ownerUserId === void 0 || !t.enabled) continue;
6794
+ const list = owned.get(t.ownerUserId) ?? [];
6795
+ list.push(t.id);
6796
+ owned.set(t.ownerUserId, list);
6797
+ }
6798
+ this.ownedTargetsCache = owned;
6799
+ this.ownedTargetsAt = now;
6800
+ }
6801
+ if (!this.deviceRouteCache.has(deviceId)) this.deviceRouteCache.set(deviceId, await getDeviceRoute(deviceId));
6802
+ const route = this.deviceRouteCache.get(deviceId) ?? null;
6803
+ const out = [];
6804
+ for (const userId of users) {
6805
+ const user = this.userGrantsCache.find((u) => u.id === userId);
6806
+ if (user === void 0) continue;
6807
+ if (!user.isAdmin) {
6808
+ if (route === null) continue;
6809
+ const grant = user.allowedDevices[route.addonId];
6810
+ if (!(grant === "*" || Array.isArray(grant) && grant.includes(route.stableId))) continue;
6811
+ }
6812
+ for (const targetId of this.ownedTargetsCache.get(userId) ?? []) out.push({ targetId });
6813
+ }
6814
+ return out;
6815
+ }
5916
6816
  /** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
5917
6817
  async seedCooldowns() {
5918
6818
  const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
@@ -6026,10 +6926,21 @@ var NotificationCenter = class {
6026
6926
  this.drainTicks += 1;
6027
6927
  if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
6028
6928
  }
6929
+ /** The "at least one addressee" invariant lives here, not in Zod: the
6930
+ * schema allows empty `targets` (a users-only rule) and a cross-field
6931
+ * refine would break `NcRulePatchSchema.partial()`. */
6932
+ static assertHasAddressee(targets, targetUsers) {
6933
+ if (targets.length === 0 && (targetUsers?.length ?? 0) === 0) throw new Error("a rule needs at least one delivery target or user");
6934
+ }
6029
6935
  /** Rule-save referential check: every targetId must resolve in the
6030
6936
  * live notification-output catalog (spec §2.3 save-time validation). */
6031
6937
  async validateTargetRefs(targetIds) {
6938
+ if (targetIds.length === 0) return;
6032
6939
  const targets = await this.deps.dispatcher.listTargets();
6940
+ if (targets.length === 0) {
6941
+ this.logger.warn("target catalog came back EMPTY at rule save — the notification-output catalog is unreachable or under-reporting (collection fan-out regression?). Skipping the referential check; delivery re-checks with retry semantics.", { meta: { targetIds: [...targetIds] } });
6942
+ return;
6943
+ }
6033
6944
  const known = new Set(targets.map((t) => t.id));
6034
6945
  for (const id of targetIds) if (!known.has(id)) throw new Error(`unknown notification target: ${id}`);
6035
6946
  }
@@ -6144,15 +7055,28 @@ function makeNcActionHandlers(deps) {
6144
7055
  if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
6145
7056
  return rule;
6146
7057
  };
6147
- const assertRuleOwned = (ruleId, userId) => {
7058
+ /**
7059
+ * A rule the caller may EDIT. Ownership binds non-admins; an ADMIN
7060
+ * administers every rule — global ones (`ownerUserId: undefined`, what the
7061
+ * admin UI creates) and, for support, a user's personal rule.
7062
+ *
7063
+ * Without the admin bypass the comparison `rule.ownerUserId === userId` is
7064
+ * false for EVERY caller on a global rule, so the viewer refused to let an
7065
+ * admin edit an admin rule (operator report, 2026-07-30). Same bypass shape
7066
+ * as `assertTargetsOwned`.
7067
+ */
7068
+ const assertRuleEditable = (ruleId, caller) => {
6148
7069
  const rule = assertOwnsRule(ruleId);
6149
- if (rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7070
+ if (caller.isAdmin) return rule;
7071
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6150
7072
  return rule;
6151
7073
  };
6152
- /** A rule the caller may SEE — his own personal rule OR a global/admin rule. */
6153
- const assertRuleVisible = (ruleId, userId) => {
7074
+ /** A rule the caller may SEE — his own personal rule, a global/admin rule, or
7075
+ * (for an admin) any rule at all. */
7076
+ const assertRuleVisible = (ruleId, caller) => {
6154
7077
  const rule = assertOwnsRule(ruleId);
6155
- if (rule.ownerUserId !== void 0 && rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7078
+ if (caller.isAdmin) return rule;
7079
+ if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6156
7080
  return rule;
6157
7081
  };
6158
7082
  const assertTargetsOwned = async (targetIds, caller) => {
@@ -6163,9 +7087,9 @@ function makeNcActionHandlers(deps) {
6163
7087
  return {
6164
7088
  "nc.listRules": async (_input, caller) => {
6165
7089
  const c = requireCaller(caller);
6166
- return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
7090
+ return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
6167
7091
  ...r,
6168
- readOnly: r.ownerUserId !== c.userId
7092
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
6169
7093
  })) };
6170
7094
  },
6171
7095
  "nc.getConditionCatalog": async () => ({
@@ -6188,7 +7112,7 @@ function makeNcActionHandlers(deps) {
6188
7112
  },
6189
7113
  "nc.updateRule": async (input, caller) => {
6190
7114
  const c = requireCaller(caller);
6191
- assertRuleOwned(input.ruleId, c.userId);
7115
+ assertRuleEditable(input.ruleId, c);
6192
7116
  const patch = require_dist.NcRulePatchSchema.parse(input.patch);
6193
7117
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
6194
7118
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
@@ -6201,7 +7125,7 @@ function makeNcActionHandlers(deps) {
6201
7125
  },
6202
7126
  "nc.deleteRule": async (input, caller) => {
6203
7127
  const c = requireCaller(caller);
6204
- assertRuleOwned(input.ruleId, c.userId);
7128
+ assertRuleEditable(input.ruleId, c);
6205
7129
  await deps.ruleStore.delete(input.ruleId);
6206
7130
  deps.logger.info("nc rule deleted", { meta: {
6207
7131
  ruleId: input.ruleId,
@@ -6211,7 +7135,7 @@ function makeNcActionHandlers(deps) {
6211
7135
  },
6212
7136
  "nc.setRuleTargetEnabled": async (input, caller) => {
6213
7137
  const c = requireCaller(caller);
6214
- assertRuleVisible(input.ruleId, c.userId);
7138
+ assertRuleVisible(input.ruleId, c);
6215
7139
  await assertTargetsOwned([input.targetId], c);
6216
7140
  await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
6217
7141
  return { success: true };
@@ -8863,7 +9787,15 @@ var MEDIA_COLUMNS = [
8863
9787
  name: "sizeBytes",
8864
9788
  type: "INTEGER",
8865
9789
  notNull: true
8866
- }
9790
+ },
9791
+ (
9792
+ /** Storage location holding the blob. NULL = the default `eventMedia`
9793
+ * location (every pre-Phase-3 row, and every write until multi-location
9794
+ * events exist) — the relocate mover stamps real ids as it moves blobs. */
9795
+ {
9796
+ name: "locationId",
9797
+ type: "TEXT"
9798
+ })
8867
9799
  ];
8868
9800
  var MEDIA_INDEXES = [{
8869
9801
  name: "idx_media_owner",
@@ -8872,11 +9804,18 @@ var MEDIA_INDEXES = [{
8872
9804
  name: "idx_media_device_ts",
8873
9805
  columns: ["deviceId", "timestamp"]
8874
9806
  }];
9807
+ /** The storage location of a media row/record: its stamped `locationId`, or
9808
+ * the default `eventMedia` location for NULL (pre-multi-location) rows. */
9809
+ var DEFAULT_MEDIA_LOCATION = "eventMedia";
9810
+ function mediaRowLocation(data) {
9811
+ const id = data?.["locationId"];
9812
+ return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
9813
+ }
8875
9814
  function buildKey(params) {
8876
9815
  return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
8877
9816
  }
8878
9817
  function buildPath(params) {
8879
- const base = `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}`;
9818
+ const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
8880
9819
  return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
8881
9820
  }
8882
9821
  var MediaStore = class {
@@ -8907,7 +9846,8 @@ var MediaStore = class {
8907
9846
  kind: params.kind,
8908
9847
  timestamp: params.timestamp,
8909
9848
  path,
8910
- sizeBytes: params.data.length
9849
+ sizeBytes: params.data.length,
9850
+ locationId: null
8911
9851
  };
8912
9852
  try {
8913
9853
  await this.storage.write({
@@ -8964,10 +9904,11 @@ var MediaStore = class {
8964
9904
  const newKey = await this.put(params);
8965
9905
  for (const row of existing) {
8966
9906
  if (row.id === newKey) continue;
8967
- const path = String(row.data["path"] ?? "");
9907
+ const rowData = row.data;
9908
+ const path = String(rowData["path"] ?? "");
8968
9909
  if (path) try {
8969
9910
  await this.storage.delete({
8970
- location: "eventMedia",
9911
+ location: mediaRowLocation(rowData),
8971
9912
  relativePath: path
8972
9913
  });
8973
9914
  } catch {}
@@ -9035,7 +9976,7 @@ var MediaStore = class {
9035
9976
  key,
9036
9977
  kind,
9037
9978
  base64: (await this.storage.read({
9038
- location: "eventMedia",
9979
+ location: mediaRowLocation(data),
9039
9980
  relativePath: path
9040
9981
  })).toString("base64"),
9041
9982
  sizeBytes,
@@ -9055,10 +9996,11 @@ var MediaStore = class {
9055
9996
  key
9056
9997
  });
9057
9998
  if (!row) return;
9058
- const path = String(row["path"] ?? "");
9999
+ const data = row;
10000
+ const path = String(data["path"] ?? "");
9059
10001
  if (path) try {
9060
10002
  await this.storage.delete({
9061
- location: "eventMedia",
10003
+ location: mediaRowLocation(data),
9062
10004
  relativePath: path
9063
10005
  });
9064
10006
  } catch {}
@@ -9097,7 +10039,7 @@ var MediaStore = class {
9097
10039
  const kind = String(data["kind"]);
9098
10040
  try {
9099
10041
  const buf = await this.storage.read({
9100
- location: "eventMedia",
10042
+ location: mediaRowLocation(data),
9101
10043
  relativePath: path
9102
10044
  });
9103
10045
  files.push({
@@ -9132,10 +10074,11 @@ var MediaStore = class {
9132
10074
  } }
9133
10075
  });
9134
10076
  for (const row of rows) {
9135
- const path = String(row.data["path"] ?? "");
10077
+ const rowData = row.data;
10078
+ const path = String(rowData["path"] ?? "");
9136
10079
  try {
9137
10080
  if (path) await this.storage.delete({
9138
- location: "eventMedia",
10081
+ location: mediaRowLocation(rowData),
9139
10082
  relativePath: path
9140
10083
  });
9141
10084
  } catch {}
@@ -9231,14 +10174,14 @@ var MediaStore = class {
9231
10174
  kind,
9232
10175
  timestamp,
9233
10176
  data: await this.storage.read({
9234
- location: "eventMedia",
10177
+ location: mediaRowLocation(meta),
9235
10178
  relativePath: oldPath
9236
10179
  })
9237
10180
  };
9238
10181
  const newKey = await this.put(newParams);
9239
10182
  try {
9240
10183
  await this.storage.delete({
9241
- location: "eventMedia",
10184
+ location: mediaRowLocation(meta),
9242
10185
  relativePath: oldPath
9243
10186
  });
9244
10187
  } catch (err) {
@@ -9308,10 +10251,11 @@ var MediaStore = class {
9308
10251
  if (rows.length === 0) break;
9309
10252
  let deletedInPage = 0;
9310
10253
  for (const row of rows) {
9311
- const path = String(row.data["path"] ?? "");
10254
+ const rowData = row.data;
10255
+ const path = String(rowData["path"] ?? "");
9312
10256
  try {
9313
10257
  if (path) await this.storage.delete({
9314
- location: "eventMedia",
10258
+ location: mediaRowLocation(rowData),
9315
10259
  relativePath: path
9316
10260
  });
9317
10261
  } catch {}
@@ -9867,6 +10811,7 @@ var EventStore = class {
9867
10811
  const rows = await this.store.query.query({
9868
10812
  collection,
9869
10813
  filter: {
10814
+ ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
9870
10815
  whereBetween: { timestamp: [0, cutoffMs] },
9871
10816
  limit: 500
9872
10817
  }
@@ -10267,6 +11212,183 @@ function stripNulls(data) {
10267
11212
  return out;
10268
11213
  }
10269
11214
  //#endregion
11215
+ //#region src/pipeline-analytics/location-aware-media-storage.ts
11216
+ /**
11217
+ * Location-aware blob storage for event media (entity-routing spec, Phase 3).
11218
+ *
11219
+ * Media rows may carry a `locationId` (stamped by the relocate mover when a
11220
+ * blob is moved off the default location). The write-rate bypass provider
11221
+ * only knows the default media root — this wrapper routes any OTHER location
11222
+ * id through a resolver (the storage cap's `resolve`, cached forever: a
11223
+ * location's root only changes via operator reconfig, which restarts us) and
11224
+ * does direct fs I/O against that root, keeping the bypass's
11225
+ * no-RPC-per-blob property for every location.
11226
+ */
11227
+ function createLocationAwareMediaStorage(deps) {
11228
+ const roots = /* @__PURE__ */ new Map();
11229
+ const rootOf = async (locationId) => {
11230
+ const cached = roots.get(locationId);
11231
+ if (cached !== void 0) return cached;
11232
+ const root = await deps.resolveRoot(locationId);
11233
+ roots.set(locationId, root);
11234
+ return root;
11235
+ };
11236
+ const absOf = async (locationId, relativePath) => node_path.default.join(await rootOf(locationId), relativePath);
11237
+ return {
11238
+ write: async (input) => {
11239
+ if (input.location === deps.defaultLocation) return deps.base.write(input);
11240
+ const abs = await absOf(input.location, input.relativePath);
11241
+ await node_fs.promises.mkdir(node_path.default.dirname(abs), { recursive: true });
11242
+ await node_fs.promises.writeFile(abs, input.data);
11243
+ },
11244
+ read: async (input) => {
11245
+ if (input.location === deps.defaultLocation) return deps.base.read(input);
11246
+ return node_fs.promises.readFile(await absOf(input.location, input.relativePath));
11247
+ },
11248
+ delete: async (input) => {
11249
+ if (input.location === deps.defaultLocation) return deps.base.delete(input);
11250
+ await node_fs.promises.rm(await absOf(input.location, input.relativePath), { force: true });
11251
+ }
11252
+ };
11253
+ }
11254
+ //#endregion
11255
+ //#region src/pipeline-analytics/media-relocate-engine.ts
11256
+ var PAGE_SIZE = 200;
11257
+ var DEFAULT_THROTTLE_MBPS = 40;
11258
+ function snapshot(j) {
11259
+ return {
11260
+ jobId: j.jobId,
11261
+ state: j.state,
11262
+ fromLocationId: j.fromLocationId,
11263
+ toLocationId: j.toLocationId,
11264
+ deviceId: j.deviceId,
11265
+ entities: ["media"],
11266
+ filesMoved: j.filesMoved,
11267
+ bytesMoved: j.bytesMoved,
11268
+ filesTotal: null,
11269
+ startedAt: j.startedAt,
11270
+ finishedAt: j.finishedAt,
11271
+ error: j.error
11272
+ };
11273
+ }
11274
+ var MediaRelocateEngine = class {
11275
+ deps;
11276
+ jobs = /* @__PURE__ */ new Map();
11277
+ constructor(deps) {
11278
+ this.deps = deps;
11279
+ }
11280
+ list() {
11281
+ return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
11282
+ }
11283
+ cancel(jobId) {
11284
+ const job = this.jobs.get(jobId);
11285
+ if (!job || job.state !== "running") return false;
11286
+ job.cancelRequested = true;
11287
+ return true;
11288
+ }
11289
+ start(input) {
11290
+ for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
11291
+ const job = {
11292
+ jobId: this.deps.newId(),
11293
+ state: "running",
11294
+ fromLocationId: "*",
11295
+ toLocationId: input.toLocationId,
11296
+ deviceId: input.deviceId ?? null,
11297
+ filesMoved: 0,
11298
+ bytesMoved: 0,
11299
+ startedAt: this.deps.now(),
11300
+ finishedAt: null,
11301
+ error: null,
11302
+ cancelRequested: false
11303
+ };
11304
+ this.jobs.set(job.jobId, job);
11305
+ this.run(job, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
11306
+ return job.jobId;
11307
+ }
11308
+ async run(job, throttleMbps) {
11309
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
11310
+ const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
11311
+ try {
11312
+ await this.deps.resolveTargetRoot(job.toLocationId);
11313
+ let cursor = 0;
11314
+ let seenAtCursor = /* @__PURE__ */ new Set();
11315
+ for (;;) {
11316
+ if (job.cancelRequested) break;
11317
+ const fresh = (await this.deps.store.query.query({
11318
+ collection: MEDIA_COLLECTION,
11319
+ filter: {
11320
+ ...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
11321
+ whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
11322
+ orderBy: {
11323
+ field: "timestamp",
11324
+ direction: "asc"
11325
+ },
11326
+ limit: PAGE_SIZE
11327
+ }
11328
+ })).filter((r) => !seenAtCursor.has(r.id));
11329
+ if (fresh.length === 0) break;
11330
+ for (const row of fresh) {
11331
+ if (job.cancelRequested) break;
11332
+ const data = row.data;
11333
+ const from = mediaRowLocation(data);
11334
+ if (from === job.toLocationId) continue;
11335
+ const relativePath = String(data["path"] ?? "");
11336
+ if (relativePath.length === 0) continue;
11337
+ try {
11338
+ const bytes = await this.deps.storage.read({
11339
+ location: from,
11340
+ relativePath
11341
+ });
11342
+ await this.deps.storage.write({
11343
+ location: job.toLocationId,
11344
+ relativePath,
11345
+ data: bytes
11346
+ });
11347
+ await this.deps.store.set.mutate({
11348
+ collection: MEDIA_COLLECTION,
11349
+ key: row.id,
11350
+ value: {
11351
+ ...data,
11352
+ locationId: job.toLocationId
11353
+ }
11354
+ });
11355
+ await this.deps.storage.delete({
11356
+ location: from,
11357
+ relativePath
11358
+ });
11359
+ job.filesMoved++;
11360
+ job.bytesMoved += bytes.length;
11361
+ await sleep(bytes.length / bytesPerMs);
11362
+ } catch (err) {
11363
+ this.deps.logger.debug("media relocate row failed", { meta: {
11364
+ key: row.id,
11365
+ error: String(err)
11366
+ } });
11367
+ }
11368
+ }
11369
+ const last = fresh[fresh.length - 1];
11370
+ const lastTs = Number(last.data["timestamp"] ?? cursor);
11371
+ if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
11372
+ else {
11373
+ cursor = lastTs;
11374
+ seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
11375
+ }
11376
+ }
11377
+ job.state = job.cancelRequested ? "cancelled" : "done";
11378
+ } catch (err) {
11379
+ job.state = "failed";
11380
+ job.error = err instanceof Error ? err.message : String(err);
11381
+ this.deps.logger.warn("media relocate job failed", { meta: {
11382
+ jobId: job.jobId,
11383
+ error: job.error
11384
+ } });
11385
+ } finally {
11386
+ job.finishedAt = this.deps.now();
11387
+ this.deps.onFinished?.(snapshot(job));
11388
+ }
11389
+ }
11390
+ };
11391
+ //#endregion
10270
11392
  //#region src/pipeline-analytics/store/sensor-event-store.ts
10271
11393
  var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
10272
11394
  var SENSOR_EVENT_COLUMNS = [
@@ -11393,7 +12515,7 @@ var EventMediaDispatcher = class {
11393
12515
  if (sn.rollingLastFrame && boxed) lastFrameWritten = await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
11394
12516
  let thumbnailWritten = false;
11395
12517
  if (sn.bestThumbnail) {
11396
- const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
12518
+ const variants = await this.cropSubjectVariants(deviceId, frameHandle, fw, fh, sn.bbox);
11397
12519
  if (variants) {
11398
12520
  thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
11399
12521
  await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
@@ -11495,12 +12617,15 @@ var EventMediaDispatcher = class {
11495
12617
  * per-frame retry lands a real native crop later. Never a local resize
11496
12618
  * upscale of a ≤640 tile (a blurred lie).
11497
12619
  */
11498
- async cropSubjectVariants(frameHandle, fw, fh, bbox) {
12620
+ async cropSubjectVariants(deviceId, frameHandle, fw, fh, bbox) {
11499
12621
  if (!this.deps.getNativeCropJpeg) {
11500
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11501
- shmId: frameHandle.shmId,
11502
- reason: "no-native-cap"
11503
- } });
12622
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12623
+ tags: { deviceId },
12624
+ meta: {
12625
+ shmId: frameHandle.shmId,
12626
+ reason: "no-native-cap"
12627
+ }
12628
+ });
11504
12629
  return null;
11505
12630
  }
11506
12631
  try {
@@ -11508,12 +12633,19 @@ var EventMediaDispatcher = class {
11508
12633
  W: fw,
11509
12634
  H: fh
11510
12635
  });
12636
+ const askedAt = Date.now();
11511
12637
  const slab = await this.deps.getNativeCropJpeg(frameHandle, layout.fetch);
11512
12638
  if (!slab) {
11513
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11514
- shmId: frameHandle.shmId,
11515
- reason: "native-miss"
11516
- } });
12639
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12640
+ tags: { deviceId },
12641
+ meta: {
12642
+ shmId: frameHandle.shmId,
12643
+ handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
12644
+ handleNodeId: frameHandle.nodeId,
12645
+ roundTripMs: Date.now() - askedAt,
12646
+ reason: "native-miss"
12647
+ }
12648
+ });
11517
12649
  return null;
11518
12650
  }
11519
12651
  const thumbnail = await composeWideCentralSquareThumbnail(slab, layout);
@@ -11522,10 +12654,14 @@ var EventMediaDispatcher = class {
11522
12654
  thumbnailSmall: await deriveThumbnailSmall(thumbnail)
11523
12655
  };
11524
12656
  } catch (err) {
11525
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11526
- shmId: frameHandle.shmId,
11527
- error: err instanceof Error ? err.message : String(err)
11528
- } });
12657
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12658
+ tags: { deviceId },
12659
+ meta: {
12660
+ shmId: frameHandle.shmId,
12661
+ reason: "threw",
12662
+ error: err instanceof Error ? err.message : String(err)
12663
+ }
12664
+ });
11529
12665
  return null;
11530
12666
  }
11531
12667
  }
@@ -13586,37 +14722,7 @@ var AnalyticsQueryFacade = class {
13586
14722
  }));
13587
14723
  }
13588
14724
  };
13589
- //#endregion
13590
- //#region src/pipeline-analytics/track-retention-sweep.ts
13591
- /**
13592
- * Periodic track retention sweep (design §6) — the piece that makes retention
13593
- * actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
13594
- * calls it; this sweep ages persisted tracks out on the provider's existing
13595
- * retention interval.
13596
- *
13597
- * `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
13598
- * `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
13599
- * cascade store layer (design §4), not here.
13600
- *
13601
- * Kept as a pure, dependency-injected function so the loop is unit-testable
13602
- * without booting the addon.
13603
- */
13604
- var DAY_MS = 1440 * 60 * 1e3;
13605
- /** Per-device track retention setting. Default 7 days; 0 = keep forever. */
13606
- var TrackRetentionSettingsSchema = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) });
13607
- var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
13608
- /** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
13609
- * missing value falls back to the default (parse never throws). */
13610
- function resolveTrackRetentionDays(raw) {
13611
- const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
13612
- return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
13613
- }
13614
- /** Cutoff timestamp for a retention window. `null` when retention is disabled
13615
- * (`retentionDays <= 0` → keep forever, the sweep skips the device). */
13616
- function trackRetentionCutoff(nowMs, retentionDays) {
13617
- if (retentionDays <= 0) return null;
13618
- return nowMs - retentionDays * DAY_MS;
13619
- }
14725
+ var TRACK_RETENTION_DEFAULT_DAYS = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) }).parse({}).trackRetentionDays;
13620
14726
  /**
13621
14727
  * Sweep every device with persisted tracks: skip retention-disabled devices,
13622
14728
  * prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
@@ -13627,7 +14733,7 @@ async function sweepTrackRetention(deps) {
13627
14733
  const nowMs = deps.now();
13628
14734
  let totalTracks = 0;
13629
14735
  for (const deviceId of devices) try {
13630
- const cutoffMs = trackRetentionCutoff(nowMs, await deps.resolveRetentionDays(deviceId));
14736
+ const cutoffMs = await deps.resolveCutoffMs(deviceId, nowMs);
13631
14737
  if (cutoffMs === null) continue;
13632
14738
  const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
13633
14739
  totalTracks += counts.tracks;
@@ -14192,6 +15298,19 @@ function retagDetectionSections(sections) {
14192
15298
  } : s);
14193
15299
  }
14194
15300
  /**
15301
+ * Re-home the analytics `retention` section onto the recorder's `recording`
15302
+ * top-tab (operator ask, 2026-07-29): footage and analytics retention are ONE
15303
+ * unified policy since the follow-recordings default, so their controls
15304
+ * belong on ONE tab. Same pure-retag mechanism as the detection sections —
15305
+ * `DeviceDetail` folds matching-tab sections together, no admin-ui change.
15306
+ */
15307
+ function retagRetentionSection(sections) {
15308
+ return sections.map((s) => s.id === "retention" ? {
15309
+ ...s,
15310
+ tab: "recording"
15311
+ } : s);
15312
+ }
15313
+ /**
14195
15314
  * Fields that live ONLY on the global settings page and must never surface in a
14196
15315
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
14197
15316
  * master kill for the whole subsystem — per-camera face production is governed
@@ -14370,9 +15489,23 @@ function buildGlobalSettingsSchema() {
14370
15489
  {
14371
15490
  id: "retention",
14372
15491
  title: "Retention",
14373
- description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
15492
+ description: "How long analytics history (tracks, events, media) is kept. Default: as long as the camera's recordings — one policy for footage and events. The day windows below apply in Custom mode, and as the bound when the camera has no footage.",
14374
15493
  columns: 3,
14375
15494
  fields: [
15495
+ {
15496
+ type: "select",
15497
+ key: "retentionMode",
15498
+ label: "Mode",
15499
+ description: "Follow recordings: tracks/events live exactly as long as retained footage (age retention AND disk eviction; never prunes the last 7 days). Custom: the day windows below.",
15500
+ default: "follow-recordings",
15501
+ options: [{
15502
+ value: "follow-recordings",
15503
+ label: "Follow recordings (default)"
15504
+ }, {
15505
+ value: "custom",
15506
+ label: "Custom windows"
15507
+ }]
15508
+ },
14376
15509
  {
14377
15510
  type: "number",
14378
15511
  key: "trackRetentionDays",
@@ -14907,6 +16040,86 @@ async function encodeKeyFrameVariants(native) {
14907
16040
  };
14908
16041
  }
14909
16042
  //#endregion
16043
+ //#region src/pipeline-analytics/retention-policy.ts
16044
+ /**
16045
+ * Unified analytics retention policy (storage entity-routing spec, Phase 1).
16046
+ *
16047
+ * Two modes, per device:
16048
+ *
16049
+ * - `follow-recordings` (the DEFAULT): tracks and events live exactly as long
16050
+ * as the camera's retained footage — ONE cutoff, the instant of the oldest
16051
+ * segment still on disk. That follows the recorder's age retention AND its
16052
+ * disk-pressure eviction automatically, with no second policy to keep in
16053
+ * sync. A camera with no footage at all (recording off / brand new) falls
16054
+ * back to the custom day windows below, so analytics stay bounded either
16055
+ * way.
16056
+ *
16057
+ * - `custom`: the per-kind day windows (tracks / motion / object / audio),
16058
+ * exactly the pre-unification behaviour — except the knobs are now actually
16059
+ * READ (they were exposed in settings and silently ignored by the sweep,
16060
+ * which used hardcoded 14/30/7).
16061
+ *
16062
+ * Follow-mode floor: events younger than {@link FOLLOW_FLOOR_DAYS} are never
16063
+ * pruned, even when footage is younger (a camera that STARTED recording
16064
+ * yesterday must not nuke its whole event history back to the first segment).
16065
+ */
16066
+ var DAY_MS = 1440 * 60 * 1e3;
16067
+ /** Per-device analytics retention settings as stored in the device blob.
16068
+ * `retentionMode` absent = follow-recordings (the new default). The day
16069
+ * windows keep their historical defaults and double as the no-footage
16070
+ * fallback in follow mode. */
16071
+ var RetentionSettingsSchema = require_dist.object({
16072
+ retentionMode: require_dist._enum(["follow-recordings", "custom"]).default("follow-recordings"),
16073
+ trackRetentionDays: require_dist.number().min(0).default(7),
16074
+ retentionMotionDays: require_dist.number().min(1).default(14),
16075
+ retentionObjectDays: require_dist.number().min(1).default(30),
16076
+ retentionAudioDays: require_dist.number().min(1).default(7)
16077
+ });
16078
+ /** Resolve the settings from a raw device-store blob — invalid or missing
16079
+ * fields fall back to defaults, never throw. */
16080
+ function resolveRetentionSettings(raw) {
16081
+ const parsed = RetentionSettingsSchema.safeParse(raw);
16082
+ if (parsed.success) return parsed.data;
16083
+ const shape = RetentionSettingsSchema.shape;
16084
+ const field = (k) => shape[k].safeParse(raw[k]).success ? shape[k].parse(raw[k]) : RetentionSettingsSchema.parse({})[k];
16085
+ return {
16086
+ retentionMode: field("retentionMode"),
16087
+ trackRetentionDays: field("trackRetentionDays"),
16088
+ retentionMotionDays: field("retentionMotionDays"),
16089
+ retentionObjectDays: field("retentionObjectDays"),
16090
+ retentionAudioDays: field("retentionAudioDays")
16091
+ };
16092
+ }
16093
+ /**
16094
+ * Follow-mode cutoff: the oldest retained footage instant, floored so
16095
+ * anything younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage
16096
+ * (none on disk) → `null`, the caller falls back to custom windows.
16097
+ */
16098
+ function followCutoffMs(nowMs, earliestFootageMs) {
16099
+ if (earliestFootageMs === null) return null;
16100
+ return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS);
16101
+ }
16102
+ /** Compute the effective cutoffs for one device at `nowMs`. */
16103
+ function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
16104
+ if (settings.retentionMode === "follow-recordings") {
16105
+ const cutoff = followCutoffMs(nowMs, earliestFootageMs);
16106
+ if (cutoff !== null) return {
16107
+ trackCutoffMs: cutoff,
16108
+ motionCutoffMs: cutoff,
16109
+ objectCutoffMs: cutoff,
16110
+ audioCutoffMs: cutoff,
16111
+ effectiveMode: "follow-recordings"
16112
+ };
16113
+ }
16114
+ return {
16115
+ trackCutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS,
16116
+ motionCutoffMs: nowMs - settings.retentionMotionDays * DAY_MS,
16117
+ objectCutoffMs: nowMs - settings.retentionObjectDays * DAY_MS,
16118
+ audioCutoffMs: nowMs - settings.retentionAudioDays * DAY_MS,
16119
+ effectiveMode: "custom"
16120
+ };
16121
+ }
16122
+ //#endregion
14910
16123
  //#region src/pipeline-analytics/store/identity-store.ts
14911
16124
  /**
14912
16125
  * IdentityStore — per-person identity registry for face recognition.
@@ -18293,6 +19506,17 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
18293
19506
  * Absent / empty / non-string all fall back to the hub default — the exact
18294
19507
  * narrowing the old raw read applied inline. */
18295
19508
  var PostProcessingNodeIdSchema = require_dist.string().min(1);
19509
+ /**
19510
+ * Footage-attachment window + geometry, used when the rule states none. The
19511
+ * window is CENTRED on the event, so the recipient sees the approach and what
19512
+ * followed rather than one side of it.
19513
+ */
19514
+ var NC_FOOTAGE_PRE_ROLL_SEC = 3;
19515
+ var NC_FOOTAGE_POST_ROLL_SEC = 5;
19516
+ var NC_FOOTAGE_MAX_WIDTH = 480;
19517
+ var NC_FOOTAGE_FPS = 5;
19518
+ /** Per-install HMAC secret behind the signed artifact links (minted once). */
19519
+ var NcArtifactSecretSchema = require_dist.string();
18296
19520
  var EmbeddingEnabledSchema = require_dist.boolean();
18297
19521
  var SILENCE_FLOOR_DBFS = -55;
18298
19522
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
@@ -18364,6 +19588,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18364
19588
  eventStore = null;
18365
19589
  /** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
18366
19590
  eventsOpsLog = null;
19591
+ /** Event-media relocation engine (entity-routing Phase 4). */
19592
+ mediaRelocate = null;
18367
19593
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
18368
19594
  sensorEventStore = null;
18369
19595
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
@@ -18422,6 +19648,88 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18422
19648
  retentionSweepTimer = null;
18423
19649
  /** Handle for the event-media data-plane listener (dispose on shutdown). */
18424
19650
  eventMediaDataPlane = null;
19651
+ /** The NC artifact plane (signed public links for notification media) and its
19652
+ * data-plane handle. Null until served / when the facility is absent. */
19653
+ ncArtifactPlane = null;
19654
+ ncArtifactDataPlane = null;
19655
+ /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
19656
+ async markedNotificationEndpoint() {
19657
+ try {
19658
+ return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
19659
+ } catch {
19660
+ return;
19661
+ }
19662
+ }
19663
+ /**
19664
+ * Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
19665
+ * each link (see `notification-center/artifact-url.ts`). It exists because
19666
+ * attachments used to carry BYTES only, and the degrade engine drops a
19667
+ * bytes-only attachment for a url-mode backend — WhatsApp and gotify were
19668
+ * receiving no media at all.
19669
+ *
19670
+ * Best-effort at every step: no facility, no secret or no reachable base URL
19671
+ * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
19672
+ */
19673
+ async serveNcArtifactPlane() {
19674
+ try {
19675
+ const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
19676
+ let secret = await secretState.get();
19677
+ if (secret === "") {
19678
+ secret = (0, node_crypto.randomUUID)().replace(/-/g, "");
19679
+ await secretState.set(secret);
19680
+ }
19681
+ const store = new NcArtifactStore({
19682
+ dir: node_path.default.join(this.ctx.dataDir, "nc-artifacts"),
19683
+ logger: this.ctx.logger.child("nc-artifacts")
19684
+ });
19685
+ await store.start();
19686
+ const plane = new NcArtifactPlane({
19687
+ store,
19688
+ secret,
19689
+ logger: this.ctx.logger.child("nc-artifacts"),
19690
+ routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
19691
+ listEndpoints: async () => collectArtifactEndpoints({
19692
+ markedBaseUrl: await this.markedNotificationEndpoint(),
19693
+ configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
19694
+ getConnected: async () => {
19695
+ const status = await this.ctx.api.networkAccess.getStatus.query();
19696
+ this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
19697
+ connected: status.connected,
19698
+ url: status.endpoint?.url ?? null,
19699
+ protocol: status.endpoint?.protocol ?? null
19700
+ } });
19701
+ return status.connected && status.endpoint !== null ? {
19702
+ url: status.endpoint.url,
19703
+ protocol: status.endpoint.protocol
19704
+ } : null;
19705
+ },
19706
+ listExternal: async () => {
19707
+ return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
19708
+ url: e.url,
19709
+ protocol: e.protocol
19710
+ }));
19711
+ },
19712
+ listLan: async (port) => {
19713
+ return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
19714
+ baseUrl: e.baseUrl,
19715
+ kind: e.kind,
19716
+ priority: e.priority
19717
+ }));
19718
+ },
19719
+ logger: this.ctx.logger
19720
+ })
19721
+ });
19722
+ this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
19723
+ prefix: "nc-artifact",
19724
+ access: "public",
19725
+ handler: plane.handler
19726
+ }) ?? null;
19727
+ this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
19728
+ this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
19729
+ } catch (err) {
19730
+ this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
19731
+ }
19732
+ }
18425
19733
  /** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
18426
19734
  * Set once the data-plane is registered; null until then (e.g. no
18427
19735
  * dataPlane facility in the current environment). */
@@ -18725,12 +20033,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18725
20033
  let storage = this.ctx.kernel.storage;
18726
20034
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
18727
20035
  if (mediaRoot) {
18728
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BY0XZAen.js"));
18729
- storage = new FilesystemStorageProvider(mediaRoot);
20036
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Dwh-F2Zf.js"));
20037
+ storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
18730
20038
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
18731
20039
  }
18732
20040
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
18733
- return storage;
20041
+ return createLocationAwareMediaStorage({
20042
+ base: storage,
20043
+ defaultLocation: "eventMedia",
20044
+ resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
20045
+ location: locationId,
20046
+ relativePath: ""
20047
+ })
20048
+ });
18734
20049
  }
18735
20050
  /** Constructs every SQLite-backed store plus the stationary/package-drop/
18736
20051
  * sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
@@ -18778,6 +20093,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18778
20093
  logger: logger.child("MediaStore")
18779
20094
  });
18780
20095
  this.mediaStore = mediaStore;
20096
+ this.mediaRelocate = new MediaRelocateEngine({
20097
+ store: api.settingsStore,
20098
+ storage,
20099
+ logger: logger.child("MediaRelocate"),
20100
+ now: () => Date.now(),
20101
+ newId: () => (0, node_crypto.randomUUID)(),
20102
+ resolveTargetRoot: (locationId) => api.storage.resolve.query({
20103
+ location: locationId,
20104
+ relativePath: ""
20105
+ }),
20106
+ onFinished: (job) => {
20107
+ this.eventsOpsLog?.append({
20108
+ op: "relocate",
20109
+ reason: "operator",
20110
+ deviceId: job.deviceId,
20111
+ itemsAffected: job.filesMoved,
20112
+ bytesReclaimed: 0,
20113
+ detail: `${job.state}: media → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
20114
+ });
20115
+ }
20116
+ });
18781
20117
  const eventStore = new EventStore({
18782
20118
  store: api.settingsStore,
18783
20119
  logger: logger.child("EventStore"),
@@ -19023,15 +20359,59 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19023
20359
  this.notificationCenter = new NotificationCenter({
19024
20360
  store: api.settingsStore,
19025
20361
  logger: logger.child("NotificationCenter"),
20362
+ listUsers: async () => {
20363
+ return (await api.userManagement.listUsers.query()).map((u) => ({
20364
+ id: u.id,
20365
+ isAdmin: u.isAdmin,
20366
+ allowedDevices: u.allowedDevices
20367
+ }));
20368
+ },
20369
+ getDeviceRoute: async (deviceId) => {
20370
+ const device = await api.deviceManager.getDevice.query({ deviceId });
20371
+ return device === null ? null : {
20372
+ stableId: device.stableId,
20373
+ addonId: device.addonId
20374
+ };
20375
+ },
19026
20376
  dispatcher: {
20377
+ getZonePolygons: async (deviceId, zoneIds) => {
20378
+ const zones = await api.zones.listZones.query({ deviceId });
20379
+ const wanted = new Set(zoneIds);
20380
+ return zones.filter((z) => wanted.has(z.id) && Array.isArray(z.polygon)).map((z) => (z.polygon ?? []).map((pt) => ({
20381
+ x: pt.x,
20382
+ y: pt.y
20383
+ })));
20384
+ },
20385
+ renderFootage: async (req) => {
20386
+ const res = await api.streamBroker.renderPreBufferClip.mutate({
20387
+ deviceId: req.deviceId,
20388
+ aroundMs: req.aroundMs,
20389
+ format: req.format,
20390
+ preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
20391
+ postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
20392
+ maxWidth: NC_FOOTAGE_MAX_WIDTH,
20393
+ fps: NC_FOOTAGE_FPS,
20394
+ ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
20395
+ });
20396
+ const buf = Buffer.from(res.base64, "base64");
20397
+ if (buf.byteLength === 0) return null;
20398
+ const bytes = new Uint8Array(buf.byteLength);
20399
+ bytes.set(buf);
20400
+ return bytes;
20401
+ },
20402
+ publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
19027
20403
  listTargets: async () => {
19028
- return (await api.notificationOutput.listTargets.query({})).map((t) => ({
19029
- id: t.id,
19030
- addonId: t.addonId,
19031
- name: t.name,
19032
- kind: t.kind,
19033
- enabled: t.enabled
19034
- }));
20404
+ return (await api.notificationOutput.listTargets.query({})).map((t) => {
20405
+ const owner = t.config["ownerUserId"];
20406
+ return {
20407
+ id: t.id,
20408
+ addonId: t.addonId,
20409
+ name: t.name,
20410
+ kind: t.kind,
20411
+ enabled: t.enabled,
20412
+ ...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
20413
+ };
20414
+ });
19035
20415
  },
19036
20416
  send: async (input) => {
19037
20417
  const { attachments, ...notification } = input.notification;
@@ -19085,6 +20465,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19085
20465
  handler
19086
20466
  }) ?? null;
19087
20467
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
20468
+ await this.serveNcArtifactPlane();
19088
20469
  this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
19089
20470
  } catch (err) {
19090
20471
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
@@ -20729,42 +22110,112 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20729
22110
  if (this.shuttingDown || !this.trackStore) return;
20730
22111
  await this.trackCloser.sweep();
20731
22112
  }
22113
+ /** Unified retention (Phase 1): earliest retained footage per device — the
22114
+ * follow-recordings cutoff source. Cached 10 min: availability answers in
22115
+ * ~70 ms but the sweep asks once per device per pass. `null` = no footage
22116
+ * (or recorder unreachable) → the policy falls back to the custom windows. */
22117
+ earliestFootageCache = /* @__PURE__ */ new Map();
22118
+ async earliestFootageMs(deviceId) {
22119
+ const cached = this.earliestFootageCache.get(deviceId);
22120
+ if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
22121
+ let value = null;
22122
+ try {
22123
+ const res = await this.ctx.api.recording.getAvailability.query({
22124
+ deviceId,
22125
+ fromMs: 0,
22126
+ toMs: Date.now()
22127
+ });
22128
+ let min = Number.POSITIVE_INFINITY;
22129
+ for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
22130
+ value = Number.isFinite(min) ? min : null;
22131
+ } catch {
22132
+ value = null;
22133
+ }
22134
+ this.earliestFootageCache.set(deviceId, {
22135
+ at: Date.now(),
22136
+ value
22137
+ });
22138
+ return value;
22139
+ }
22140
+ /** The per-device effective cutoffs (mode + footage → numbers). */
22141
+ async deviceRetentionCutoffs(deviceId, nowMs) {
22142
+ const settings = resolveRetentionSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
22143
+ return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
22144
+ }
22145
+ /** The devices one retention pass covers: every camera the hub knows plus
22146
+ * any device that still has persisted tracks (covers deleted cameras whose
22147
+ * history must keep aging out). */
22148
+ async retentionDeviceIds() {
22149
+ const ids = /* @__PURE__ */ new Set();
22150
+ try {
22151
+ const all = await this.ctx.api.deviceManager.listAll.query({});
22152
+ for (const d of all) ids.add(d.id);
22153
+ } catch {}
22154
+ try {
22155
+ for (const id of await this.trackStore?.listDeviceIds() ?? []) ids.add(id);
22156
+ } catch {}
22157
+ return [...ids];
22158
+ }
20732
22159
  async sweepRetention() {
20733
22160
  if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
20734
22161
  const now = Date.now();
20735
22162
  const day = 1440 * 60 * 1e3;
20736
- const OBJECT_RETENTION_DAYS = 30;
20737
- const motionCutoffMs = now - 14 * day;
20738
- const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
20739
- const audioCutoffMs = now - 7 * day;
20740
22163
  try {
20741
- const evicted = await this.eventStore.evictBefore({
20742
- motionCutoffMs,
20743
- objectCutoffMs,
20744
- audioCutoffMs
20745
- });
20746
- const evictedIds = [
20747
- ...evicted.motion,
20748
- ...evicted.object,
20749
- ...evicted.audio
20750
- ];
20751
- if (evictedIds.length > 0) {
20752
- await this.mediaStore.deleteForEvents(evictedIds);
20753
- this.ctx.logger.info("analytics event eviction (age sweep)", { meta: {
20754
- motion: evicted.motion.length,
20755
- object: evicted.object.length,
20756
- audio: evicted.audio.length,
20757
- motionCutoffMs,
20758
- objectCutoffMs,
20759
- audioCutoffMs
20760
- } });
22164
+ const deviceIds = await this.retentionDeviceIds();
22165
+ const evictedIds = [];
22166
+ let minObjectCutoffMs = Number.POSITIVE_INFINITY;
22167
+ for (const deviceId of deviceIds) {
22168
+ if (this.shuttingDown) return;
22169
+ try {
22170
+ const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
22171
+ if (cutoffs.objectCutoffMs !== null && cutoffs.objectCutoffMs < minObjectCutoffMs) minObjectCutoffMs = cutoffs.objectCutoffMs;
22172
+ if (cutoffs.motionCutoffMs === null && cutoffs.objectCutoffMs === null && cutoffs.audioCutoffMs === null) continue;
22173
+ const evicted = await this.eventStore.evictBefore({
22174
+ deviceId,
22175
+ motionCutoffMs: cutoffs.motionCutoffMs ?? 0,
22176
+ objectCutoffMs: cutoffs.objectCutoffMs ?? 0,
22177
+ audioCutoffMs: cutoffs.audioCutoffMs ?? 0
22178
+ });
22179
+ const ids = [
22180
+ ...evicted.motion,
22181
+ ...evicted.object,
22182
+ ...evicted.audio
22183
+ ];
22184
+ if (ids.length > 0) {
22185
+ evictedIds.push(...ids);
22186
+ this.eventsOpsLog?.append({
22187
+ op: "prune",
22188
+ reason: "retention",
22189
+ deviceId,
22190
+ itemsAffected: ids.length,
22191
+ bytesReclaimed: 0,
22192
+ detail: `age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.object.length} object, ${evicted.audio.length} audio`
22193
+ });
22194
+ this.ctx.logger.info("analytics event eviction (age sweep)", {
22195
+ tags: { deviceId },
22196
+ meta: {
22197
+ motion: evicted.motion.length,
22198
+ object: evicted.object.length,
22199
+ audio: evicted.audio.length,
22200
+ mode: cutoffs.effectiveMode,
22201
+ objectCutoffMs: cutoffs.objectCutoffMs
22202
+ }
22203
+ });
22204
+ }
22205
+ } catch (err) {
22206
+ this.ctx.logger.debug("event retention sweep (device) failed", {
22207
+ tags: { deviceId },
22208
+ meta: { error: String(err) }
22209
+ });
22210
+ }
20761
22211
  }
20762
- await this.mediaStore.evictBefore(now - 31 * day);
20763
- if (this.sensorEventStore) try {
20764
- const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
22212
+ if (evictedIds.length > 0) await this.mediaStore.deleteForEvents(evictedIds);
22213
+ if (Number.isFinite(minObjectCutoffMs)) await this.mediaStore.evictBefore(minObjectCutoffMs - 1 * day);
22214
+ if (this.sensorEventStore && Number.isFinite(minObjectCutoffMs)) try {
22215
+ const sensorDeleted = await this.sensorEventStore.evictBefore(minObjectCutoffMs);
20765
22216
  if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
20766
22217
  deleted: sensorDeleted,
20767
- cutoffMs: objectCutoffMs
22218
+ cutoffMs: minObjectCutoffMs
20768
22219
  } });
20769
22220
  } catch (err) {
20770
22221
  this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
@@ -20795,8 +22246,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20795
22246
  } catch (err) {
20796
22247
  this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
20797
22248
  }
20798
- if (this.objectEmbeddingStore) try {
20799
- const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(objectCutoffMs);
22249
+ if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
22250
+ const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
20800
22251
  if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
20801
22252
  } catch (err) {
20802
22253
  this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
@@ -21462,6 +22913,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21462
22913
  return this.queryFacade.deleteDeviceEvents(input);
21463
22914
  }
21464
22915
  /** The events ops-log rows (newest-first), optionally scoped to one camera. */
22916
+ async relocateMedia(input) {
22917
+ if (!this.mediaRelocate) throw new Error("media relocation unavailable");
22918
+ return { jobId: this.mediaRelocate.start(input) };
22919
+ }
22920
+ async getMediaRelocateStatus() {
22921
+ return this.mediaRelocate?.list() ?? [];
22922
+ }
22923
+ async cancelMediaRelocate(input) {
22924
+ return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
22925
+ }
21465
22926
  async listOpsLog(input) {
21466
22927
  return this.queryFacade.listOpsLog(input);
21467
22928
  }
@@ -21541,13 +23002,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21541
23002
  try {
21542
23003
  const total = await sweepTrackRetention({
21543
23004
  listDeviceIds: () => trackStore.listDeviceIds(),
21544
- resolveRetentionDays: async (deviceId) => {
21545
- return resolveTrackRetentionDays(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
23005
+ resolveCutoffMs: async (deviceId, nowMs) => (await this.deviceRetentionCutoffs(deviceId, nowMs)).trackCutoffMs,
23006
+ pruneTracksBefore: async (deviceId, cutoffMs) => {
23007
+ const counts = await this.pruneTracksBefore({
23008
+ deviceId,
23009
+ cutoffMs
23010
+ });
23011
+ if (counts.tracks > 0) this.eventsOpsLog?.append({
23012
+ op: "prune",
23013
+ reason: "retention",
23014
+ deviceId,
23015
+ itemsAffected: counts.tracks,
23016
+ bytesReclaimed: 0,
23017
+ detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
23018
+ });
23019
+ return counts;
21546
23020
  },
21547
- pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
21548
- deviceId,
21549
- cutoffMs
21550
- }),
21551
23021
  now: () => Date.now(),
21552
23022
  onError: (deviceId, err) => {
21553
23023
  this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
@@ -21641,7 +23111,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21641
23111
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
21642
23112
  const baseSections = schema ? require_dist.hydrateSchema({
21643
23113
  ...schema,
21644
- sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
23114
+ sections: retagRetentionSection(retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))))
21645
23115
  }, raw).sections : [];
21646
23116
  const liveStatsSection = {
21647
23117
  id: "live-stats",
@@ -21697,5 +23167,6 @@ exports.default = PipelineAnalyticsAddon;
21697
23167
  exports.ncActions = ncActions;
21698
23168
  exports.pickCleanMedia = pickCleanMedia;
21699
23169
  exports.retagDetectionSections = retagDetectionSections;
23170
+ exports.retagRetentionSection = retagRetentionSection;
21700
23171
  exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
21701
23172
  exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;