@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.
@@ -1,6 +1,346 @@
1
- import { A as DeviceType, B as EventCategory, C as pipelineAnalyticsCapability, D as zoneAnalyticsCapability, E as videoclipsCapability, F as boolean, I as literal, L as number, M as hydrateSchema, N as nodePin, O as errMsg, P as array, R as object, S as notificationRulesCapability, T as subKindsOf, _ as customAction, a as NC_CONDITION_CATALOG, b as faceGalleryCapability, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as cosineSimilarity, h as buildEventKindDescriptor, i as MACRO_LABELS, j as createEvent, k as BaseAddon, l as NcRulePatchSchema, m as audioMetricsCapability, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as addonWidgetsSourceCapability, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as defineCustomActions, w as plateGalleryCapability, z as string } from "../dist-BsUHqtgU.mjs";
2
- import { randomUUID } from "node:crypto";
1
+ import { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-C41w6Xvl.mjs";
2
+ import { promises } from "node:fs";
3
+ import path from "node:path";
4
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
3
5
  import sharp from "sharp";
6
+ //#region src/notification-center/artifact-url.ts
7
+ /**
8
+ * Signed, externally-reachable URLs for notification artifacts.
9
+ *
10
+ * WHY this exists: the dispatcher only ever produced attachment BYTES, and the
11
+ * degrade engine drops a bytes-only attachment for a `mode:'url'` target
12
+ * (`attachment:noUrl`) — so WhatsApp and gotify received no media at all,
13
+ * silently. A URL also lets an oversized attachment degrade to a link instead
14
+ * of being dropped on `maxBytes`.
15
+ *
16
+ * Two decisions the shape depends on:
17
+ *
18
+ * - **Signed, not authenticated.** The fetcher is a notifier BACKEND (or the
19
+ * recipient's phone) — it holds no session and no admin token. The route is
20
+ * therefore `access:'public'` and the authority is the signature: an
21
+ * unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
22
+ * its own. Nothing else about the install is reachable through it.
23
+ * - **The base URL is CHOSEN, never assumed.** A notification carrying
24
+ * `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
25
+ * ranked endpoint list the `local-network` cap already computes — public
26
+ * tunnel > mesh > LAN, loopback last — is the authority, and loopback is
27
+ * refused outright rather than shipped as a broken link.
28
+ */
29
+ /**
30
+ * Pick the base URL an artifact link should use: the LOWEST-priority-number
31
+ * endpoint that is not loopback (the list is already ranked public > mesh >
32
+ * LAN > loopback). `null` when only loopback is available — a link nobody
33
+ * outside the host could open is worse than no link, because the notification
34
+ * would arrive advertising media that 404s.
35
+ */
36
+ function pickArtifactBaseUrl(endpoints) {
37
+ return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
38
+ }
39
+ /** Sign `(id, exp)` — the exact string the verifier recomputes. */
40
+ function signArtifact(secret, id, expMs) {
41
+ return createHmac("sha256", secret).update(`${id}:${expMs}`).digest("hex");
42
+ }
43
+ /**
44
+ * Build the fully-qualified, signed URL for an artifact.
45
+ * `baseUrl` comes from {@link pickArtifactBaseUrl}, `routePrefix` from the
46
+ * data-plane handle (`/addon/<addonId>/nc-artifact`).
47
+ */
48
+ function buildArtifactUrl(input) {
49
+ const sig = signArtifact(input.secret, input.id, input.expMs);
50
+ 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}`;
51
+ }
52
+ /**
53
+ * Verify a request's `(id, exp, sig)`. Constant-time on the signature so a
54
+ * public route cannot be probed for it byte by byte, and expiry-checked before
55
+ * the compare so an expired link is rejected even with a valid signature.
56
+ */
57
+ function verifyArtifactSignature(input) {
58
+ if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
59
+ const expected = signArtifact(input.secret, input.id, input.exp);
60
+ const a = Buffer.from(expected, "utf8");
61
+ const b = Buffer.from(input.sig, "utf8");
62
+ if (a.length !== b.length) return false;
63
+ return timingSafeEqual(a, b);
64
+ }
65
+ //#endregion
66
+ //#region src/notification-center/artifact-plane.ts
67
+ /**
68
+ * The hub's default HTTPS API port — the same constant `derive-hub-url.ts`
69
+ * documents. Only used for the LAN candidates; an operator who runs the hub on
70
+ * another port sets `CAMSTACK_HUB_PUBLIC_URL`, which wins outright.
71
+ */
72
+ var HUB_API_PORT = 4443;
73
+ /**
74
+ * Ranking of the public sources. All NEGATIVE: `local-network` gives its
75
+ * preferred LAN interface priority `0`, so a public candidate has to sit below
76
+ * zero to outrank it.
77
+ */
78
+ var PRIORITY_MARKED = -400;
79
+ var PRIORITY_CONFIGURED = -300;
80
+ var PRIORITY_CONNECTED = -200;
81
+ var PRIORITY_EXTERNAL_LIST = -100;
82
+ /**
83
+ * Build the ranked candidate list, in the operator's own order: an explicitly
84
+ * configured public origin, then the connected external ingress (tunnels),
85
+ * then the LAN addresses. Every source is best-effort — one that throws
86
+ * contributes nothing rather than costing the whole list.
87
+ */
88
+ async function collectArtifactEndpoints(sources) {
89
+ const out = [];
90
+ const marked = sources.markedBaseUrl;
91
+ if (marked !== void 0 && marked !== "") out.push({
92
+ baseUrl: marked,
93
+ kind: "public",
94
+ priority: PRIORITY_MARKED
95
+ });
96
+ const configured = sources.configuredPublicUrl;
97
+ if (configured !== void 0 && configured !== "" && !/127\.0\.0\.1|localhost/.test(configured)) out.push({
98
+ baseUrl: configured,
99
+ kind: "public",
100
+ priority: PRIORITY_CONFIGURED
101
+ });
102
+ try {
103
+ const connected = await sources.getConnected();
104
+ if (connected !== null && connected.protocol === "https") out.push({
105
+ baseUrl: connected.url,
106
+ kind: "public",
107
+ priority: PRIORITY_CONNECTED
108
+ });
109
+ } catch (err) {
110
+ sources.logger.debug("connected endpoint lookup failed", { meta: { error: String(err) } });
111
+ }
112
+ try {
113
+ const external = await sources.listExternal();
114
+ for (const [i, e] of external.entries()) {
115
+ if (e.protocol !== "https") continue;
116
+ out.push({
117
+ baseUrl: e.url,
118
+ kind: "public",
119
+ priority: PRIORITY_EXTERNAL_LIST + i
120
+ });
121
+ }
122
+ } catch (err) {
123
+ sources.logger.debug("external endpoint lookup failed", { meta: { error: String(err) } });
124
+ }
125
+ try {
126
+ out.push(...await sources.listLan(HUB_API_PORT));
127
+ } catch (err) {
128
+ sources.logger.debug("LAN endpoint lookup failed", { meta: { error: String(err) } });
129
+ }
130
+ return out;
131
+ }
132
+ var DEFAULT_TTL_MS = 24 * 36e5;
133
+ var DEFAULT_ENDPOINT_CACHE_MS = 6e4;
134
+ var NcArtifactPlane = class {
135
+ deps;
136
+ now;
137
+ cachedBaseUrl = null;
138
+ /** Last base URL announced in the log — so the line appears on CHANGE only. */
139
+ lastLoggedBaseUrl = null;
140
+ cachedAt = 0;
141
+ constructor(deps) {
142
+ this.deps = deps;
143
+ this.now = deps.now ?? (() => Date.now());
144
+ }
145
+ /**
146
+ * Store bytes and mint a signed, externally-reachable URL for them.
147
+ * Returns `null` when no usable base URL exists (loopback only) — the caller
148
+ * then ships bytes alone, exactly as before. Never throws: an artifact URL is
149
+ * an ENHANCEMENT, and failing to mint one must not cost the notification.
150
+ */
151
+ async publish(bytes, mime) {
152
+ try {
153
+ const baseUrl = await this.resolveBaseUrl();
154
+ if (baseUrl === null) return null;
155
+ const artifact = await this.deps.store.put(bytes, mime);
156
+ return buildArtifactUrl({
157
+ baseUrl,
158
+ routePrefix: this.deps.routePrefix,
159
+ id: artifact.id,
160
+ secret: this.deps.secret,
161
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
162
+ });
163
+ } catch (err) {
164
+ this.deps.logger.debug("artifact publish failed — shipping bytes only", { meta: { error: String(err) } });
165
+ return null;
166
+ }
167
+ }
168
+ /** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
169
+ handler = async (req, res) => {
170
+ const url = new URL(req.url ?? "/", "http://placeholder");
171
+ const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
172
+ if (!verifyArtifactSignature({
173
+ secret: this.deps.secret,
174
+ id,
175
+ exp: Number(url.searchParams.get("exp")),
176
+ sig: url.searchParams.get("sig") ?? "",
177
+ nowMs: this.now()
178
+ })) {
179
+ res.writeHead(404, { "content-type": "text/plain" });
180
+ res.end("not found");
181
+ return;
182
+ }
183
+ const found = await this.deps.store.read(id);
184
+ if (found === null) {
185
+ res.writeHead(404, { "content-type": "text/plain" });
186
+ res.end("not found");
187
+ return;
188
+ }
189
+ res.writeHead(200, {
190
+ "content-type": found.mime,
191
+ "content-length": String(found.bytes.byteLength),
192
+ "cache-control": "private, max-age=3600"
193
+ });
194
+ res.end(found.bytes);
195
+ };
196
+ /** The chosen base URL, cached briefly (the interface list barely moves). */
197
+ async resolveBaseUrl() {
198
+ const ttl = this.deps.endpointCacheMs ?? DEFAULT_ENDPOINT_CACHE_MS;
199
+ if (this.cachedBaseUrl !== null && this.now() - this.cachedAt < ttl) return this.cachedBaseUrl;
200
+ const picked = pickArtifactBaseUrl(await this.deps.listEndpoints());
201
+ if (picked === null) {
202
+ this.deps.logger.debug("no externally-reachable endpoint — artifacts ship as bytes only");
203
+ return null;
204
+ }
205
+ if (picked !== this.lastLoggedBaseUrl) {
206
+ this.deps.logger.info("artifact links will use", { meta: { baseUrl: picked } });
207
+ this.lastLoggedBaseUrl = picked;
208
+ }
209
+ this.cachedBaseUrl = picked;
210
+ this.cachedAt = this.now();
211
+ return picked;
212
+ }
213
+ };
214
+ //#endregion
215
+ //#region src/notification-center/artifact-store.ts
216
+ /**
217
+ * NcArtifactStore — the bytes behind a signed notification-artifact URL.
218
+ *
219
+ * A notification attachment is transient by nature: it exists to be fetched
220
+ * once, by a notifier backend or the recipient's phone, within minutes of the
221
+ * event. So this is a BOUNDED SCRATCH, not a media library — the durable copy
222
+ * of an event's media already lives in the media store, and duplicating it here
223
+ * permanently would grow without limit for no benefit.
224
+ *
225
+ * Two bounds, both enforced on every write (age first, then count): whichever
226
+ * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
227
+ * left behind — the directory is disposable by construction, so a lost file is
228
+ * a 404 on one link, never a corrupt state.
229
+ */
230
+ var DEFAULT_MAX_AGE_MS = 6 * 36e5;
231
+ var DEFAULT_MAX_ENTRIES = 500;
232
+ /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
233
+ function extensionFor(mime) {
234
+ if (mime === "image/jpeg") return "jpg";
235
+ if (mime === "image/gif") return "gif";
236
+ if (mime === "video/mp4") return "mp4";
237
+ if (mime === "image/png") return "png";
238
+ return "bin";
239
+ }
240
+ var NcArtifactStore = class {
241
+ options;
242
+ now;
243
+ maxAgeMs;
244
+ maxEntries;
245
+ /** id → mime, so the route can answer with the right content-type without
246
+ * re-deriving it from the extension on every request. */
247
+ mimeById = /* @__PURE__ */ new Map();
248
+ constructor(options) {
249
+ this.options = options;
250
+ this.now = options.now ?? (() => Date.now());
251
+ this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
252
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
253
+ }
254
+ /** Create the directory and sweep whatever a previous run left behind. */
255
+ async start() {
256
+ await promises.mkdir(this.options.dir, { recursive: true });
257
+ await this.sweep();
258
+ }
259
+ /** Persist bytes and return the artifact handle (never throws on sweep). */
260
+ async put(bytes, mime) {
261
+ const id = `${this.now()}-${randomUUID()}`;
262
+ const file = path.join(this.options.dir, `${id}.${extensionFor(mime)}`);
263
+ await promises.mkdir(this.options.dir, { recursive: true });
264
+ await promises.writeFile(file, bytes);
265
+ this.mimeById.set(id, mime);
266
+ await this.sweep();
267
+ return {
268
+ id,
269
+ mime,
270
+ bytes: bytes.byteLength
271
+ };
272
+ }
273
+ /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
274
+ async read(id) {
275
+ if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
276
+ const found = (await this.list()).find((e) => e.id === id);
277
+ if (!found) return null;
278
+ try {
279
+ return {
280
+ bytes: await promises.readFile(found.file),
281
+ mime: this.mimeById.get(id) ?? mimeFromExtension(found.file)
282
+ };
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+ /** Delete every artifact (operator cleanup / shutdown). */
288
+ async clear() {
289
+ await promises.rm(this.options.dir, {
290
+ recursive: true,
291
+ force: true
292
+ }).catch(() => {});
293
+ this.mimeById.clear();
294
+ }
295
+ /** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
296
+ async sweep() {
297
+ const entries = await this.list();
298
+ const cutoff = this.now() - this.maxAgeMs;
299
+ const doomed = entries.filter((e) => e.mtimeMs < cutoff);
300
+ const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
301
+ doomed.push(...survivors.slice(this.maxEntries));
302
+ for (const e of doomed) try {
303
+ await promises.rm(e.file, { force: true });
304
+ this.mimeById.delete(e.id);
305
+ } catch (err) {
306
+ this.options.logger.debug("artifact sweep failed to unlink", { meta: {
307
+ file: e.file,
308
+ error: String(err)
309
+ } });
310
+ }
311
+ }
312
+ async list() {
313
+ let names;
314
+ try {
315
+ names = await promises.readdir(this.options.dir);
316
+ } catch {
317
+ return [];
318
+ }
319
+ const out = [];
320
+ for (const name of names) {
321
+ const file = path.join(this.options.dir, name);
322
+ try {
323
+ const st = await promises.stat(file);
324
+ if (!st.isFile()) continue;
325
+ out.push({
326
+ id: name.slice(0, name.lastIndexOf(".")),
327
+ file,
328
+ mtimeMs: st.mtimeMs
329
+ });
330
+ } catch {}
331
+ }
332
+ return out;
333
+ }
334
+ };
335
+ function mimeFromExtension(file) {
336
+ const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
337
+ if (ext === "jpg") return "image/jpeg";
338
+ if (ext === "gif") return "image/gif";
339
+ if (ext === "mp4") return "video/mp4";
340
+ if (ext === "png") return "image/png";
341
+ return "application/octet-stream";
342
+ }
343
+ //#endregion
4
344
  //#region src/pipeline-analytics/videoclips-provider.ts
5
345
  var SOURCE = "analytics";
6
346
  function clipIdFor(eventId, startMs, endMs) {
@@ -4157,6 +4497,42 @@ function attachmentKindPreference(policy, ownerKind) {
4157
4497
  ];
4158
4498
  }
4159
4499
  /**
4500
+ * The kind ladder for an EXPLICIT frame choice. Strict by design — each option
4501
+ * stays inside its own family, because a rule that asked for the clean scene
4502
+ * and received the boxed one (or vice versa) is not "degrading gracefully", it
4503
+ * is answering a different question.
4504
+ *
4505
+ * `boxed` is the one exception: with no annotated frame stored, the CLEAN
4506
+ * scene is the honest fallback (same picture, no annotation) — never a subject
4507
+ * crop, which shows something else entirely.
4508
+ */
4509
+ function framePreference(frame, ownerKind) {
4510
+ if (frame === "cropped") return ownerKind === "track" ? [
4511
+ "thumbnail",
4512
+ "thumbnailSmall",
4513
+ "crop"
4514
+ ] : [
4515
+ "crop",
4516
+ "thumbnail",
4517
+ "thumbnailSmall"
4518
+ ];
4519
+ if (frame === "boxed") return [
4520
+ "fullFrameBoxed",
4521
+ "keyFrame",
4522
+ "keyFrameSmall",
4523
+ "fullFrame"
4524
+ ];
4525
+ return ownerKind === "track" ? [
4526
+ "keyFrame",
4527
+ "keyFrameSmall",
4528
+ "firstFrame"
4529
+ ] : [
4530
+ "fullFrame",
4531
+ "keyFrame",
4532
+ "keyFrameSmall"
4533
+ ];
4534
+ }
4535
+ /**
4160
4536
  * Derive the `best-matching` media signal from a matched rule's condition
4161
4537
  * summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
4162
4538
  * (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
@@ -4185,6 +4561,20 @@ function bestMatchingKindPreference(signal, ownerKind) {
4185
4561
  //#endregion
4186
4562
  //#region src/notification-center/dispatcher.ts
4187
4563
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
4564
+ /** GIF and MP4 are the same cut in two containers — one render request each. */
4565
+ var FOOTAGE_FORMATS = [{
4566
+ flag: "mediaGif",
4567
+ format: "gif",
4568
+ mediaType: "gif",
4569
+ mime: "image/gif",
4570
+ name: "event.gif"
4571
+ }, {
4572
+ flag: "mediaClip",
4573
+ format: "mp4",
4574
+ mediaType: "video",
4575
+ mime: "video/mp4",
4576
+ name: "event.mp4"
4577
+ }];
4188
4578
  var NcDispatcher = class {
4189
4579
  deps;
4190
4580
  targetCache = null;
@@ -4254,14 +4644,19 @@ var NcDispatcher = class {
4254
4644
  async resolveTarget(targetId) {
4255
4645
  const cached = this.cachedTarget(targetId);
4256
4646
  if (cached !== null) return cached;
4647
+ let targets;
4257
4648
  try {
4258
- const targets = await this.deps.listTargets();
4259
- this.targetCache = new Map(targets.map((t) => [t.id, t]));
4260
- this.targetCacheAt = this.now();
4649
+ targets = await this.deps.listTargets();
4261
4650
  } catch (err) {
4262
4651
  this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
4263
4652
  throw err instanceof Error ? err : new Error(String(err));
4264
4653
  }
4654
+ if (targets.length === 0) {
4655
+ this.deps.logger.warn("target catalog came back EMPTY — treating as transient", { meta: { targetId } });
4656
+ throw new Error(`target catalog empty (transient) while resolving ${targetId}`);
4657
+ }
4658
+ this.targetCache = new Map(targets.map((t) => [t.id, t]));
4659
+ this.targetCacheAt = this.now();
4265
4660
  return this.targetCache.get(targetId) ?? null;
4266
4661
  }
4267
4662
  cachedTarget(targetId) {
@@ -4275,7 +4670,7 @@ var NcDispatcher = class {
4275
4670
  const vars = buildTemplateVars(entry, deviceName);
4276
4671
  const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4277
4672
  const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4278
- const attachment = await this.resolveAttachment(entry);
4673
+ const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
4279
4674
  const params = pickParams(entry.payload.params);
4280
4675
  return {
4281
4676
  body,
@@ -4285,7 +4680,7 @@ var NcDispatcher = class {
4285
4680
  tag: entry.ruleId,
4286
4681
  deviceId: subject.deviceId,
4287
4682
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
4288
- ...attachment !== null ? { attachments: [attachment] } : {}
4683
+ ...attachments.length > 0 ? { attachments } : {}
4289
4684
  };
4290
4685
  }
4291
4686
  /**
@@ -4297,9 +4692,142 @@ var NcDispatcher = class {
4297
4692
  * explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
4298
4693
  * then degrades to the plain `best` → `keyFrame` ladders.
4299
4694
  */
4300
- async resolveAttachment(entry) {
4301
- const policy = entry.payload.media;
4695
+ /**
4696
+ * Mint a signed URL for each attachment, alongside the bytes it already
4697
+ * carries. Best-effort per attachment: a failed mint leaves that one
4698
+ * bytes-only rather than costing the notification. Without this a url-mode
4699
+ * target (WhatsApp, gotify) silently received NO media — the degrade engine
4700
+ * drops a bytes-only attachment for them (`attachment:noUrl`).
4701
+ */
4702
+ /**
4703
+ * A failed footage render is best-effort — ship the still, skip the video.
4704
+ *
4705
+ * There is nothing to wait for: the clip ring only grows FORWARD, so a window
4706
+ * it does not already cover will never be covered by retrying. (An earlier
4707
+ * revision deferred delivery here, when clips were cut from finalized
4708
+ * recording segments; retrying under the pre-buffer would just age the
4709
+ * pre-roll out of the ring.)
4710
+ */
4711
+ notePendingFootage(entry, err, what) {
4712
+ this.deps.logger.debug(`${what} attachment render failed`, {
4713
+ tags: { deviceId: entry.payload.subject.deviceId },
4714
+ meta: {
4715
+ error: String(err),
4716
+ action: "send-without-video"
4717
+ }
4718
+ });
4719
+ }
4720
+ async withArtifactUrls(attachments) {
4721
+ const publish = this.deps.publishArtifact;
4722
+ if (publish === void 0) return [...attachments];
4723
+ const out = [];
4724
+ for (const att of attachments) {
4725
+ const url = await publish(att.bytes, att.mime).catch(() => null);
4726
+ out.push(url === null ? { ...att } : {
4727
+ ...att,
4728
+ url
4729
+ });
4730
+ }
4731
+ return out;
4732
+ }
4733
+ /** The full attachment list: the policy still (zone-cropped when the rule
4734
+ * froze zone ids) plus the footage cut from the broker's clip ring when
4735
+ * requested. Each part is best-effort — a failed crop falls back to the
4736
+ * uncropped still, a failed render just omits the video. */
4737
+ async resolveAttachments(entry) {
4738
+ const out = [];
4739
+ const zoneIdsWanted = entry.payload.mediaZoneIds;
4740
+ const still = await this.resolveAttachment(entry, zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0);
4741
+ if (still !== null) {
4742
+ const zoneIds = zoneIdsWanted;
4743
+ if (zoneIds !== void 0 && zoneIds.length > 0) {
4744
+ const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
4745
+ out.push(cropped !== null ? {
4746
+ ...still,
4747
+ bytes: cropped,
4748
+ name: "zone.jpg"
4749
+ } : still);
4750
+ } else out.push(still);
4751
+ }
4752
+ for (const want of FOOTAGE_FORMATS) {
4753
+ if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
4754
+ try {
4755
+ const rendered = await this.deps.renderFootage({
4756
+ deviceId: entry.payload.subject.deviceId,
4757
+ aroundMs: entry.payload.subject.timestamp,
4758
+ format: want.format,
4759
+ ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
4760
+ ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
4761
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
4762
+ });
4763
+ if (rendered !== null && rendered.byteLength > 0) {
4764
+ const bytes = new Uint8Array(rendered.byteLength);
4765
+ bytes.set(rendered);
4766
+ out.push({
4767
+ mediaType: want.mediaType,
4768
+ bytes,
4769
+ mime: want.mime,
4770
+ name: want.name
4771
+ });
4772
+ }
4773
+ } catch (err) {
4774
+ this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
4775
+ }
4776
+ }
4777
+ return out;
4778
+ }
4779
+ /** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
4780
+ * pixel rect via sharp metadata). Null on any failure — caller falls back
4781
+ * to the uncropped still. */
4782
+ async zoneCrop(deviceId, zoneIds, jpeg) {
4783
+ try {
4784
+ const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
4785
+ if (points.length === 0) return null;
4786
+ const { default: sharp } = await import("sharp");
4787
+ const img = sharp(Buffer.from(jpeg));
4788
+ const meta = await img.metadata();
4789
+ const W = meta.width ?? 0;
4790
+ const H = meta.height ?? 0;
4791
+ if (W === 0 || H === 0) return null;
4792
+ let minX = 1;
4793
+ let minY = 1;
4794
+ let maxX = 0;
4795
+ let maxY = 0;
4796
+ for (const p of points) {
4797
+ if (p.x < minX) minX = p.x;
4798
+ if (p.y < minY) minY = p.y;
4799
+ if (p.x > maxX) maxX = p.x;
4800
+ if (p.y > maxY) maxY = p.y;
4801
+ }
4802
+ if (maxX <= minX || maxY <= minY) return null;
4803
+ const padX = (maxX - minX) * .1;
4804
+ const padY = (maxY - minY) * .1;
4805
+ const left = Math.max(0, Math.floor((minX - padX) * W));
4806
+ const top = Math.max(0, Math.floor((minY - padY) * H));
4807
+ const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
4808
+ const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
4809
+ if (width < 16 || height < 16) return null;
4810
+ const outBuf = await img.extract({
4811
+ left,
4812
+ top,
4813
+ width,
4814
+ height
4815
+ }).jpeg({ quality: 82 }).toBuffer();
4816
+ const bytes = new Uint8Array(outBuf.byteLength);
4817
+ bytes.set(outBuf);
4818
+ return bytes;
4819
+ } catch (err) {
4820
+ this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
4821
+ deviceId,
4822
+ error: String(err)
4823
+ } });
4824
+ return null;
4825
+ }
4826
+ }
4827
+ async resolveAttachment(entry, policyOverride) {
4828
+ const policy = policyOverride ?? entry.payload.media;
4302
4829
  if (policy === "none") return null;
4830
+ const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
4303
4831
  const subject = entry.payload.subject;
4304
4832
  const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
4305
4833
  const owners = [];
@@ -4311,11 +4839,11 @@ var NcDispatcher = class {
4311
4839
  kind: "track",
4312
4840
  id: subject.trackId
4313
4841
  });
4314
- if (policy === "keyFrame") owners.reverse();
4842
+ if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
4315
4843
  for (const owner of owners) try {
4316
4844
  const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
4317
4845
  if (files.length === 0) continue;
4318
- const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4846
+ const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4319
4847
  for (const kind of preference) {
4320
4848
  const file = files.find((f) => f.kind === kind);
4321
4849
  if (file === void 0) continue;
@@ -4637,7 +5165,7 @@ var NC_OCCUPANCY_INDEXES = [{
4637
5165
  }];
4638
5166
  /** Query cap — a per-(device, zone, class) key set is small; this is a
4639
5167
  * generous ceiling that still bounds a pathological read. */
4640
- var LOAD_LIMIT = 1e5;
5168
+ var LOAD_LIMIT$1 = 1e5;
4641
5169
  var OccupancyStore = class {
4642
5170
  cache = /* @__PURE__ */ new Map();
4643
5171
  store;
@@ -4664,7 +5192,7 @@ var OccupancyStore = class {
4664
5192
  try {
4665
5193
  const records = await this.store.query.query({
4666
5194
  collection: NC_OCCUPANCY_COLLECTION,
4667
- filter: { limit: LOAD_LIMIT }
5195
+ filter: { limit: LOAD_LIMIT$1 }
4668
5196
  });
4669
5197
  this.cache.clear();
4670
5198
  let skipped = 0;
@@ -5423,25 +5951,273 @@ var NcRuleStore = class {
5423
5951
  this.byId.delete(ruleId);
5424
5952
  try {
5425
5953
  await this.store.delete.mutate({
5426
- collection: NC_RULES_COLLECTION,
5954
+ collection: NC_RULES_COLLECTION,
5955
+ key: ruleId
5956
+ });
5957
+ } catch (err) {
5958
+ this.logger.warn("notification rule delete failed", { meta: {
5959
+ ruleId,
5960
+ error: String(err)
5961
+ } });
5962
+ throw err instanceof Error ? err : new Error(String(err));
5963
+ }
5964
+ }
5965
+ async persist(rule) {
5966
+ await this.store.set.mutate({
5967
+ collection: NC_RULES_COLLECTION,
5968
+ key: rule.id,
5969
+ value: {
5970
+ name: rule.name,
5971
+ enabled: rule.enabled,
5972
+ delivery: rule.delivery,
5973
+ updatedAt: rule.updatedAt,
5974
+ rule
5975
+ }
5976
+ });
5977
+ }
5978
+ };
5979
+ //#endregion
5980
+ //#region src/notification-center/timelapse/timelapse-store.ts
5981
+ var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
5982
+ var NC_TIMELAPSE_RULES_COLUMNS = [
5983
+ {
5984
+ name: "id",
5985
+ type: "TEXT",
5986
+ primaryKey: true,
5987
+ notNull: true
5988
+ },
5989
+ {
5990
+ name: "name",
5991
+ type: "TEXT",
5992
+ notNull: true
5993
+ },
5994
+ {
5995
+ name: "enabled",
5996
+ type: "BOOLEAN",
5997
+ notNull: true
5998
+ },
5999
+ {
6000
+ name: "updatedAt",
6001
+ type: "INTEGER",
6002
+ notNull: true
6003
+ },
6004
+ (
6005
+ /** The FULL rule object (Zod-validated on read) — scalars above are
6006
+ * indexed projections only. */
6007
+ {
6008
+ name: "rule",
6009
+ type: "JSON",
6010
+ notNull: true
6011
+ })
6012
+ ];
6013
+ var NC_TIMELAPSE_RULES_INDEXES = [{
6014
+ name: "idx_nc_timelapse_rules_enabled",
6015
+ columns: ["enabled"]
6016
+ }];
6017
+ /** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
6018
+ var LOAD_LIMIT = 1e4;
6019
+ /**
6020
+ * Resolve the three-way `template` patch signal onto a merged rule, immutably:
6021
+ * `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
6022
+ * replaces it. Keeping `null` out of the persisted rule is what lets
6023
+ * `TimelapseRuleSchema` stay a plain `.optional()`.
6024
+ */
6025
+ function applyTemplatePatch(merged, template) {
6026
+ if (template === void 0) return merged;
6027
+ if (template !== null) return {
6028
+ ...merged,
6029
+ template
6030
+ };
6031
+ const { template: _cleared, ...withoutTemplate } = merged;
6032
+ return withoutTemplate;
6033
+ }
6034
+ var TimelapseStore = class {
6035
+ byId = /* @__PURE__ */ new Map();
6036
+ store;
6037
+ logger;
6038
+ now;
6039
+ newId;
6040
+ constructor(deps) {
6041
+ this.store = deps.store;
6042
+ this.logger = deps.logger;
6043
+ this.now = deps.now ?? (() => Date.now());
6044
+ this.newId = deps.newId ?? (() => randomUUID());
6045
+ }
6046
+ static async declare(store) {
6047
+ await store.declareCollection.mutate({
6048
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6049
+ columns: [...NC_TIMELAPSE_RULES_COLUMNS],
6050
+ indexes: [...NC_TIMELAPSE_RULES_INDEXES]
6051
+ });
6052
+ }
6053
+ /**
6054
+ * (Re)hydrate the FULL rule set from the store — called at boot and on the
6055
+ * periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
6056
+ * longer validates is skipped with a warning (a degraded rule must never
6057
+ * crash the scheduler).
6058
+ */
6059
+ async load() {
6060
+ try {
6061
+ const rows = await this.store.query.query({
6062
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6063
+ filter: { limit: LOAD_LIMIT }
6064
+ });
6065
+ this.byId.clear();
6066
+ let skipped = 0;
6067
+ for (const row of rows) {
6068
+ const parsed = TimelapseRuleSchema.safeParse(row.data["rule"]);
6069
+ if (!parsed.success) {
6070
+ skipped += 1;
6071
+ continue;
6072
+ }
6073
+ this.byId.set(parsed.data.id, parsed.data);
6074
+ }
6075
+ this.logger.debug("timelapse rules loaded", { meta: {
6076
+ rules: this.byId.size,
6077
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
6078
+ } });
6079
+ } catch (err) {
6080
+ this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
6081
+ }
6082
+ }
6083
+ /** Every rule, newest-first (admin path). */
6084
+ list() {
6085
+ return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
6086
+ }
6087
+ /** The scheduler's read: only rules that should be ticked. */
6088
+ listEnabled() {
6089
+ return this.list().filter((r) => r.enabled);
6090
+ }
6091
+ get(ruleId) {
6092
+ return this.byId.get(ruleId) ?? null;
6093
+ }
6094
+ /**
6095
+ * Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
6096
+ * userId`) plus every admin/global rule (`ownerUserId` absent). Never another
6097
+ * user's personal rows. Newest-first (inherits {@link list}).
6098
+ *
6099
+ * The caller identity is server-derived; an absent/undefined caller must be
6100
+ * resolved to a fail-closed value by the bridge action BEFORE calling here —
6101
+ * this store never treats a missing caller as admin/global.
6102
+ */
6103
+ listForOwner(userId) {
6104
+ return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
6105
+ }
6106
+ /**
6107
+ * Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
6108
+ * user owns. A global rule (no `ownerUserId`) returns false — global rules
6109
+ * are admin-only, and the bridge action grants admins the mutation without
6110
+ * consulting this check. An unknown rule id is false (fail-closed).
6111
+ */
6112
+ isOwnedBy(ruleId, userId) {
6113
+ const rule = this.byId.get(ruleId);
6114
+ return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
6115
+ }
6116
+ /**
6117
+ * Create a rule. `createdBy` is the SERVER-injected caller userId;
6118
+ * `ownerUserId` is the server-derived owner (omit for an admin/global rule).
6119
+ * Neither is ever read from `input`.
6120
+ *
6121
+ * The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
6122
+ * spread — that schema carries no ownership/provenance keys, so it strips any
6123
+ * that rode in on the blob. Without it, an `ownerUserId` on `input` would
6124
+ * survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
6125
+ * path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
6126
+ * so a future "duplicate rule" action (`create(existingRule, caller)`) would
6127
+ * compile cleanly and silently clone the ORIGINAL owner.
6128
+ */
6129
+ async create(input, createdBy, ownerUserId) {
6130
+ const now = this.now();
6131
+ const rule = TimelapseRuleSchema.parse({
6132
+ ...TimelapseRuleInputSchema.parse(input),
6133
+ id: this.newId(),
6134
+ ...ownerUserId !== void 0 ? { ownerUserId } : {},
6135
+ createdBy,
6136
+ createdAt: now,
6137
+ updatedAt: now
6138
+ });
6139
+ await this.persist(rule);
6140
+ this.byId.set(rule.id, rule);
6141
+ return rule;
6142
+ }
6143
+ /**
6144
+ * Apply a partial patch. Immutable: returns the NEW rule object. Identity,
6145
+ * ownership and generation state are re-pinned from the existing rule AFTER
6146
+ * the spread — the patch schema carries none of them, and this makes a
6147
+ * hand-built (unparsed) patch object equally unable to re-own a rule.
6148
+ *
6149
+ * `template` is the one clearable field: an absent key leaves it unchanged,
6150
+ * an explicit `null` CLEARS it (the persisted rule loses the key — `null`
6151
+ * never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
6152
+ * note.
6153
+ */
6154
+ async update(ruleId, patch) {
6155
+ const existing = this.byId.get(ruleId);
6156
+ if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
6157
+ const { template, ...rest } = patch;
6158
+ const merged = {
6159
+ ...existing,
6160
+ ...rest,
6161
+ id: existing.id,
6162
+ ownerUserId: existing.ownerUserId,
6163
+ lastGeneratedAt: existing.lastGeneratedAt,
6164
+ createdBy: existing.createdBy,
6165
+ createdAt: existing.createdAt,
6166
+ updatedAt: this.now()
6167
+ };
6168
+ return this.write(applyTemplatePatch(merged, template));
6169
+ }
6170
+ async setEnabled(ruleId, enabled) {
6171
+ return this.update(ruleId, { enabled });
6172
+ }
6173
+ /**
6174
+ * Record a successful generation. `at` is the generation epoch-ms — the
6175
+ * durable state behind the 1-hour re-generation guard. Does NOT bump
6176
+ * `updatedAt` (generation is not an edit of the rule definition).
6177
+ */
6178
+ async markGenerated(ruleId, at) {
6179
+ const existing = this.byId.get(ruleId);
6180
+ if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
6181
+ return this.write({
6182
+ ...existing,
6183
+ lastGeneratedAt: at
6184
+ });
6185
+ }
6186
+ /** Idempotent delete — unknown ids are a no-op. */
6187
+ async delete(ruleId) {
6188
+ this.byId.delete(ruleId);
6189
+ try {
6190
+ await this.store.delete.mutate({
6191
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
5427
6192
  key: ruleId
5428
6193
  });
5429
6194
  } catch (err) {
5430
- this.logger.warn("notification rule delete failed", { meta: {
6195
+ this.logger.warn("timelapse rule delete failed", { meta: {
5431
6196
  ruleId,
5432
6197
  error: String(err)
5433
6198
  } });
5434
6199
  throw err instanceof Error ? err : new Error(String(err));
5435
6200
  }
5436
6201
  }
6202
+ /**
6203
+ * Re-validate a merged candidate, persist it, then cache it. Re-validating
6204
+ * means a patch can never persist a rule that would be skipped at the next
6205
+ * `load()`; provenance/ownership survive because they are spread from the
6206
+ * existing rule and never present on a patch.
6207
+ */
6208
+ async write(candidate) {
6209
+ const rule = TimelapseRuleSchema.parse(candidate);
6210
+ await this.persist(rule);
6211
+ this.byId.set(rule.id, rule);
6212
+ return rule;
6213
+ }
5437
6214
  async persist(rule) {
5438
6215
  await this.store.set.mutate({
5439
- collection: NC_RULES_COLLECTION,
6216
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
5440
6217
  key: rule.id,
5441
6218
  value: {
5442
6219
  name: rule.name,
5443
6220
  enabled: rule.enabled,
5444
- delivery: rule.delivery,
5445
6221
  updatedAt: rule.updatedAt,
5446
6222
  rule
5447
6223
  }
@@ -5543,7 +6319,7 @@ function outboxEntryToHistory(entry) {
5543
6319
  }
5544
6320
  };
5545
6321
  }
5546
- var NotificationCenter = class {
6322
+ var NotificationCenter = class NotificationCenter {
5547
6323
  logger;
5548
6324
  rules;
5549
6325
  outbox;
@@ -5605,11 +6381,19 @@ var NotificationCenter = class {
5605
6381
  get ruleStore() {
5606
6382
  return this.rules;
5607
6383
  }
5608
- /** Declare every Notification Center collection (idempotent, boot-time). */
6384
+ /**
6385
+ * Declare every Notification Center collection (idempotent, boot-time).
6386
+ *
6387
+ * EVERY store the module can touch belongs here, including ones whose
6388
+ * feature is not wired yet: an UNDECLARED collection answers 412 on first
6389
+ * use and takes the whole runner down with it. The timelapse store shipped
6390
+ * without this line and was one enable away from doing exactly that.
6391
+ */
5609
6392
  static async declare(store) {
5610
6393
  await NcRuleStore.declare(store);
5611
6394
  await NcOutbox.declare(store);
5612
6395
  await OccupancyStore.declare(store);
6396
+ await TimelapseStore.declare(store);
5613
6397
  }
5614
6398
  /**
5615
6399
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -5794,6 +6578,7 @@ var NotificationCenter = class {
5794
6578
  listRules: async () => ({ rules: [...this.rules.list()] }),
5795
6579
  getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
5796
6580
  createRule: async ({ rule, caller }) => {
6581
+ NotificationCenter.assertHasAddressee(rule.targets, rule.targetUsers);
5797
6582
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
5798
6583
  const created = await this.rules.create(rule, caller.userId);
5799
6584
  this.logger.info("notification rule created", { meta: {
@@ -5805,6 +6590,10 @@ var NotificationCenter = class {
5805
6590
  },
5806
6591
  updateRule: async ({ ruleId, patch, caller }) => {
5807
6592
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
6593
+ if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
6594
+ const existing = this.rules.get(ruleId);
6595
+ NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
6596
+ }
5808
6597
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
5809
6598
  const updated = await this.rules.update(ruleId, safePatch);
5810
6599
  this.logger.info("notification rule updated", { meta: {
@@ -5822,7 +6611,10 @@ var NotificationCenter = class {
5822
6611
  return { success: true };
5823
6612
  },
5824
6613
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
5825
- getConditionCatalog: async () => ({ catalog: [...NC_CONDITION_CATALOG] }),
6614
+ getConditionCatalog: async () => ({
6615
+ catalog: [...NC_CONDITION_CATALOG],
6616
+ taxonomy: NC_TAXONOMY
6617
+ }),
5826
6618
  getHistory: async ({ filter }) => {
5827
6619
  const entries = await this.outbox.queryHistory({
5828
6620
  ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
@@ -5863,26 +6655,76 @@ var NotificationCenter = class {
5863
6655
  async evaluateAndEnqueue(subject, kind) {
5864
6656
  const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
5865
6657
  const candidates = this.rules.listEnabled(delivery);
5866
- if (candidates.length === 0) return;
6658
+ if (candidates.length === 0) {
6659
+ this.logger.debug("no enabled rule for this trigger", {
6660
+ tags: { deviceId: subject.deviceId },
6661
+ meta: {
6662
+ delivery,
6663
+ kind,
6664
+ rulesLoaded: this.rules.list().length
6665
+ }
6666
+ });
6667
+ return;
6668
+ }
5867
6669
  const now = this.now();
5868
6670
  for (const rule of candidates) {
5869
6671
  const evaluation = evaluateRule(rule, subject);
5870
- if (!evaluation.matched) continue;
6672
+ if (!evaluation.matched) {
6673
+ this.logger.debug("rule did not match", {
6674
+ tags: { deviceId: subject.deviceId },
6675
+ meta: {
6676
+ ruleId: rule.id,
6677
+ rule: rule.name,
6678
+ kind,
6679
+ failed: evaluation.failedCondition,
6680
+ classes: subject.classNames
6681
+ }
6682
+ });
6683
+ continue;
6684
+ }
5871
6685
  const key = cooldownKey(rule, subject);
5872
- if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) continue;
5873
- if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn)) > 0) this.lastFiredAt.set(key, now);
6686
+ if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
6687
+ this.logger.debug("rule matched but is cooling down", {
6688
+ tags: { deviceId: subject.deviceId },
6689
+ meta: {
6690
+ ruleId: rule.id,
6691
+ rule: rule.name,
6692
+ key
6693
+ }
6694
+ });
6695
+ continue;
6696
+ }
6697
+ this.logger.info("rule matched — enqueueing", {
6698
+ tags: { deviceId: subject.deviceId },
6699
+ meta: {
6700
+ ruleId: rule.id,
6701
+ rule: rule.name,
6702
+ kind,
6703
+ targets: rule.targets.length
6704
+ }
6705
+ });
6706
+ const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6707
+ if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
5874
6708
  }
5875
6709
  }
5876
- buildEntries(rule, subject, kind, matchedOn) {
6710
+ buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
5877
6711
  const hasEventMedia = kind === "object-event" || kind === "package-event";
5878
6712
  const isTrackScoped = kind === "object-event" || kind === "track-end";
5879
- return rule.targets.filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
6713
+ const direct = new Set(rule.targets.map((t) => t.targetId));
6714
+ return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
5880
6715
  const payload = {
5881
6716
  ruleName: rule.name,
5882
6717
  delivery: rule.delivery,
5883
6718
  priority: rule.priority,
5884
6719
  ...rule.template !== void 0 ? { template: rule.template } : {},
5885
6720
  media: rule.media.attach,
6721
+ ...rule.media.zoneCrop === true && rule.conditions.zones !== void 0 ? { mediaZoneIds: [...rule.conditions.zones.ids] } : {},
6722
+ ...rule.media.frame !== void 0 ? { mediaFrame: rule.media.frame } : {},
6723
+ ...rule.media.profile !== void 0 ? { mediaProfile: rule.media.profile } : {},
6724
+ ...rule.media.gif === true ? { mediaGif: true } : {},
6725
+ ...rule.media.clip === true ? { mediaClip: true } : {},
6726
+ ...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
6727
+ ...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
5886
6728
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
5887
6729
  ...target.params !== void 0 ? { params: target.params } : {},
5888
6730
  subject: {
@@ -5908,6 +6750,63 @@ var NotificationCenter = class {
5908
6750
  };
5909
6751
  });
5910
6752
  }
6753
+ /** rule→user fan-out caches (60 s users/targets; device routes immutable). */
6754
+ userGrantsCache = null;
6755
+ userGrantsAt = 0;
6756
+ ownedTargetsCache = null;
6757
+ ownedTargetsAt = 0;
6758
+ deviceRouteCache = /* @__PURE__ */ new Map();
6759
+ userFanoutUnavailableLogged = false;
6760
+ /**
6761
+ * The personal targets a rule's `targetUsers` resolve to for THIS device:
6762
+ * each addressed user contributes the enabled targets they own — but only
6763
+ * when their `allowedDevices` grant covers the firing camera (admin users
6764
+ * always pass). A user must never be notified about a device they cannot
6765
+ * open.
6766
+ */
6767
+ async resolveUserTargets(rule, deviceId) {
6768
+ const users = rule.targetUsers;
6769
+ if (users === void 0 || users.length === 0) return [];
6770
+ const { listUsers, getDeviceRoute } = this.deps;
6771
+ if (listUsers === void 0 || getDeviceRoute === void 0) {
6772
+ if (!this.userFanoutUnavailableLogged) {
6773
+ this.userFanoutUnavailableLogged = true;
6774
+ this.logger.warn("rule addresses users but the user fan-out deps are not wired", { meta: { ruleId: rule.id } });
6775
+ }
6776
+ return [];
6777
+ }
6778
+ const now = this.now();
6779
+ if (this.userGrantsCache === null || now - this.userGrantsAt > 6e4) {
6780
+ this.userGrantsCache = await listUsers();
6781
+ this.userGrantsAt = now;
6782
+ }
6783
+ if (this.ownedTargetsCache === null || now - this.ownedTargetsAt > 6e4) {
6784
+ const targets = await this.deps.dispatcher.listTargets();
6785
+ const owned = /* @__PURE__ */ new Map();
6786
+ for (const t of targets) {
6787
+ if (t.ownerUserId === void 0 || !t.enabled) continue;
6788
+ const list = owned.get(t.ownerUserId) ?? [];
6789
+ list.push(t.id);
6790
+ owned.set(t.ownerUserId, list);
6791
+ }
6792
+ this.ownedTargetsCache = owned;
6793
+ this.ownedTargetsAt = now;
6794
+ }
6795
+ if (!this.deviceRouteCache.has(deviceId)) this.deviceRouteCache.set(deviceId, await getDeviceRoute(deviceId));
6796
+ const route = this.deviceRouteCache.get(deviceId) ?? null;
6797
+ const out = [];
6798
+ for (const userId of users) {
6799
+ const user = this.userGrantsCache.find((u) => u.id === userId);
6800
+ if (user === void 0) continue;
6801
+ if (!user.isAdmin) {
6802
+ if (route === null) continue;
6803
+ const grant = user.allowedDevices[route.addonId];
6804
+ if (!(grant === "*" || Array.isArray(grant) && grant.includes(route.stableId))) continue;
6805
+ }
6806
+ for (const targetId of this.ownedTargetsCache.get(userId) ?? []) out.push({ targetId });
6807
+ }
6808
+ return out;
6809
+ }
5911
6810
  /** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
5912
6811
  async seedCooldowns() {
5913
6812
  const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
@@ -6021,10 +6920,21 @@ var NotificationCenter = class {
6021
6920
  this.drainTicks += 1;
6022
6921
  if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
6023
6922
  }
6923
+ /** The "at least one addressee" invariant lives here, not in Zod: the
6924
+ * schema allows empty `targets` (a users-only rule) and a cross-field
6925
+ * refine would break `NcRulePatchSchema.partial()`. */
6926
+ static assertHasAddressee(targets, targetUsers) {
6927
+ if (targets.length === 0 && (targetUsers?.length ?? 0) === 0) throw new Error("a rule needs at least one delivery target or user");
6928
+ }
6024
6929
  /** Rule-save referential check: every targetId must resolve in the
6025
6930
  * live notification-output catalog (spec §2.3 save-time validation). */
6026
6931
  async validateTargetRefs(targetIds) {
6932
+ if (targetIds.length === 0) return;
6027
6933
  const targets = await this.deps.dispatcher.listTargets();
6934
+ if (targets.length === 0) {
6935
+ 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] } });
6936
+ return;
6937
+ }
6028
6938
  const known = new Set(targets.map((t) => t.id));
6029
6939
  for (const id of targetIds) if (!known.has(id)) throw new Error(`unknown notification target: ${id}`);
6030
6940
  }
@@ -6139,15 +7049,28 @@ function makeNcActionHandlers(deps) {
6139
7049
  if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
6140
7050
  return rule;
6141
7051
  };
6142
- const assertRuleOwned = (ruleId, userId) => {
7052
+ /**
7053
+ * A rule the caller may EDIT. Ownership binds non-admins; an ADMIN
7054
+ * administers every rule — global ones (`ownerUserId: undefined`, what the
7055
+ * admin UI creates) and, for support, a user's personal rule.
7056
+ *
7057
+ * Without the admin bypass the comparison `rule.ownerUserId === userId` is
7058
+ * false for EVERY caller on a global rule, so the viewer refused to let an
7059
+ * admin edit an admin rule (operator report, 2026-07-30). Same bypass shape
7060
+ * as `assertTargetsOwned`.
7061
+ */
7062
+ const assertRuleEditable = (ruleId, caller) => {
6143
7063
  const rule = assertOwnsRule(ruleId);
6144
- if (rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7064
+ if (caller.isAdmin) return rule;
7065
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6145
7066
  return rule;
6146
7067
  };
6147
- /** A rule the caller may SEE — his own personal rule OR a global/admin rule. */
6148
- const assertRuleVisible = (ruleId, userId) => {
7068
+ /** A rule the caller may SEE — his own personal rule, a global/admin rule, or
7069
+ * (for an admin) any rule at all. */
7070
+ const assertRuleVisible = (ruleId, caller) => {
6149
7071
  const rule = assertOwnsRule(ruleId);
6150
- if (rule.ownerUserId !== void 0 && rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7072
+ if (caller.isAdmin) return rule;
7073
+ if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6151
7074
  return rule;
6152
7075
  };
6153
7076
  const assertTargetsOwned = async (targetIds, caller) => {
@@ -6158,9 +7081,9 @@ function makeNcActionHandlers(deps) {
6158
7081
  return {
6159
7082
  "nc.listRules": async (_input, caller) => {
6160
7083
  const c = requireCaller(caller);
6161
- return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
7084
+ return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
6162
7085
  ...r,
6163
- readOnly: r.ownerUserId !== c.userId
7086
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
6164
7087
  })) };
6165
7088
  },
6166
7089
  "nc.getConditionCatalog": async () => ({
@@ -6183,7 +7106,7 @@ function makeNcActionHandlers(deps) {
6183
7106
  },
6184
7107
  "nc.updateRule": async (input, caller) => {
6185
7108
  const c = requireCaller(caller);
6186
- assertRuleOwned(input.ruleId, c.userId);
7109
+ assertRuleEditable(input.ruleId, c);
6187
7110
  const patch = NcRulePatchSchema.parse(input.patch);
6188
7111
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
6189
7112
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
@@ -6196,7 +7119,7 @@ function makeNcActionHandlers(deps) {
6196
7119
  },
6197
7120
  "nc.deleteRule": async (input, caller) => {
6198
7121
  const c = requireCaller(caller);
6199
- assertRuleOwned(input.ruleId, c.userId);
7122
+ assertRuleEditable(input.ruleId, c);
6200
7123
  await deps.ruleStore.delete(input.ruleId);
6201
7124
  deps.logger.info("nc rule deleted", { meta: {
6202
7125
  ruleId: input.ruleId,
@@ -6206,7 +7129,7 @@ function makeNcActionHandlers(deps) {
6206
7129
  },
6207
7130
  "nc.setRuleTargetEnabled": async (input, caller) => {
6208
7131
  const c = requireCaller(caller);
6209
- assertRuleVisible(input.ruleId, c.userId);
7132
+ assertRuleVisible(input.ruleId, c);
6210
7133
  await assertTargetsOwned([input.targetId], c);
6211
7134
  await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
6212
7135
  return { success: true };
@@ -8858,7 +9781,15 @@ var MEDIA_COLUMNS = [
8858
9781
  name: "sizeBytes",
8859
9782
  type: "INTEGER",
8860
9783
  notNull: true
8861
- }
9784
+ },
9785
+ (
9786
+ /** Storage location holding the blob. NULL = the default `eventMedia`
9787
+ * location (every pre-Phase-3 row, and every write until multi-location
9788
+ * events exist) — the relocate mover stamps real ids as it moves blobs. */
9789
+ {
9790
+ name: "locationId",
9791
+ type: "TEXT"
9792
+ })
8862
9793
  ];
8863
9794
  var MEDIA_INDEXES = [{
8864
9795
  name: "idx_media_owner",
@@ -8867,11 +9798,18 @@ var MEDIA_INDEXES = [{
8867
9798
  name: "idx_media_device_ts",
8868
9799
  columns: ["deviceId", "timestamp"]
8869
9800
  }];
9801
+ /** The storage location of a media row/record: its stamped `locationId`, or
9802
+ * the default `eventMedia` location for NULL (pre-multi-location) rows. */
9803
+ var DEFAULT_MEDIA_LOCATION = "eventMedia";
9804
+ function mediaRowLocation(data) {
9805
+ const id = data?.["locationId"];
9806
+ return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
9807
+ }
8870
9808
  function buildKey(params) {
8871
9809
  return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
8872
9810
  }
8873
9811
  function buildPath(params) {
8874
- const base = `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}`;
9812
+ const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
8875
9813
  return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
8876
9814
  }
8877
9815
  var MediaStore = class {
@@ -8902,7 +9840,8 @@ var MediaStore = class {
8902
9840
  kind: params.kind,
8903
9841
  timestamp: params.timestamp,
8904
9842
  path,
8905
- sizeBytes: params.data.length
9843
+ sizeBytes: params.data.length,
9844
+ locationId: null
8906
9845
  };
8907
9846
  try {
8908
9847
  await this.storage.write({
@@ -8959,10 +9898,11 @@ var MediaStore = class {
8959
9898
  const newKey = await this.put(params);
8960
9899
  for (const row of existing) {
8961
9900
  if (row.id === newKey) continue;
8962
- const path = String(row.data["path"] ?? "");
9901
+ const rowData = row.data;
9902
+ const path = String(rowData["path"] ?? "");
8963
9903
  if (path) try {
8964
9904
  await this.storage.delete({
8965
- location: "eventMedia",
9905
+ location: mediaRowLocation(rowData),
8966
9906
  relativePath: path
8967
9907
  });
8968
9908
  } catch {}
@@ -9030,7 +9970,7 @@ var MediaStore = class {
9030
9970
  key,
9031
9971
  kind,
9032
9972
  base64: (await this.storage.read({
9033
- location: "eventMedia",
9973
+ location: mediaRowLocation(data),
9034
9974
  relativePath: path
9035
9975
  })).toString("base64"),
9036
9976
  sizeBytes,
@@ -9050,10 +9990,11 @@ var MediaStore = class {
9050
9990
  key
9051
9991
  });
9052
9992
  if (!row) return;
9053
- const path = String(row["path"] ?? "");
9993
+ const data = row;
9994
+ const path = String(data["path"] ?? "");
9054
9995
  if (path) try {
9055
9996
  await this.storage.delete({
9056
- location: "eventMedia",
9997
+ location: mediaRowLocation(data),
9057
9998
  relativePath: path
9058
9999
  });
9059
10000
  } catch {}
@@ -9092,7 +10033,7 @@ var MediaStore = class {
9092
10033
  const kind = String(data["kind"]);
9093
10034
  try {
9094
10035
  const buf = await this.storage.read({
9095
- location: "eventMedia",
10036
+ location: mediaRowLocation(data),
9096
10037
  relativePath: path
9097
10038
  });
9098
10039
  files.push({
@@ -9127,10 +10068,11 @@ var MediaStore = class {
9127
10068
  } }
9128
10069
  });
9129
10070
  for (const row of rows) {
9130
- const path = String(row.data["path"] ?? "");
10071
+ const rowData = row.data;
10072
+ const path = String(rowData["path"] ?? "");
9131
10073
  try {
9132
10074
  if (path) await this.storage.delete({
9133
- location: "eventMedia",
10075
+ location: mediaRowLocation(rowData),
9134
10076
  relativePath: path
9135
10077
  });
9136
10078
  } catch {}
@@ -9226,14 +10168,14 @@ var MediaStore = class {
9226
10168
  kind,
9227
10169
  timestamp,
9228
10170
  data: await this.storage.read({
9229
- location: "eventMedia",
10171
+ location: mediaRowLocation(meta),
9230
10172
  relativePath: oldPath
9231
10173
  })
9232
10174
  };
9233
10175
  const newKey = await this.put(newParams);
9234
10176
  try {
9235
10177
  await this.storage.delete({
9236
- location: "eventMedia",
10178
+ location: mediaRowLocation(meta),
9237
10179
  relativePath: oldPath
9238
10180
  });
9239
10181
  } catch (err) {
@@ -9303,10 +10245,11 @@ var MediaStore = class {
9303
10245
  if (rows.length === 0) break;
9304
10246
  let deletedInPage = 0;
9305
10247
  for (const row of rows) {
9306
- const path = String(row.data["path"] ?? "");
10248
+ const rowData = row.data;
10249
+ const path = String(rowData["path"] ?? "");
9307
10250
  try {
9308
10251
  if (path) await this.storage.delete({
9309
- location: "eventMedia",
10252
+ location: mediaRowLocation(rowData),
9310
10253
  relativePath: path
9311
10254
  });
9312
10255
  } catch {}
@@ -9862,6 +10805,7 @@ var EventStore = class {
9862
10805
  const rows = await this.store.query.query({
9863
10806
  collection,
9864
10807
  filter: {
10808
+ ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
9865
10809
  whereBetween: { timestamp: [0, cutoffMs] },
9866
10810
  limit: 500
9867
10811
  }
@@ -10262,6 +11206,183 @@ function stripNulls(data) {
10262
11206
  return out;
10263
11207
  }
10264
11208
  //#endregion
11209
+ //#region src/pipeline-analytics/location-aware-media-storage.ts
11210
+ /**
11211
+ * Location-aware blob storage for event media (entity-routing spec, Phase 3).
11212
+ *
11213
+ * Media rows may carry a `locationId` (stamped by the relocate mover when a
11214
+ * blob is moved off the default location). The write-rate bypass provider
11215
+ * only knows the default media root — this wrapper routes any OTHER location
11216
+ * id through a resolver (the storage cap's `resolve`, cached forever: a
11217
+ * location's root only changes via operator reconfig, which restarts us) and
11218
+ * does direct fs I/O against that root, keeping the bypass's
11219
+ * no-RPC-per-blob property for every location.
11220
+ */
11221
+ function createLocationAwareMediaStorage(deps) {
11222
+ const roots = /* @__PURE__ */ new Map();
11223
+ const rootOf = async (locationId) => {
11224
+ const cached = roots.get(locationId);
11225
+ if (cached !== void 0) return cached;
11226
+ const root = await deps.resolveRoot(locationId);
11227
+ roots.set(locationId, root);
11228
+ return root;
11229
+ };
11230
+ const absOf = async (locationId, relativePath) => path.join(await rootOf(locationId), relativePath);
11231
+ return {
11232
+ write: async (input) => {
11233
+ if (input.location === deps.defaultLocation) return deps.base.write(input);
11234
+ const abs = await absOf(input.location, input.relativePath);
11235
+ await promises.mkdir(path.dirname(abs), { recursive: true });
11236
+ await promises.writeFile(abs, input.data);
11237
+ },
11238
+ read: async (input) => {
11239
+ if (input.location === deps.defaultLocation) return deps.base.read(input);
11240
+ return promises.readFile(await absOf(input.location, input.relativePath));
11241
+ },
11242
+ delete: async (input) => {
11243
+ if (input.location === deps.defaultLocation) return deps.base.delete(input);
11244
+ await promises.rm(await absOf(input.location, input.relativePath), { force: true });
11245
+ }
11246
+ };
11247
+ }
11248
+ //#endregion
11249
+ //#region src/pipeline-analytics/media-relocate-engine.ts
11250
+ var PAGE_SIZE = 200;
11251
+ var DEFAULT_THROTTLE_MBPS = 40;
11252
+ function snapshot(j) {
11253
+ return {
11254
+ jobId: j.jobId,
11255
+ state: j.state,
11256
+ fromLocationId: j.fromLocationId,
11257
+ toLocationId: j.toLocationId,
11258
+ deviceId: j.deviceId,
11259
+ entities: ["media"],
11260
+ filesMoved: j.filesMoved,
11261
+ bytesMoved: j.bytesMoved,
11262
+ filesTotal: null,
11263
+ startedAt: j.startedAt,
11264
+ finishedAt: j.finishedAt,
11265
+ error: j.error
11266
+ };
11267
+ }
11268
+ var MediaRelocateEngine = class {
11269
+ deps;
11270
+ jobs = /* @__PURE__ */ new Map();
11271
+ constructor(deps) {
11272
+ this.deps = deps;
11273
+ }
11274
+ list() {
11275
+ return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
11276
+ }
11277
+ cancel(jobId) {
11278
+ const job = this.jobs.get(jobId);
11279
+ if (!job || job.state !== "running") return false;
11280
+ job.cancelRequested = true;
11281
+ return true;
11282
+ }
11283
+ start(input) {
11284
+ for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
11285
+ const job = {
11286
+ jobId: this.deps.newId(),
11287
+ state: "running",
11288
+ fromLocationId: "*",
11289
+ toLocationId: input.toLocationId,
11290
+ deviceId: input.deviceId ?? null,
11291
+ filesMoved: 0,
11292
+ bytesMoved: 0,
11293
+ startedAt: this.deps.now(),
11294
+ finishedAt: null,
11295
+ error: null,
11296
+ cancelRequested: false
11297
+ };
11298
+ this.jobs.set(job.jobId, job);
11299
+ this.run(job, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
11300
+ return job.jobId;
11301
+ }
11302
+ async run(job, throttleMbps) {
11303
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
11304
+ const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
11305
+ try {
11306
+ await this.deps.resolveTargetRoot(job.toLocationId);
11307
+ let cursor = 0;
11308
+ let seenAtCursor = /* @__PURE__ */ new Set();
11309
+ for (;;) {
11310
+ if (job.cancelRequested) break;
11311
+ const fresh = (await this.deps.store.query.query({
11312
+ collection: MEDIA_COLLECTION,
11313
+ filter: {
11314
+ ...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
11315
+ whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
11316
+ orderBy: {
11317
+ field: "timestamp",
11318
+ direction: "asc"
11319
+ },
11320
+ limit: PAGE_SIZE
11321
+ }
11322
+ })).filter((r) => !seenAtCursor.has(r.id));
11323
+ if (fresh.length === 0) break;
11324
+ for (const row of fresh) {
11325
+ if (job.cancelRequested) break;
11326
+ const data = row.data;
11327
+ const from = mediaRowLocation(data);
11328
+ if (from === job.toLocationId) continue;
11329
+ const relativePath = String(data["path"] ?? "");
11330
+ if (relativePath.length === 0) continue;
11331
+ try {
11332
+ const bytes = await this.deps.storage.read({
11333
+ location: from,
11334
+ relativePath
11335
+ });
11336
+ await this.deps.storage.write({
11337
+ location: job.toLocationId,
11338
+ relativePath,
11339
+ data: bytes
11340
+ });
11341
+ await this.deps.store.set.mutate({
11342
+ collection: MEDIA_COLLECTION,
11343
+ key: row.id,
11344
+ value: {
11345
+ ...data,
11346
+ locationId: job.toLocationId
11347
+ }
11348
+ });
11349
+ await this.deps.storage.delete({
11350
+ location: from,
11351
+ relativePath
11352
+ });
11353
+ job.filesMoved++;
11354
+ job.bytesMoved += bytes.length;
11355
+ await sleep(bytes.length / bytesPerMs);
11356
+ } catch (err) {
11357
+ this.deps.logger.debug("media relocate row failed", { meta: {
11358
+ key: row.id,
11359
+ error: String(err)
11360
+ } });
11361
+ }
11362
+ }
11363
+ const last = fresh[fresh.length - 1];
11364
+ const lastTs = Number(last.data["timestamp"] ?? cursor);
11365
+ if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
11366
+ else {
11367
+ cursor = lastTs;
11368
+ seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
11369
+ }
11370
+ }
11371
+ job.state = job.cancelRequested ? "cancelled" : "done";
11372
+ } catch (err) {
11373
+ job.state = "failed";
11374
+ job.error = err instanceof Error ? err.message : String(err);
11375
+ this.deps.logger.warn("media relocate job failed", { meta: {
11376
+ jobId: job.jobId,
11377
+ error: job.error
11378
+ } });
11379
+ } finally {
11380
+ job.finishedAt = this.deps.now();
11381
+ this.deps.onFinished?.(snapshot(job));
11382
+ }
11383
+ }
11384
+ };
11385
+ //#endregion
10265
11386
  //#region src/pipeline-analytics/store/sensor-event-store.ts
10266
11387
  var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
10267
11388
  var SENSOR_EVENT_COLUMNS = [
@@ -11388,7 +12509,7 @@ var EventMediaDispatcher = class {
11388
12509
  if (sn.rollingLastFrame && boxed) lastFrameWritten = await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
11389
12510
  let thumbnailWritten = false;
11390
12511
  if (sn.bestThumbnail) {
11391
- const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
12512
+ const variants = await this.cropSubjectVariants(deviceId, frameHandle, fw, fh, sn.bbox);
11392
12513
  if (variants) {
11393
12514
  thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
11394
12515
  await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
@@ -11490,12 +12611,15 @@ var EventMediaDispatcher = class {
11490
12611
  * per-frame retry lands a real native crop later. Never a local resize
11491
12612
  * upscale of a ≤640 tile (a blurred lie).
11492
12613
  */
11493
- async cropSubjectVariants(frameHandle, fw, fh, bbox) {
12614
+ async cropSubjectVariants(deviceId, frameHandle, fw, fh, bbox) {
11494
12615
  if (!this.deps.getNativeCropJpeg) {
11495
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11496
- shmId: frameHandle.shmId,
11497
- reason: "no-native-cap"
11498
- } });
12616
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12617
+ tags: { deviceId },
12618
+ meta: {
12619
+ shmId: frameHandle.shmId,
12620
+ reason: "no-native-cap"
12621
+ }
12622
+ });
11499
12623
  return null;
11500
12624
  }
11501
12625
  try {
@@ -11503,12 +12627,19 @@ var EventMediaDispatcher = class {
11503
12627
  W: fw,
11504
12628
  H: fh
11505
12629
  });
12630
+ const askedAt = Date.now();
11506
12631
  const slab = await this.deps.getNativeCropJpeg(frameHandle, layout.fetch);
11507
12632
  if (!slab) {
11508
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11509
- shmId: frameHandle.shmId,
11510
- reason: "native-miss"
11511
- } });
12633
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12634
+ tags: { deviceId },
12635
+ meta: {
12636
+ shmId: frameHandle.shmId,
12637
+ handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
12638
+ handleNodeId: frameHandle.nodeId,
12639
+ roundTripMs: Date.now() - askedAt,
12640
+ reason: "native-miss"
12641
+ }
12642
+ });
11512
12643
  return null;
11513
12644
  }
11514
12645
  const thumbnail = await composeWideCentralSquareThumbnail(slab, layout);
@@ -11517,10 +12648,14 @@ var EventMediaDispatcher = class {
11517
12648
  thumbnailSmall: await deriveThumbnailSmall(thumbnail)
11518
12649
  };
11519
12650
  } catch (err) {
11520
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11521
- shmId: frameHandle.shmId,
11522
- error: err instanceof Error ? err.message : String(err)
11523
- } });
12651
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12652
+ tags: { deviceId },
12653
+ meta: {
12654
+ shmId: frameHandle.shmId,
12655
+ reason: "threw",
12656
+ error: err instanceof Error ? err.message : String(err)
12657
+ }
12658
+ });
11524
12659
  return null;
11525
12660
  }
11526
12661
  }
@@ -13581,37 +14716,7 @@ var AnalyticsQueryFacade = class {
13581
14716
  }));
13582
14717
  }
13583
14718
  };
13584
- //#endregion
13585
- //#region src/pipeline-analytics/track-retention-sweep.ts
13586
- /**
13587
- * Periodic track retention sweep (design §6) — the piece that makes retention
13588
- * actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
13589
- * calls it; this sweep ages persisted tracks out on the provider's existing
13590
- * retention interval.
13591
- *
13592
- * `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
13593
- * `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
13594
- * cascade store layer (design §4), not here.
13595
- *
13596
- * Kept as a pure, dependency-injected function so the loop is unit-testable
13597
- * without booting the addon.
13598
- */
13599
- var DAY_MS = 1440 * 60 * 1e3;
13600
- /** Per-device track retention setting. Default 7 days; 0 = keep forever. */
13601
- var TrackRetentionSettingsSchema = object({ trackRetentionDays: number().min(0).default(7) });
13602
- var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
13603
- /** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
13604
- * missing value falls back to the default (parse never throws). */
13605
- function resolveTrackRetentionDays(raw) {
13606
- const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
13607
- return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
13608
- }
13609
- /** Cutoff timestamp for a retention window. `null` when retention is disabled
13610
- * (`retentionDays <= 0` → keep forever, the sweep skips the device). */
13611
- function trackRetentionCutoff(nowMs, retentionDays) {
13612
- if (retentionDays <= 0) return null;
13613
- return nowMs - retentionDays * DAY_MS;
13614
- }
14719
+ var TRACK_RETENTION_DEFAULT_DAYS = object({ trackRetentionDays: number().min(0).default(7) }).parse({}).trackRetentionDays;
13615
14720
  /**
13616
14721
  * Sweep every device with persisted tracks: skip retention-disabled devices,
13617
14722
  * prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
@@ -13622,7 +14727,7 @@ async function sweepTrackRetention(deps) {
13622
14727
  const nowMs = deps.now();
13623
14728
  let totalTracks = 0;
13624
14729
  for (const deviceId of devices) try {
13625
- const cutoffMs = trackRetentionCutoff(nowMs, await deps.resolveRetentionDays(deviceId));
14730
+ const cutoffMs = await deps.resolveCutoffMs(deviceId, nowMs);
13626
14731
  if (cutoffMs === null) continue;
13627
14732
  const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
13628
14733
  totalTracks += counts.tracks;
@@ -14187,6 +15292,19 @@ function retagDetectionSections(sections) {
14187
15292
  } : s);
14188
15293
  }
14189
15294
  /**
15295
+ * Re-home the analytics `retention` section onto the recorder's `recording`
15296
+ * top-tab (operator ask, 2026-07-29): footage and analytics retention are ONE
15297
+ * unified policy since the follow-recordings default, so their controls
15298
+ * belong on ONE tab. Same pure-retag mechanism as the detection sections —
15299
+ * `DeviceDetail` folds matching-tab sections together, no admin-ui change.
15300
+ */
15301
+ function retagRetentionSection(sections) {
15302
+ return sections.map((s) => s.id === "retention" ? {
15303
+ ...s,
15304
+ tab: "recording"
15305
+ } : s);
15306
+ }
15307
+ /**
14190
15308
  * Fields that live ONLY on the global settings page and must never surface in a
14191
15309
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
14192
15310
  * master kill for the whole subsystem — per-camera face production is governed
@@ -14365,9 +15483,23 @@ function buildGlobalSettingsSchema() {
14365
15483
  {
14366
15484
  id: "retention",
14367
15485
  title: "Retention",
14368
- description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
15486
+ 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.",
14369
15487
  columns: 3,
14370
15488
  fields: [
15489
+ {
15490
+ type: "select",
15491
+ key: "retentionMode",
15492
+ label: "Mode",
15493
+ 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.",
15494
+ default: "follow-recordings",
15495
+ options: [{
15496
+ value: "follow-recordings",
15497
+ label: "Follow recordings (default)"
15498
+ }, {
15499
+ value: "custom",
15500
+ label: "Custom windows"
15501
+ }]
15502
+ },
14371
15503
  {
14372
15504
  type: "number",
14373
15505
  key: "trackRetentionDays",
@@ -14902,6 +16034,86 @@ async function encodeKeyFrameVariants(native) {
14902
16034
  };
14903
16035
  }
14904
16036
  //#endregion
16037
+ //#region src/pipeline-analytics/retention-policy.ts
16038
+ /**
16039
+ * Unified analytics retention policy (storage entity-routing spec, Phase 1).
16040
+ *
16041
+ * Two modes, per device:
16042
+ *
16043
+ * - `follow-recordings` (the DEFAULT): tracks and events live exactly as long
16044
+ * as the camera's retained footage — ONE cutoff, the instant of the oldest
16045
+ * segment still on disk. That follows the recorder's age retention AND its
16046
+ * disk-pressure eviction automatically, with no second policy to keep in
16047
+ * sync. A camera with no footage at all (recording off / brand new) falls
16048
+ * back to the custom day windows below, so analytics stay bounded either
16049
+ * way.
16050
+ *
16051
+ * - `custom`: the per-kind day windows (tracks / motion / object / audio),
16052
+ * exactly the pre-unification behaviour — except the knobs are now actually
16053
+ * READ (they were exposed in settings and silently ignored by the sweep,
16054
+ * which used hardcoded 14/30/7).
16055
+ *
16056
+ * Follow-mode floor: events younger than {@link FOLLOW_FLOOR_DAYS} are never
16057
+ * pruned, even when footage is younger (a camera that STARTED recording
16058
+ * yesterday must not nuke its whole event history back to the first segment).
16059
+ */
16060
+ var DAY_MS = 1440 * 60 * 1e3;
16061
+ /** Per-device analytics retention settings as stored in the device blob.
16062
+ * `retentionMode` absent = follow-recordings (the new default). The day
16063
+ * windows keep their historical defaults and double as the no-footage
16064
+ * fallback in follow mode. */
16065
+ var RetentionSettingsSchema = object({
16066
+ retentionMode: _enum(["follow-recordings", "custom"]).default("follow-recordings"),
16067
+ trackRetentionDays: number().min(0).default(7),
16068
+ retentionMotionDays: number().min(1).default(14),
16069
+ retentionObjectDays: number().min(1).default(30),
16070
+ retentionAudioDays: number().min(1).default(7)
16071
+ });
16072
+ /** Resolve the settings from a raw device-store blob — invalid or missing
16073
+ * fields fall back to defaults, never throw. */
16074
+ function resolveRetentionSettings(raw) {
16075
+ const parsed = RetentionSettingsSchema.safeParse(raw);
16076
+ if (parsed.success) return parsed.data;
16077
+ const shape = RetentionSettingsSchema.shape;
16078
+ const field = (k) => shape[k].safeParse(raw[k]).success ? shape[k].parse(raw[k]) : RetentionSettingsSchema.parse({})[k];
16079
+ return {
16080
+ retentionMode: field("retentionMode"),
16081
+ trackRetentionDays: field("trackRetentionDays"),
16082
+ retentionMotionDays: field("retentionMotionDays"),
16083
+ retentionObjectDays: field("retentionObjectDays"),
16084
+ retentionAudioDays: field("retentionAudioDays")
16085
+ };
16086
+ }
16087
+ /**
16088
+ * Follow-mode cutoff: the oldest retained footage instant, floored so
16089
+ * anything younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage
16090
+ * (none on disk) → `null`, the caller falls back to custom windows.
16091
+ */
16092
+ function followCutoffMs(nowMs, earliestFootageMs) {
16093
+ if (earliestFootageMs === null) return null;
16094
+ return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS);
16095
+ }
16096
+ /** Compute the effective cutoffs for one device at `nowMs`. */
16097
+ function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
16098
+ if (settings.retentionMode === "follow-recordings") {
16099
+ const cutoff = followCutoffMs(nowMs, earliestFootageMs);
16100
+ if (cutoff !== null) return {
16101
+ trackCutoffMs: cutoff,
16102
+ motionCutoffMs: cutoff,
16103
+ objectCutoffMs: cutoff,
16104
+ audioCutoffMs: cutoff,
16105
+ effectiveMode: "follow-recordings"
16106
+ };
16107
+ }
16108
+ return {
16109
+ trackCutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS,
16110
+ motionCutoffMs: nowMs - settings.retentionMotionDays * DAY_MS,
16111
+ objectCutoffMs: nowMs - settings.retentionObjectDays * DAY_MS,
16112
+ audioCutoffMs: nowMs - settings.retentionAudioDays * DAY_MS,
16113
+ effectiveMode: "custom"
16114
+ };
16115
+ }
16116
+ //#endregion
14905
16117
  //#region src/pipeline-analytics/store/identity-store.ts
14906
16118
  /**
14907
16119
  * IdentityStore — per-person identity registry for face recognition.
@@ -18288,6 +19500,17 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
18288
19500
  * Absent / empty / non-string all fall back to the hub default — the exact
18289
19501
  * narrowing the old raw read applied inline. */
18290
19502
  var PostProcessingNodeIdSchema = string().min(1);
19503
+ /**
19504
+ * Footage-attachment window + geometry, used when the rule states none. The
19505
+ * window is CENTRED on the event, so the recipient sees the approach and what
19506
+ * followed rather than one side of it.
19507
+ */
19508
+ var NC_FOOTAGE_PRE_ROLL_SEC = 3;
19509
+ var NC_FOOTAGE_POST_ROLL_SEC = 5;
19510
+ var NC_FOOTAGE_MAX_WIDTH = 480;
19511
+ var NC_FOOTAGE_FPS = 5;
19512
+ /** Per-install HMAC secret behind the signed artifact links (minted once). */
19513
+ var NcArtifactSecretSchema = string();
18291
19514
  var EmbeddingEnabledSchema = boolean();
18292
19515
  var SILENCE_FLOOR_DBFS = -55;
18293
19516
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
@@ -18359,6 +19582,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18359
19582
  eventStore = null;
18360
19583
  /** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
18361
19584
  eventsOpsLog = null;
19585
+ /** Event-media relocation engine (entity-routing Phase 4). */
19586
+ mediaRelocate = null;
18362
19587
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
18363
19588
  sensorEventStore = null;
18364
19589
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
@@ -18417,6 +19642,88 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18417
19642
  retentionSweepTimer = null;
18418
19643
  /** Handle for the event-media data-plane listener (dispose on shutdown). */
18419
19644
  eventMediaDataPlane = null;
19645
+ /** The NC artifact plane (signed public links for notification media) and its
19646
+ * data-plane handle. Null until served / when the facility is absent. */
19647
+ ncArtifactPlane = null;
19648
+ ncArtifactDataPlane = null;
19649
+ /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
19650
+ async markedNotificationEndpoint() {
19651
+ try {
19652
+ return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
19653
+ } catch {
19654
+ return;
19655
+ }
19656
+ }
19657
+ /**
19658
+ * Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
19659
+ * each link (see `notification-center/artifact-url.ts`). It exists because
19660
+ * attachments used to carry BYTES only, and the degrade engine drops a
19661
+ * bytes-only attachment for a url-mode backend — WhatsApp and gotify were
19662
+ * receiving no media at all.
19663
+ *
19664
+ * Best-effort at every step: no facility, no secret or no reachable base URL
19665
+ * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
19666
+ */
19667
+ async serveNcArtifactPlane() {
19668
+ try {
19669
+ const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
19670
+ let secret = await secretState.get();
19671
+ if (secret === "") {
19672
+ secret = randomUUID().replace(/-/g, "");
19673
+ await secretState.set(secret);
19674
+ }
19675
+ const store = new NcArtifactStore({
19676
+ dir: path.join(this.ctx.dataDir, "nc-artifacts"),
19677
+ logger: this.ctx.logger.child("nc-artifacts")
19678
+ });
19679
+ await store.start();
19680
+ const plane = new NcArtifactPlane({
19681
+ store,
19682
+ secret,
19683
+ logger: this.ctx.logger.child("nc-artifacts"),
19684
+ routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
19685
+ listEndpoints: async () => collectArtifactEndpoints({
19686
+ markedBaseUrl: await this.markedNotificationEndpoint(),
19687
+ configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
19688
+ getConnected: async () => {
19689
+ const status = await this.ctx.api.networkAccess.getStatus.query();
19690
+ this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
19691
+ connected: status.connected,
19692
+ url: status.endpoint?.url ?? null,
19693
+ protocol: status.endpoint?.protocol ?? null
19694
+ } });
19695
+ return status.connected && status.endpoint !== null ? {
19696
+ url: status.endpoint.url,
19697
+ protocol: status.endpoint.protocol
19698
+ } : null;
19699
+ },
19700
+ listExternal: async () => {
19701
+ return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
19702
+ url: e.url,
19703
+ protocol: e.protocol
19704
+ }));
19705
+ },
19706
+ listLan: async (port) => {
19707
+ return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
19708
+ baseUrl: e.baseUrl,
19709
+ kind: e.kind,
19710
+ priority: e.priority
19711
+ }));
19712
+ },
19713
+ logger: this.ctx.logger
19714
+ })
19715
+ });
19716
+ this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
19717
+ prefix: "nc-artifact",
19718
+ access: "public",
19719
+ handler: plane.handler
19720
+ }) ?? null;
19721
+ this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
19722
+ this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
19723
+ } catch (err) {
19724
+ this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: errMsg(err) } });
19725
+ }
19726
+ }
18420
19727
  /** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
18421
19728
  * Set once the data-plane is registered; null until then (e.g. no
18422
19729
  * dataPlane facility in the current environment). */
@@ -18720,12 +20027,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18720
20027
  let storage = this.ctx.kernel.storage;
18721
20028
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
18722
20029
  if (mediaRoot) {
18723
- const { FilesystemStorageProvider } = await import("../node-BuPMb-ti.mjs");
18724
- storage = new FilesystemStorageProvider(mediaRoot);
20030
+ const { FilesystemStorageProvider } = await import("../node-BmVBZiOw.mjs");
20031
+ storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
18725
20032
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
18726
20033
  }
18727
20034
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
18728
- return storage;
20035
+ return createLocationAwareMediaStorage({
20036
+ base: storage,
20037
+ defaultLocation: "eventMedia",
20038
+ resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
20039
+ location: locationId,
20040
+ relativePath: ""
20041
+ })
20042
+ });
18729
20043
  }
18730
20044
  /** Constructs every SQLite-backed store plus the stationary/package-drop/
18731
20045
  * sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
@@ -18773,6 +20087,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18773
20087
  logger: logger.child("MediaStore")
18774
20088
  });
18775
20089
  this.mediaStore = mediaStore;
20090
+ this.mediaRelocate = new MediaRelocateEngine({
20091
+ store: api.settingsStore,
20092
+ storage,
20093
+ logger: logger.child("MediaRelocate"),
20094
+ now: () => Date.now(),
20095
+ newId: () => randomUUID(),
20096
+ resolveTargetRoot: (locationId) => api.storage.resolve.query({
20097
+ location: locationId,
20098
+ relativePath: ""
20099
+ }),
20100
+ onFinished: (job) => {
20101
+ this.eventsOpsLog?.append({
20102
+ op: "relocate",
20103
+ reason: "operator",
20104
+ deviceId: job.deviceId,
20105
+ itemsAffected: job.filesMoved,
20106
+ bytesReclaimed: 0,
20107
+ detail: `${job.state}: media → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
20108
+ });
20109
+ }
20110
+ });
18776
20111
  const eventStore = new EventStore({
18777
20112
  store: api.settingsStore,
18778
20113
  logger: logger.child("EventStore"),
@@ -19018,15 +20353,59 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19018
20353
  this.notificationCenter = new NotificationCenter({
19019
20354
  store: api.settingsStore,
19020
20355
  logger: logger.child("NotificationCenter"),
20356
+ listUsers: async () => {
20357
+ return (await api.userManagement.listUsers.query()).map((u) => ({
20358
+ id: u.id,
20359
+ isAdmin: u.isAdmin,
20360
+ allowedDevices: u.allowedDevices
20361
+ }));
20362
+ },
20363
+ getDeviceRoute: async (deviceId) => {
20364
+ const device = await api.deviceManager.getDevice.query({ deviceId });
20365
+ return device === null ? null : {
20366
+ stableId: device.stableId,
20367
+ addonId: device.addonId
20368
+ };
20369
+ },
19021
20370
  dispatcher: {
20371
+ getZonePolygons: async (deviceId, zoneIds) => {
20372
+ const zones = await api.zones.listZones.query({ deviceId });
20373
+ const wanted = new Set(zoneIds);
20374
+ return zones.filter((z) => wanted.has(z.id) && Array.isArray(z.polygon)).map((z) => (z.polygon ?? []).map((pt) => ({
20375
+ x: pt.x,
20376
+ y: pt.y
20377
+ })));
20378
+ },
20379
+ renderFootage: async (req) => {
20380
+ const res = await api.streamBroker.renderPreBufferClip.mutate({
20381
+ deviceId: req.deviceId,
20382
+ aroundMs: req.aroundMs,
20383
+ format: req.format,
20384
+ preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
20385
+ postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
20386
+ maxWidth: NC_FOOTAGE_MAX_WIDTH,
20387
+ fps: NC_FOOTAGE_FPS,
20388
+ ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
20389
+ });
20390
+ const buf = Buffer.from(res.base64, "base64");
20391
+ if (buf.byteLength === 0) return null;
20392
+ const bytes = new Uint8Array(buf.byteLength);
20393
+ bytes.set(buf);
20394
+ return bytes;
20395
+ },
20396
+ publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
19022
20397
  listTargets: async () => {
19023
- return (await api.notificationOutput.listTargets.query({})).map((t) => ({
19024
- id: t.id,
19025
- addonId: t.addonId,
19026
- name: t.name,
19027
- kind: t.kind,
19028
- enabled: t.enabled
19029
- }));
20398
+ return (await api.notificationOutput.listTargets.query({})).map((t) => {
20399
+ const owner = t.config["ownerUserId"];
20400
+ return {
20401
+ id: t.id,
20402
+ addonId: t.addonId,
20403
+ name: t.name,
20404
+ kind: t.kind,
20405
+ enabled: t.enabled,
20406
+ ...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
20407
+ };
20408
+ });
19030
20409
  },
19031
20410
  send: async (input) => {
19032
20411
  const { attachments, ...notification } = input.notification;
@@ -19080,6 +20459,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19080
20459
  handler
19081
20460
  }) ?? null;
19082
20461
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
20462
+ await this.serveNcArtifactPlane();
19083
20463
  this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
19084
20464
  } catch (err) {
19085
20465
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
@@ -20724,42 +22104,112 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20724
22104
  if (this.shuttingDown || !this.trackStore) return;
20725
22105
  await this.trackCloser.sweep();
20726
22106
  }
22107
+ /** Unified retention (Phase 1): earliest retained footage per device — the
22108
+ * follow-recordings cutoff source. Cached 10 min: availability answers in
22109
+ * ~70 ms but the sweep asks once per device per pass. `null` = no footage
22110
+ * (or recorder unreachable) → the policy falls back to the custom windows. */
22111
+ earliestFootageCache = /* @__PURE__ */ new Map();
22112
+ async earliestFootageMs(deviceId) {
22113
+ const cached = this.earliestFootageCache.get(deviceId);
22114
+ if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
22115
+ let value = null;
22116
+ try {
22117
+ const res = await this.ctx.api.recording.getAvailability.query({
22118
+ deviceId,
22119
+ fromMs: 0,
22120
+ toMs: Date.now()
22121
+ });
22122
+ let min = Number.POSITIVE_INFINITY;
22123
+ for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
22124
+ value = Number.isFinite(min) ? min : null;
22125
+ } catch {
22126
+ value = null;
22127
+ }
22128
+ this.earliestFootageCache.set(deviceId, {
22129
+ at: Date.now(),
22130
+ value
22131
+ });
22132
+ return value;
22133
+ }
22134
+ /** The per-device effective cutoffs (mode + footage → numbers). */
22135
+ async deviceRetentionCutoffs(deviceId, nowMs) {
22136
+ const settings = resolveRetentionSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
22137
+ return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
22138
+ }
22139
+ /** The devices one retention pass covers: every camera the hub knows plus
22140
+ * any device that still has persisted tracks (covers deleted cameras whose
22141
+ * history must keep aging out). */
22142
+ async retentionDeviceIds() {
22143
+ const ids = /* @__PURE__ */ new Set();
22144
+ try {
22145
+ const all = await this.ctx.api.deviceManager.listAll.query({});
22146
+ for (const d of all) ids.add(d.id);
22147
+ } catch {}
22148
+ try {
22149
+ for (const id of await this.trackStore?.listDeviceIds() ?? []) ids.add(id);
22150
+ } catch {}
22151
+ return [...ids];
22152
+ }
20727
22153
  async sweepRetention() {
20728
22154
  if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
20729
22155
  const now = Date.now();
20730
22156
  const day = 1440 * 60 * 1e3;
20731
- const OBJECT_RETENTION_DAYS = 30;
20732
- const motionCutoffMs = now - 14 * day;
20733
- const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
20734
- const audioCutoffMs = now - 7 * day;
20735
22157
  try {
20736
- const evicted = await this.eventStore.evictBefore({
20737
- motionCutoffMs,
20738
- objectCutoffMs,
20739
- audioCutoffMs
20740
- });
20741
- const evictedIds = [
20742
- ...evicted.motion,
20743
- ...evicted.object,
20744
- ...evicted.audio
20745
- ];
20746
- if (evictedIds.length > 0) {
20747
- await this.mediaStore.deleteForEvents(evictedIds);
20748
- this.ctx.logger.info("analytics event eviction (age sweep)", { meta: {
20749
- motion: evicted.motion.length,
20750
- object: evicted.object.length,
20751
- audio: evicted.audio.length,
20752
- motionCutoffMs,
20753
- objectCutoffMs,
20754
- audioCutoffMs
20755
- } });
22158
+ const deviceIds = await this.retentionDeviceIds();
22159
+ const evictedIds = [];
22160
+ let minObjectCutoffMs = Number.POSITIVE_INFINITY;
22161
+ for (const deviceId of deviceIds) {
22162
+ if (this.shuttingDown) return;
22163
+ try {
22164
+ const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
22165
+ if (cutoffs.objectCutoffMs !== null && cutoffs.objectCutoffMs < minObjectCutoffMs) minObjectCutoffMs = cutoffs.objectCutoffMs;
22166
+ if (cutoffs.motionCutoffMs === null && cutoffs.objectCutoffMs === null && cutoffs.audioCutoffMs === null) continue;
22167
+ const evicted = await this.eventStore.evictBefore({
22168
+ deviceId,
22169
+ motionCutoffMs: cutoffs.motionCutoffMs ?? 0,
22170
+ objectCutoffMs: cutoffs.objectCutoffMs ?? 0,
22171
+ audioCutoffMs: cutoffs.audioCutoffMs ?? 0
22172
+ });
22173
+ const ids = [
22174
+ ...evicted.motion,
22175
+ ...evicted.object,
22176
+ ...evicted.audio
22177
+ ];
22178
+ if (ids.length > 0) {
22179
+ evictedIds.push(...ids);
22180
+ this.eventsOpsLog?.append({
22181
+ op: "prune",
22182
+ reason: "retention",
22183
+ deviceId,
22184
+ itemsAffected: ids.length,
22185
+ bytesReclaimed: 0,
22186
+ detail: `age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.object.length} object, ${evicted.audio.length} audio`
22187
+ });
22188
+ this.ctx.logger.info("analytics event eviction (age sweep)", {
22189
+ tags: { deviceId },
22190
+ meta: {
22191
+ motion: evicted.motion.length,
22192
+ object: evicted.object.length,
22193
+ audio: evicted.audio.length,
22194
+ mode: cutoffs.effectiveMode,
22195
+ objectCutoffMs: cutoffs.objectCutoffMs
22196
+ }
22197
+ });
22198
+ }
22199
+ } catch (err) {
22200
+ this.ctx.logger.debug("event retention sweep (device) failed", {
22201
+ tags: { deviceId },
22202
+ meta: { error: String(err) }
22203
+ });
22204
+ }
20756
22205
  }
20757
- await this.mediaStore.evictBefore(now - 31 * day);
20758
- if (this.sensorEventStore) try {
20759
- const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
22206
+ if (evictedIds.length > 0) await this.mediaStore.deleteForEvents(evictedIds);
22207
+ if (Number.isFinite(minObjectCutoffMs)) await this.mediaStore.evictBefore(minObjectCutoffMs - 1 * day);
22208
+ if (this.sensorEventStore && Number.isFinite(minObjectCutoffMs)) try {
22209
+ const sensorDeleted = await this.sensorEventStore.evictBefore(minObjectCutoffMs);
20760
22210
  if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
20761
22211
  deleted: sensorDeleted,
20762
- cutoffMs: objectCutoffMs
22212
+ cutoffMs: minObjectCutoffMs
20763
22213
  } });
20764
22214
  } catch (err) {
20765
22215
  this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
@@ -20790,8 +22240,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20790
22240
  } catch (err) {
20791
22241
  this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
20792
22242
  }
20793
- if (this.objectEmbeddingStore) try {
20794
- const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(objectCutoffMs);
22243
+ if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
22244
+ const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
20795
22245
  if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
20796
22246
  } catch (err) {
20797
22247
  this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
@@ -21457,6 +22907,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21457
22907
  return this.queryFacade.deleteDeviceEvents(input);
21458
22908
  }
21459
22909
  /** The events ops-log rows (newest-first), optionally scoped to one camera. */
22910
+ async relocateMedia(input) {
22911
+ if (!this.mediaRelocate) throw new Error("media relocation unavailable");
22912
+ return { jobId: this.mediaRelocate.start(input) };
22913
+ }
22914
+ async getMediaRelocateStatus() {
22915
+ return this.mediaRelocate?.list() ?? [];
22916
+ }
22917
+ async cancelMediaRelocate(input) {
22918
+ return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
22919
+ }
21460
22920
  async listOpsLog(input) {
21461
22921
  return this.queryFacade.listOpsLog(input);
21462
22922
  }
@@ -21536,13 +22996,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21536
22996
  try {
21537
22997
  const total = await sweepTrackRetention({
21538
22998
  listDeviceIds: () => trackStore.listDeviceIds(),
21539
- resolveRetentionDays: async (deviceId) => {
21540
- return resolveTrackRetentionDays(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
22999
+ resolveCutoffMs: async (deviceId, nowMs) => (await this.deviceRetentionCutoffs(deviceId, nowMs)).trackCutoffMs,
23000
+ pruneTracksBefore: async (deviceId, cutoffMs) => {
23001
+ const counts = await this.pruneTracksBefore({
23002
+ deviceId,
23003
+ cutoffMs
23004
+ });
23005
+ if (counts.tracks > 0) this.eventsOpsLog?.append({
23006
+ op: "prune",
23007
+ reason: "retention",
23008
+ deviceId,
23009
+ itemsAffected: counts.tracks,
23010
+ bytesReclaimed: 0,
23011
+ detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
23012
+ });
23013
+ return counts;
21541
23014
  },
21542
- pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
21543
- deviceId,
21544
- cutoffMs
21545
- }),
21546
23015
  now: () => Date.now(),
21547
23016
  onError: (deviceId, err) => {
21548
23017
  this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
@@ -21636,7 +23105,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21636
23105
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
21637
23106
  const baseSections = schema ? hydrateSchema({
21638
23107
  ...schema,
21639
- sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
23108
+ sections: retagRetentionSection(retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))))
21640
23109
  }, raw).sections : [];
21641
23110
  const liveStatsSection = {
21642
23111
  id: "live-stats",
@@ -21686,4 +23155,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21686
23155
  }
21687
23156
  };
21688
23157
  //#endregion
21689
- export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };
23158
+ export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, retagRetentionSection, stripGlobalOnlyFields, toAnalyticsDeviceSections };