@camstack/addon-post-analysis 1.2.33 → 1.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,13 +2,216 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DPAMoN4x.js");
5
+ const require_dist = require("../dist-Cmb3B0Ij.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
9
9
  let node_crypto = require("node:crypto");
10
10
  let sharp = require("sharp");
11
11
  sharp = require_dist.__toESM(sharp);
12
+ //#region src/notification-center/action-token.ts
13
+ /**
14
+ * The authority behind a notification button.
15
+ *
16
+ * **Read this before changing anything here.** A button's URL travels through
17
+ * third-party infrastructure — ntfy's server, a push relay, whatever forwarded
18
+ * the message — and is therefore exactly as public as the notification is. The
19
+ * operator decided (2026-08-05) that the token ALONE authorises the action:
20
+ * there is no session check, and a tap does not identify who tapped. Whoever
21
+ * holds the link can run that one action. [D47](../../../../docs/decisions/adr-0047.md).
22
+ *
23
+ * Everything in this file exists to bound that blast radius, since the
24
+ * authorisation model does not:
25
+ *
26
+ * - **One token, one action, one notification.** The record carries the rule,
27
+ * the sequence and the device, so a token cannot be re-pointed at anything
28
+ * else. There is no "run arbitrary sequence" shape to abuse.
29
+ * - **Single use, claimed ATOMICALLY.** `claim()` is the only way to read a
30
+ * record and it marks it spent in the same synchronous step, so two taps
31
+ * arriving together cannot both open a gate. This is why the registry is a
32
+ * plain in-process Map and not the settings store: a read-then-write across
33
+ * an async store is a race that a physical actuator would pay for.
34
+ * - **Short-lived, and the expiry is a COMPARISON, not a sweeper.** A reaper
35
+ * that failed would leave tokens live indefinitely — the same reasoning the
36
+ * snooze policy is built on. The sweep here only reclaims memory.
37
+ * - **In-memory is deliberate.** Tokens do not survive a hub restart, so a
38
+ * link can never outlive the process that minted it. The cost is real and
39
+ * accepted: a deploy kills the buttons on notifications already delivered,
40
+ * which with a short TTL were nearly dead anyway.
41
+ */
42
+ /** Sign `(id, exp)` — the exact string the verifier recomputes. Same shape as
43
+ * the artifact plane's, deliberately: one signing convention in this addon. */
44
+ function signActionToken(secret, id, expMs) {
45
+ return (0, node_crypto.createHmac)("sha256", secret).update(`act:${id}:${expMs}`).digest("hex");
46
+ }
47
+ /**
48
+ * Verify a callback's `(id, exp, sig)`.
49
+ *
50
+ * Constant-time on the signature so a public route cannot be probed for it byte
51
+ * by byte, and expiry-checked BEFORE the compare so an expired link is refused
52
+ * even when its signature is perfect.
53
+ */
54
+ function verifyActionSignature(input) {
55
+ if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
56
+ const expected = signActionToken(input.secret, input.id, input.exp);
57
+ const a = Buffer.from(expected, "utf8");
58
+ const b = Buffer.from(input.sig, "utf8");
59
+ if (a.length !== b.length) return false;
60
+ return (0, node_crypto.timingSafeEqual)(a, b);
61
+ }
62
+ /** Build the fully-qualified, signed callback URL for one button. */
63
+ function buildActionUrl(input) {
64
+ const sig = signActionToken(input.secret, input.id, input.expMs);
65
+ 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}`;
66
+ }
67
+ /**
68
+ * How long a button stays live.
69
+ *
70
+ * Fifteen minutes, not the artifact plane's 24 h. An image link is a read; this
71
+ * one moves something physical, and the window is the only thing limiting who
72
+ * can use a forwarded notification. An operator who wakes hours later opens the
73
+ * app — the button was never the path for that.
74
+ */
75
+ var DEFAULT_ACTION_TTL_MS = 15 * 6e4;
76
+ var NcActionTokenRegistry = class {
77
+ grants = /* @__PURE__ */ new Map();
78
+ now;
79
+ constructor(now) {
80
+ this.now = now ?? (() => Date.now());
81
+ }
82
+ /** Register a freshly minted grant. The caller owns id generation (it needs
83
+ * the id to build the URL before the record exists). */
84
+ put(grant) {
85
+ this.grants.set(grant.id, {
86
+ grant,
87
+ used: false
88
+ });
89
+ }
90
+ /**
91
+ * Take the grant, once.
92
+ *
93
+ * Synchronous and mutating in one step ON PURPOSE — see the header. Anything
94
+ * that awaited between the read and the mark would let two taps through, and
95
+ * the thing on the other end is a lock or a gate.
96
+ */
97
+ claim(id) {
98
+ const found = this.grants.get(id);
99
+ if (found === void 0) return {
100
+ ok: false,
101
+ reason: "unknown"
102
+ };
103
+ if (found.used) return {
104
+ ok: false,
105
+ reason: "already-used"
106
+ };
107
+ if (found.grant.expiresAt <= this.now()) return {
108
+ ok: false,
109
+ reason: "expired"
110
+ };
111
+ found.used = true;
112
+ return {
113
+ ok: true,
114
+ grant: found.grant
115
+ };
116
+ }
117
+ /** Drop records that can no longer be claimed. Memory hygiene only — an
118
+ * expired record is already refused by {@link claim}, so a sweep that never
119
+ * ran would cost bytes, never safety. */
120
+ sweep() {
121
+ const now = this.now();
122
+ let removed = 0;
123
+ for (const [id, entry] of this.grants) if (entry.used || entry.grant.expiresAt <= now) {
124
+ this.grants.delete(id);
125
+ removed += 1;
126
+ }
127
+ return removed;
128
+ }
129
+ /** Live (unclaimed, unexpired) token count — for the log line that tells an
130
+ * operator whether buttons are actually being minted. */
131
+ get liveCount() {
132
+ const now = this.now();
133
+ let n = 0;
134
+ for (const entry of this.grants.values()) if (!entry.used && entry.grant.expiresAt > now) n += 1;
135
+ return n;
136
+ }
137
+ };
138
+ //#endregion
139
+ //#region src/notification-center/action-plane.ts
140
+ /** What the human standing in front of the page is told. */
141
+ var FAILURE_COPY = {
142
+ unknown: "This button is no longer available.",
143
+ expired: "This button has expired. Open CamStack to do it there.",
144
+ "already-used": "Already done — this button had been used."
145
+ };
146
+ function page(title, detail) {
147
+ return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee}main{max-width:24rem;padding:2rem;text-align:center}h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style><main><h1>${title}</h1><p>${detail}</p></main>`;
148
+ }
149
+ var NcActionPlane = class {
150
+ deps;
151
+ now;
152
+ constructor(deps) {
153
+ this.deps = deps;
154
+ this.now = deps.now ?? (() => Date.now());
155
+ }
156
+ handler = async (req, res) => {
157
+ const url = new URL(req.url ?? "/", "http://placeholder");
158
+ const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
159
+ if (!verifyActionSignature({
160
+ secret: this.deps.secret,
161
+ id,
162
+ exp: Number(url.searchParams.get("exp")),
163
+ sig: url.searchParams.get("sig") ?? "",
164
+ nowMs: this.now()
165
+ })) {
166
+ this.deps.logger.warn("nc action callback refused — bad or expired signature", { meta: { id } });
167
+ this.send(res, 404, page("Not found", "This link is not valid."));
168
+ return;
169
+ }
170
+ const claim = this.deps.registry.claim(id);
171
+ if (!claim.ok) {
172
+ this.deps.logger.info("nc action callback not claimable", { meta: {
173
+ id,
174
+ reason: claim.reason
175
+ } });
176
+ this.send(res, 200, page("Nothing to do", FAILURE_COPY[claim.reason]));
177
+ return;
178
+ }
179
+ const grant = claim.grant;
180
+ try {
181
+ await this.deps.run(grant);
182
+ this.deps.logger.info("nc action ran from a notification button", {
183
+ tags: { deviceId: grant.deviceId },
184
+ meta: {
185
+ ruleId: grant.ruleId,
186
+ rule: grant.ruleName,
187
+ sequence: grant.sequence,
188
+ action: grant.actionId,
189
+ targetId: grant.targetId ?? null
190
+ }
191
+ });
192
+ this.send(res, 200, page("Done", `${grant.ruleName} — ${grant.sequence}`));
193
+ } catch (err) {
194
+ this.deps.logger.warn("nc action failed after the token was spent", {
195
+ tags: { deviceId: grant.deviceId },
196
+ meta: {
197
+ ruleId: grant.ruleId,
198
+ sequence: grant.sequence,
199
+ action: grant.actionId,
200
+ error: String(err)
201
+ }
202
+ });
203
+ this.send(res, 200, page("It did not work", "The action failed and this button is now spent. Open CamStack."));
204
+ }
205
+ };
206
+ send(res, status, html) {
207
+ res.writeHead(status, {
208
+ "content-type": "text/html; charset=utf-8",
209
+ "cache-control": "no-store"
210
+ });
211
+ res.end(html);
212
+ }
213
+ };
214
+ //#endregion
12
215
  //#region src/notification-center/artifact-url.ts
13
216
  /**
14
217
  * Signed, externally-reachable URLs for notification artifacts.
@@ -347,7 +550,132 @@ function mimeFromExtension(file) {
347
550
  return "application/octet-stream";
348
551
  }
349
552
  //#endregion
553
+ //#region src/notification-center/alarm/alarm-mode-coverage.ts
554
+ /**
555
+ * Whether the panel just BECAME armed, in the sense a person means.
556
+ *
557
+ * Two transitions reach an `armed_*` state and only one of them is an arm:
558
+ *
559
+ * - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
560
+ * - `triggered` → `armed_away` is the siren's duration ending and the panel
561
+ * re-arming itself. Announcing it would send "Away armed" in the middle of
562
+ * a break-in, seconds after the alarm notification, saying nothing new.
563
+ *
564
+ * `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
565
+ * set of devices covered just changed with it — which is the whole content of
566
+ * the message.
567
+ *
568
+ * A null `previous` is the FIRST publish and never announces. A hub restarting
569
+ * while armed re-publishes `armed_away`, and nobody armed anything — every
570
+ * deploy would otherwise send one. Handled here rather than by the caller
571
+ * happening to wire the hook late, because "it works because of the order two
572
+ * unrelated things run in" is how this stops working.
573
+ */
574
+ function shouldAnnounceArm(previous, next) {
575
+ if (previous === null) return false;
576
+ if (!next.startsWith("armed_")) return false;
577
+ if (previous === next) return false;
578
+ if (previous === "triggered") return false;
579
+ return true;
580
+ }
581
+ /** The mode inside an `armed_<mode>` state, or null for any other state. */
582
+ function armModeOf(state, modes) {
583
+ for (const mode of modes) if (state === armedStateFor(mode)) return mode;
584
+ return null;
585
+ }
586
+ /** The `deviceState` word a rule must gate on for `mode` to cover it. */
587
+ function armedStateFor(mode) {
588
+ return `armed_${mode}`;
589
+ }
590
+ /**
591
+ * One entry per mode the panel offers, including modes nothing is gated on.
592
+ *
593
+ * Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
594
+ * most useful thing this can tell an operator, and a list that omits it looks
595
+ * identical to a list where night is covered.
596
+ */
597
+ function alarmModeCoverage(rules, panelDeviceId, modes) {
598
+ return modes.map((mode) => coverageFor(rules, panelDeviceId, mode));
599
+ }
600
+ function coverageFor(rules, panelDeviceId, mode) {
601
+ const wanted = armedStateFor(mode);
602
+ const ids = /* @__PURE__ */ new Set();
603
+ let ruleCount = 0;
604
+ let allDevices = false;
605
+ for (const rule of rules) {
606
+ if (!rule.enabled) continue;
607
+ const gate = rule.conditions.deviceState;
608
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
609
+ if (!gate.states.includes(wanted)) continue;
610
+ ruleCount += 1;
611
+ const scope = rule.conditions.devices;
612
+ if (scope === void 0 || scope.length === 0) {
613
+ allDevices = true;
614
+ continue;
615
+ }
616
+ for (const id of scope) ids.add(id);
617
+ }
618
+ return {
619
+ mode,
620
+ ruleCount,
621
+ allDevices,
622
+ deviceIds: [...ids].toSorted((a, b) => a - b)
623
+ };
624
+ }
625
+ /**
626
+ * "Away armed — 3 cameras: Front door, Garage, Gate."
627
+ *
628
+ * Names, not ids: this is read by a person standing at a door. An id that has
629
+ * no name falls back to `Device <id>` rather than being dropped — a silently
630
+ * shorter list would understate what is armed, which is the one error this
631
+ * message must not make.
632
+ *
633
+ * Returns null when the mode covers NOTHING and no rule is gated on it. A
634
+ * notification saying "Night armed" that is followed by nothing happening all
635
+ * night is worse than no notification: it is a false assurance. The caller
636
+ * still logs the arm.
637
+ */
638
+ function buildArmAnnouncement(coverage, deviceNames) {
639
+ if (coverage.ruleCount === 0) return null;
640
+ const title = `${modeLabel(coverage.mode)} armed`;
641
+ if (coverage.allDevices) return {
642
+ title,
643
+ body: `Every device is armed (${plural$1(coverage.ruleCount, "rule")}).`
644
+ };
645
+ if (coverage.deviceIds.length === 0) return {
646
+ title,
647
+ body: `${plural$1(coverage.ruleCount, "rule")}, no device restriction.`
648
+ };
649
+ const names = coverage.deviceIds.map((id) => deviceNames.get(id) ?? `Device ${id}`);
650
+ return {
651
+ title,
652
+ body: `${plural$1(names.length, "device")} armed: ${names.join(", ")}.`
653
+ };
654
+ }
655
+ function modeLabel(mode) {
656
+ return mode.charAt(0).toUpperCase() + mode.slice(1).replace(/_/g, " ");
657
+ }
658
+ function plural$1(n, one) {
659
+ return n === 1 ? `1 ${one}` : `${n} ${one}s`;
660
+ }
661
+ //#endregion
350
662
  //#region src/notification-center/alarm/alarm-state-machine.ts
663
+ /**
664
+ * Defaults an operator can change per panel. `home` has no exit delay on
665
+ * purpose — nobody is leaving — but the shared value is applied to every mode
666
+ * until per-mode delays are asked for.
667
+ *
668
+ * `triggeredDurationSec: 0` = "sound until somebody disarms it". That is what
669
+ * the panel did before the field existed, so an install that never opens the
670
+ * tab keeps behaving exactly as it did — a default that silently re-armed
671
+ * every existing alarm after N seconds would be a behaviour change nobody
672
+ * asked for.
673
+ */
674
+ var DEFAULT_DELAYS = {
675
+ exitDelaySec: 30,
676
+ entryDelaySec: 20,
677
+ triggeredDurationSec: 0
678
+ };
351
679
  var DISARMED = {
352
680
  target: "disarmed",
353
681
  fired: false,
@@ -364,6 +692,7 @@ function armedState(mode) {
364
692
  * answer is still correct the moment anyone asks.
365
693
  */
366
694
  function stateAt(m, now) {
695
+ if (m.clearAt !== void 0 && now >= m.clearAt) return m.target;
367
696
  if (m.fired) return "triggered";
368
697
  if (m.triggerAt !== void 0) return now >= m.triggerAt ? "triggered" : "pending";
369
698
  if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
@@ -417,19 +746,24 @@ function disarm(now) {
417
746
  * intruder walking past two sensors would otherwise postpone the alarm.
418
747
  */
419
748
  function trigger(m, delays, now) {
749
+ const state = stateAt(m, now);
750
+ if (state === "triggered" || state === "pending") return m;
420
751
  if (!isArmed(m, now)) return m;
421
- if (m.fired || m.triggerAt !== void 0) return m;
422
- if (delays.entryDelaySec <= 0) return {
423
- ...m,
424
- fired: true,
425
- changedAt: now
426
- };
752
+ const base = settle(m, now);
753
+ const firesAt = now + Math.max(0, delays.entryDelaySec) * 1e3;
427
754
  return {
428
- ...m,
429
- triggerAt: now + delays.entryDelaySec * 1e3,
755
+ ...base,
756
+ triggerAt: firesAt,
757
+ ...autoClear(delays, firesAt),
758
+ fired: false,
430
759
  changedAt: now
431
760
  };
432
761
  }
762
+ /** `clearAt` iff the operator asked for a bounded siren — see {@link AlarmDelays}. */
763
+ function autoClear(delays, firesAt) {
764
+ if (delays.triggeredDurationSec <= 0) return {};
765
+ return { clearAt: firesAt + delays.triggeredDurationSec * 1e3 };
766
+ }
433
767
  /**
434
768
  * Settle the machine so a stored value never depends on when it is read.
435
769
  *
@@ -438,10 +772,16 @@ function trigger(m, delays, now) {
438
772
  * than only in the reading of it.
439
773
  */
440
774
  function settle(m, now) {
775
+ if (m.clearAt !== void 0 && now >= m.clearAt) return {
776
+ target: m.target,
777
+ fired: false,
778
+ changedAt: m.clearAt
779
+ };
441
780
  if (m.triggerAt !== void 0 && now >= m.triggerAt) return {
442
781
  target: m.target,
443
782
  fired: true,
444
- changedAt: m.triggerAt
783
+ changedAt: m.triggerAt,
784
+ ...m.clearAt !== void 0 ? { clearAt: m.clearAt } : {}
445
785
  };
446
786
  if (m.armedAt !== void 0 && now >= m.armedAt) return {
447
787
  target: m.target,
@@ -470,21 +810,12 @@ function settle(m, now) {
470
810
  * and re-publish when a delay elapses.
471
811
  */
472
812
  /** Every arm mode a camstack-owned panel offers. */
473
- var MODES = [
813
+ var NC_ALARM_MODES = [
474
814
  "home",
475
815
  "away",
476
816
  "night"
477
817
  ];
478
818
  /**
479
- * Defaults an operator can change per panel. `home` has no exit delay on
480
- * purpose — nobody is leaving — but the shared value is applied to every mode
481
- * until per-mode delays are asked for.
482
- */
483
- var DEFAULT_DELAYS = {
484
- exitDelaySec: 30,
485
- entryDelaySec: 20
486
- };
487
- /**
488
819
  * How often the panel re-publishes while a delay is running.
489
820
  *
490
821
  * The state is computed from an INSTANT, so this tick only decides how quickly
@@ -500,30 +831,94 @@ var TICK_MS = 1e3;
500
831
  var ncAlarmPanelSchema = require_dist.object({
501
832
  exitDelaySec: require_dist.number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
502
833
  entryDelaySec: require_dist.number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
834
+ triggeredDurationSec: require_dist.number().int().min(0).max(3600).default(DEFAULT_DELAYS.triggeredDurationSec),
835
+ /** Send a notification when a mode takes effect. Off until asked for. */
836
+ announceArm: require_dist.boolean().default(false),
837
+ /** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
838
+ announceTargets: require_dist.array(require_dist.string()).default([]),
503
839
  machine: require_dist.record(require_dist.string(), require_dist.unknown()).optional()
504
840
  });
841
+ /** `armed_home` and friends — the only targets a machine may settle into. */
842
+ function isArmTarget(value) {
843
+ return value === "disarmed" || typeof value === "string" && value.startsWith("armed_");
844
+ }
845
+ function optionalInstant(value) {
846
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
847
+ }
848
+ /**
849
+ * Rebuild the machine from the persisted blob, or null when there is nothing
850
+ * trustworthy in it.
851
+ *
852
+ * A type GUARD, not a cast: the blob has survived a schema change once already
853
+ * (`clearAt` did not exist), and the failure mode of a cast here is an alarm
854
+ * that reports a state it cannot reach. Anything unrecognised falls back to
855
+ * DISARMED, which is the state an operator will notice.
856
+ *
857
+ * Exported for the test that proves a pre-`clearAt` blob still restores.
858
+ */
859
+ function machineFromPersisted(raw) {
860
+ if (raw === null || typeof raw !== "object") return null;
861
+ const rec = { ...raw };
862
+ if (!isArmTarget(rec["target"]) || typeof rec["changedAt"] !== "number") return null;
863
+ const armedAt = optionalInstant(rec["armedAt"]);
864
+ const triggerAt = optionalInstant(rec["triggerAt"]);
865
+ const clearAt = optionalInstant(rec["clearAt"]);
866
+ return {
867
+ target: rec["target"],
868
+ ...armedAt !== void 0 ? { armedAt } : {},
869
+ ...triggerAt !== void 0 ? { triggerAt } : {},
870
+ ...clearAt !== void 0 ? { clearAt } : {},
871
+ fired: rec["fired"] === true,
872
+ changedAt: rec["changedAt"]
873
+ };
874
+ }
505
875
  var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
506
876
  features = [];
507
877
  machine = DISARMED;
508
878
  delays = DEFAULT_DELAYS;
509
879
  ticker = null;
880
+ onArmed = null;
881
+ /**
882
+ * The state the last publish reported. Null until the first one, so the
883
+ * BOOT publish never announces: a hub restarting while armed would otherwise
884
+ * announce "Away armed" on every deploy.
885
+ */
886
+ lastPublished = null;
510
887
  constructor(ctx) {
511
888
  super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
512
- this.delays = {
513
- exitDelaySec: this.config.get("exitDelaySec"),
514
- entryDelaySec: this.config.get("entryDelaySec")
515
- };
889
+ this.delays = this.readDelays();
516
890
  this.restore();
517
891
  this.registerAlarmCap();
518
892
  this.publish();
519
893
  }
520
- /** Fire a trigger at the panel. Called by the notification center when a rule
521
- * that arms the alarm matches. Ignored unless the panel is armed. */
522
- onRuleTriggered(now = Date.now()) {
523
- const next = trigger(this.machine, this.delays, now);
524
- if (next === this.machine) return;
525
- this.machine = next;
526
- this.publish(now);
894
+ /** The operator-visible configuration, as the cap serves it. */
895
+ settings() {
896
+ return {
897
+ ...this.readDelays(),
898
+ announceArm: this.config.get("announceArm"),
899
+ announceTargets: [...this.config.get("announceTargets")]
900
+ };
901
+ }
902
+ /**
903
+ * Apply an editor's patch and return what the panel settled on.
904
+ *
905
+ * The new delays take effect on the NEXT transition, never retroactively: a
906
+ * machine already counting down keeps the instants it was given. Re-deriving
907
+ * `armedAt` from a delay the operator changed mid-exit would move the arm
908
+ * instant under somebody who is walking out of the door.
909
+ */
910
+ async applySettings(patch) {
911
+ if (patch.exitDelaySec !== void 0) await this.config.set("exitDelaySec", patch.exitDelaySec);
912
+ if (patch.entryDelaySec !== void 0) await this.config.set("entryDelaySec", patch.entryDelaySec);
913
+ if (patch.triggeredDurationSec !== void 0) await this.config.set("triggeredDurationSec", patch.triggeredDurationSec);
914
+ if (patch.announceArm !== void 0) await this.config.set("announceArm", patch.announceArm);
915
+ if (patch.announceTargets !== void 0) await this.config.set("announceTargets", [...patch.announceTargets]);
916
+ this.delays = this.readDelays();
917
+ return this.settings();
918
+ }
919
+ /** Wire the arm announcement. See {@link NcAlarmArmedHook}. */
920
+ setArmedHook(hook) {
921
+ this.onArmed = hook;
527
922
  }
528
923
  /** Current lifecycle word — what the `deviceState` gate compares against. */
529
924
  currentState(now = Date.now()) {
@@ -546,19 +941,24 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
546
941
  this.publish();
547
942
  },
548
943
  trigger: async () => {
549
- this.machine = {
550
- ...this.machine,
551
- fired: true,
552
- changedAt: Date.now()
553
- };
554
- this.publish();
944
+ const now = Date.now();
945
+ const next = trigger(this.machine, this.delays, now);
946
+ if (next === this.machine) {
947
+ this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
948
+ tags: { deviceId: this.id },
949
+ meta: { state: stateAt(this.machine, now) }
950
+ });
951
+ return;
952
+ }
953
+ this.machine = next;
954
+ this.publish(now);
555
955
  }
556
956
  });
557
957
  }
558
958
  status(now = Date.now()) {
559
959
  return {
560
960
  state: stateAt(this.machine, now),
561
- availableModes: [...MODES],
961
+ availableModes: [...NC_ALARM_MODES],
562
962
  requiresCode: false,
563
963
  lastChangedAt: this.machine.changedAt
564
964
  };
@@ -572,11 +972,48 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
572
972
  */
573
973
  publish(now = Date.now()) {
574
974
  this.machine = settle(this.machine, now);
975
+ const state = stateAt(this.machine, now);
575
976
  this.runtimeState.setCapState("alarm-panel", this.status(now));
576
977
  this.persist();
577
- if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0) this.startTicking();
978
+ this.announceIfArmed(state);
979
+ if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0 || this.machine.clearAt !== void 0) this.startTicking();
578
980
  else this.stopTicking();
579
981
  }
982
+ /**
983
+ * Fire the arm hook on the transition INTO an armed mode.
984
+ *
985
+ * Never throws into the publish path: an announcement that could take the
986
+ * panel's own state update with it would make a notification failure look
987
+ * like an alarm failure.
988
+ */
989
+ announceIfArmed(state) {
990
+ const previous = this.lastPublished;
991
+ this.lastPublished = state;
992
+ const hook = this.onArmed;
993
+ if (hook === null) return;
994
+ if (!shouldAnnounceArm(previous, state)) return;
995
+ const mode = armModeOf(state, NC_ALARM_MODES);
996
+ if (mode === null) return;
997
+ try {
998
+ hook(mode);
999
+ } catch (err) {
1000
+ this.ctx.logger.warn("alarm arm announcement failed", {
1001
+ tags: { deviceId: this.id },
1002
+ meta: {
1003
+ mode,
1004
+ error: String(err)
1005
+ }
1006
+ });
1007
+ }
1008
+ }
1009
+ /** The three durations, read from the persisted config. */
1010
+ readDelays() {
1011
+ return {
1012
+ exitDelaySec: this.config.get("exitDelaySec"),
1013
+ entryDelaySec: this.config.get("entryDelaySec"),
1014
+ triggeredDurationSec: this.config.get("triggeredDurationSec")
1015
+ };
1016
+ }
580
1017
  startTicking() {
581
1018
  if (this.ticker !== null) return;
582
1019
  this.ticker = setInterval(() => this.publish(), TICK_MS);
@@ -593,17 +1030,9 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
593
1030
  this.config.set("machine", { ...this.machine });
594
1031
  }
595
1032
  restore() {
596
- const raw = this.config.get("machine");
597
- if (raw === null || typeof raw !== "object") return;
598
- const m = raw;
599
- if (typeof m.target !== "string" || typeof m.changedAt !== "number") return;
600
- this.machine = {
601
- target: m.target,
602
- ...typeof m.armedAt === "number" ? { armedAt: m.armedAt } : {},
603
- ...typeof m.triggerAt === "number" ? { triggerAt: m.triggerAt } : {},
604
- fired: m.fired === true,
605
- changedAt: m.changedAt
606
- };
1033
+ const restored = machineFromPersisted(this.config.get("machine"));
1034
+ if (restored === null) return;
1035
+ this.machine = restored;
607
1036
  }
608
1037
  };
609
1038
  //#endregion
@@ -4871,14 +5300,21 @@ function readSensorEventType(value) {
4871
5300
  const eventType = last["eventType"];
4872
5301
  return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
4873
5302
  }
4874
- /** Build the subject for a `device-event` evaluation (a persisted SensorEvent —
4875
- * one row per linked camera; `deviceId` is the CAMERA). */
5303
+ /**
5304
+ * Build the subject for a `device-event` evaluation (a persisted SensorEvent —
5305
+ * one row per linked camera; `deviceId` is the CAMERA).
5306
+ *
5307
+ * BOTH ids ride along. The camera is what the notification shows; the sensor is
5308
+ * what the operator named in the rule. Carrying only the camera is what made a
5309
+ * sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
5310
+ */
4876
5311
  function subjectFromSensorEvent(ev) {
4877
5312
  const eventType = readSensorEventType(ev.value);
4878
5313
  return {
4879
5314
  kind: "device-event",
4880
5315
  recordId: ev.id,
4881
5316
  deviceId: ev.deviceId,
5317
+ ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
4882
5318
  timestamp: ev.timestamp,
4883
5319
  classNames: [],
4884
5320
  zones: [],
@@ -5110,7 +5546,7 @@ function evaluateRule(rule, subject, deviceState) {
5110
5546
  if (current === void 0) return fail("deviceState");
5111
5547
  if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
5112
5548
  }
5113
- if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
5549
+ if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
5114
5550
  if (c.source !== void 0 && c.source !== "any") {
5115
5551
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
5116
5552
  }
@@ -5297,6 +5733,28 @@ function keysPerClass(rule, subject) {
5297
5733
  return rule.throttle.granularity === "per-class";
5298
5734
  }
5299
5735
  /** Stable cooldown key per the rule's throttle scope + class granularity. */
5736
+ /**
5737
+ * Does the rule's device scope cover this subject?
5738
+ *
5739
+ * EITHER id matches, and both are needed for the same rule to be authorable
5740
+ * the two ways an operator thinks about it:
5741
+ *
5742
+ * - "when anything happens on the front-door CAMERA" → the camera id, which
5743
+ * is what a sensor row is attributed to;
5744
+ * - "when the front-door CONTACT opens" → the sensor id, which is the device
5745
+ * the operator actually cares about and the one they pick in the Devices
5746
+ * tab. Before this, that rule matched nothing, ever, silently.
5747
+ *
5748
+ * Consequence worth knowing: a sensor linked to N cameras persists N rows, so
5749
+ * a SENSOR-scoped rule is evaluated N times for one door opening. The rule's
5750
+ * own throttle is what collapses that — `scope: 'rule'` gives one
5751
+ * notification, `scope: 'device'` gives one per camera, which is the right
5752
+ * choice when each carries its own picture.
5753
+ */
5754
+ function matchesDeviceScope(scope, subject) {
5755
+ if (scope.includes(subject.deviceId)) return true;
5756
+ return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
5757
+ }
5300
5758
  function cooldownKey(rule, subject) {
5301
5759
  const first = subject.classNames[0];
5302
5760
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
@@ -5473,7 +5931,7 @@ var NcDispatcher = class {
5473
5931
  return { ok: true };
5474
5932
  }
5475
5933
  }
5476
- const notification = await this.buildNotification(entry);
5934
+ const notification = await this.buildNotification(entry, target);
5477
5935
  try {
5478
5936
  const result = await this.deps.send({
5479
5937
  addonId: target.addonId,
@@ -5571,6 +6029,82 @@ var NcDispatcher = class {
5571
6029
  return false;
5572
6030
  }
5573
6031
  }
6032
+ /**
6033
+ * Send a Notification-Center-composed message to ONE target.
6034
+ *
6035
+ * Never throws and never retries — see {@link NcAnnouncementSendInput}. Every
6036
+ * way of not delivering produces a line: an announcement that silently did
6037
+ * not arrive is indistinguishable from the alarm not having armed, which is
6038
+ * the exact confusion this feature exists to remove.
6039
+ */
6040
+ async deliverAnnouncement(input) {
6041
+ const tags = input.deviceId !== void 0 ? { deviceId: input.deviceId } : void 0;
6042
+ let target;
6043
+ try {
6044
+ target = await this.resolveTarget(input.targetId);
6045
+ } catch (err) {
6046
+ this.deps.logger.warn("announcement: target catalog unreachable", {
6047
+ ...tags !== void 0 ? { tags } : {},
6048
+ meta: {
6049
+ reason: input.reason,
6050
+ targetId: input.targetId,
6051
+ error: String(err)
6052
+ }
6053
+ });
6054
+ return false;
6055
+ }
6056
+ if (target === null || !target.enabled) {
6057
+ this.deps.logger.warn("announcement: target gone or disabled", {
6058
+ ...tags !== void 0 ? { tags } : {},
6059
+ meta: {
6060
+ reason: input.reason,
6061
+ targetId: input.targetId
6062
+ }
6063
+ });
6064
+ return false;
6065
+ }
6066
+ try {
6067
+ const result = await this.deps.send({
6068
+ addonId: target.addonId,
6069
+ targetId: target.id,
6070
+ notification: {
6071
+ title: input.title,
6072
+ body: input.body,
6073
+ priority: 2,
6074
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
6075
+ }
6076
+ });
6077
+ if (!result.success) {
6078
+ this.deps.logger.warn("announcement send failed", {
6079
+ ...tags !== void 0 ? { tags } : {},
6080
+ meta: {
6081
+ reason: input.reason,
6082
+ targetId: target.id,
6083
+ error: result.error
6084
+ }
6085
+ });
6086
+ return false;
6087
+ }
6088
+ this.deps.logger.info("announcement delivered", {
6089
+ ...tags !== void 0 ? { tags } : {},
6090
+ meta: {
6091
+ reason: input.reason,
6092
+ target: target.name
6093
+ }
6094
+ });
6095
+ return true;
6096
+ } catch (err) {
6097
+ this.deps.logger.warn("announcement send threw", {
6098
+ ...tags !== void 0 ? { tags } : {},
6099
+ meta: {
6100
+ reason: input.reason,
6101
+ targetId: target.id,
6102
+ error: String(err)
6103
+ }
6104
+ });
6105
+ return false;
6106
+ }
6107
+ }
5574
6108
  /** Device display names for a set of ids, for the digest's lines. */
5575
6109
  async resolveDeviceNames(deviceIds) {
5576
6110
  const out = /* @__PURE__ */ new Map();
@@ -5603,7 +6137,37 @@ var NcDispatcher = class {
5603
6137
  if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
5604
6138
  return this.targetCache.get(targetId) ?? null;
5605
6139
  }
5606
- async buildNotification(entry) {
6140
+ /**
6141
+ * This delivery's buttons, or none.
6142
+ *
6143
+ * Never throws. A notification that arrives without its buttons is a
6144
+ * degraded notification; one that does not arrive because the token registry
6145
+ * hiccupped is a missed event. The failure is logged rather than swallowed —
6146
+ * a button the operator authored and never saw is exactly the silence this
6147
+ * repo keeps paying for.
6148
+ */
6149
+ async mintActions(entry, target) {
6150
+ if (this.deps.buildActions === void 0) return [];
6151
+ try {
6152
+ return await this.deps.buildActions({
6153
+ ruleId: entry.ruleId,
6154
+ ruleName: entry.payload.ruleName,
6155
+ deviceId: entry.deviceId,
6156
+ targetId: target.id
6157
+ });
6158
+ } catch (err) {
6159
+ this.deps.logger.warn("notification buttons could not be minted — sending without them", {
6160
+ tags: { deviceId: entry.deviceId },
6161
+ meta: {
6162
+ ruleId: entry.ruleId,
6163
+ targetId: target.id,
6164
+ error: String(err)
6165
+ }
6166
+ });
6167
+ return [];
6168
+ }
6169
+ }
6170
+ async buildNotification(entry, target) {
5607
6171
  const subject = entry.payload.subject;
5608
6172
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
5609
6173
  const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
@@ -5612,6 +6176,7 @@ var NcDispatcher = class {
5612
6176
  const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
5613
6177
  const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
5614
6178
  const params = pickParams(entry.payload.params);
6179
+ const actions = await this.mintActions(entry, target);
5615
6180
  return {
5616
6181
  body,
5617
6182
  title,
@@ -5620,7 +6185,8 @@ var NcDispatcher = class {
5620
6185
  tag: entry.ruleId,
5621
6186
  deviceId: subject.deviceId,
5622
6187
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
5623
- ...attachments.length > 0 ? { attachments } : {}
6188
+ ...attachments.length > 0 ? { attachments } : {},
6189
+ ...actions.length > 0 ? { actions } : {}
5624
6190
  };
5625
6191
  }
5626
6192
  /**
@@ -6738,6 +7304,60 @@ function rowToEntry$1(id, data) {
6738
7304
  };
6739
7305
  }
6740
7306
  //#endregion
7307
+ //#region src/notification-center/action-buttons.ts
7308
+ /** Sequence names the rule actually declares. A button may only name one. */
7309
+ function declaredSequences(actions) {
7310
+ const names = /* @__PURE__ */ new Set();
7311
+ for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
7312
+ return names;
7313
+ }
7314
+ /**
7315
+ * Build the notification's buttons.
7316
+ *
7317
+ * Returns an empty array rather than `undefined` — the caller decides whether
7318
+ * to set the field, and an empty array is the honest answer to "which buttons
7319
+ * survived", which `undefined` would conflate with "the rule declared none".
7320
+ */
7321
+ function buildActionButtons(input) {
7322
+ const actions = input.actions;
7323
+ if (actions === void 0) return [];
7324
+ const buttons = actions.buttons ?? [];
7325
+ if (buttons.length === 0) return [];
7326
+ const known = declaredSequences(actions);
7327
+ const seen = /* @__PURE__ */ new Set();
7328
+ const out = [];
7329
+ for (const button of buttons) {
7330
+ if (!known.has(button.sequence)) continue;
7331
+ if (seen.has(button.id)) continue;
7332
+ seen.add(button.id);
7333
+ out.push({
7334
+ id: button.id,
7335
+ label: button.label,
7336
+ url: input.mintUrl({
7337
+ sequence: button.sequence,
7338
+ actionId: button.id
7339
+ }),
7340
+ ...button.icon !== void 0 ? { icon: button.icon } : {},
7341
+ ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
7342
+ });
7343
+ }
7344
+ return out;
7345
+ }
7346
+ /**
7347
+ * Which of a rule's buttons name a sequence it does not have.
7348
+ *
7349
+ * Exported so the caller can LOG the drop. A button silently missing from a
7350
+ * notification is the exact shape of failure this repo keeps paying for — the
7351
+ * operator authored it, it never appeared, and nothing said why.
7352
+ */
7353
+ function unresolvableButtons(actions) {
7354
+ if (actions === void 0) return [];
7355
+ const buttons = actions.buttons ?? [];
7356
+ if (buttons.length === 0) return [];
7357
+ const known = declaredSequences(actions);
7358
+ return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
7359
+ }
7360
+ //#endregion
6741
7361
  //#region src/notification-center/rule-actions.ts
6742
7362
  var NcRuleActionRunner = class {
6743
7363
  deps;
@@ -7816,6 +8436,44 @@ var TimelapseStore = class {
7816
8436
  //#endregion
7817
8437
  //#region src/notification-center/index.ts
7818
8438
  /**
8439
+ * NotificationCenter — the P1 core of the Notification Center (spec
8440
+ * `2026-07-22-notification-center-requirements.md`, decisions D-1/D-2/D-3).
8441
+ *
8442
+ * A deps-injected, extractable module (TrackCloser pattern) hosted by
8443
+ * pipeline-analytics. It couples rule evaluation to the DURABLE persist
8444
+ * moments (D-2), never to the telemetry bus:
8445
+ *
8446
+ * object-event insert ─▶ onObjectEventPersisted ─▶ immediate rules
8447
+ * TrackCloser.closeExpired ─▶ onTrackClosed ─▶ track-end rules
8448
+ * │ match + throttle
8449
+ * ▼
8450
+ * NcOutbox (durable rows, unique key rule:track:target)
8451
+ * │ drain loop (backoff / dead-letter)
8452
+ * ▼
8453
+ * NcDispatcher ─▶ ctx.api notification-output.send (RPC, hub-routed)
8454
+ *
8455
+ * Crash gap: on start (evaluation mode) the module scans object events
8456
+ * newer than the persisted watermark and re-evaluates them — idempotent
8457
+ * through the outbox dedup key (D8: reconcile, never trust an event).
8458
+ *
8459
+ * Multi-node: rules live in the centralized settings-store; only the
8460
+ * designated post-processing node runs evaluation + the drain loop, so a
8461
+ * CRUD served elsewhere becomes effective within one rule-reload tick.
8462
+ */
8463
+ /**
8464
+ * What `getAlarmConfig` answers on a node with no panel.
8465
+ *
8466
+ * The DEFAULTS, not zeroes: paired with `deviceId: null` this tells a client
8467
+ * "there is no panel here" while still describing the shape it would have.
8468
+ * Zeroes would render as "no entry delay", which is a claim about an alarm
8469
+ * that does not exist.
8470
+ */
8471
+ var NC_ALARM_SETTINGS_FALLBACK = {
8472
+ ...DEFAULT_DELAYS,
8473
+ announceArm: false,
8474
+ announceTargets: []
8475
+ };
8476
+ /**
7819
8477
  * How often the "matched NO rule" report may fire per device. Long enough that
7820
8478
  * a busy camera prints one line rather than one per event, short enough that a
7821
8479
  * rule which has stopped matching is visible within minutes rather than by
@@ -7935,9 +8593,14 @@ var NotificationCenter = class NotificationCenter {
7935
8593
  deviceStates;
7936
8594
  /** Runs a matched rule's action sequences. Null when no actuator is wired. */
7937
8595
  actionRunner;
8596
+ /** Grants behind the tap-through buttons. In-process and single-use — read
8597
+ * `action-token.ts` before assuming anything about what a tap proves. */
8598
+ actionTokens = new NcActionTokenRegistry();
7938
8599
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
7939
8600
  * the watcher is idle (zero per-frame cost) otherwise. */
7940
8601
  occupancyEnabled = false;
8602
+ /** The panel this node owns, or null. See {@link NcAlarmPanelPort}. */
8603
+ alarmPanel = null;
7941
8604
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
7942
8605
  lastFiredAt = /* @__PURE__ */ new Map();
7943
8606
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
@@ -7955,6 +8618,109 @@ var NotificationCenter = class NotificationCenter {
7955
8618
  reloadTimer = null;
7956
8619
  drainTicks = 0;
7957
8620
  evaluationActive = false;
8621
+ /**
8622
+ * The tap-through buttons for ONE delivery, each with its own single-use
8623
+ * token.
8624
+ *
8625
+ * Per delivery, not per notification: the first tap spends the token, so two
8626
+ * recipients sharing one would mean the second person's button was already
8627
+ * dead when it arrived.
8628
+ *
8629
+ * Returns `[]` for every ordinary reason — the rule declares no buttons, no
8630
+ * actuator is wired, no reachable URL exists. A button is an enhancement; not
8631
+ * being able to mint one must never cost the notification.
8632
+ */
8633
+ async mintButtons(input) {
8634
+ const mint = this.deps.mintActionUrl;
8635
+ if (mint === void 0 || this.actionRunner === null) return [];
8636
+ const rule = this.rules.get(input.ruleId);
8637
+ if (rule === null) return [];
8638
+ if ((rule.actions?.buttons ?? []).length === 0) return [];
8639
+ const expMs = this.now() + DEFAULT_ACTION_TTL_MS;
8640
+ const pending = [];
8641
+ const out = buildActionButtons({
8642
+ actions: rule.actions,
8643
+ mintUrl: ({ sequence, actionId }) => {
8644
+ const id = (0, node_crypto.randomUUID)();
8645
+ pending.push({
8646
+ id,
8647
+ url: "",
8648
+ sequence,
8649
+ actionId
8650
+ });
8651
+ return id;
8652
+ }
8653
+ });
8654
+ if (out.length === 0) return [];
8655
+ const resolved = [];
8656
+ for (const [index, action] of out.entries()) {
8657
+ const slot = pending[index];
8658
+ if (slot === void 0) continue;
8659
+ const url = await mint({
8660
+ id: slot.id,
8661
+ expMs
8662
+ });
8663
+ if (url === null) {
8664
+ this.logger.debug("no reachable base URL — notification buttons omitted", {
8665
+ tags: { deviceId: input.deviceId },
8666
+ meta: { ruleId: input.ruleId }
8667
+ });
8668
+ return [];
8669
+ }
8670
+ this.actionTokens.put({
8671
+ id: slot.id,
8672
+ ruleId: input.ruleId,
8673
+ ruleName: input.ruleName,
8674
+ sequence: slot.sequence,
8675
+ deviceId: input.deviceId,
8676
+ actionId: slot.actionId,
8677
+ targetId: input.targetId,
8678
+ expiresAt: expMs
8679
+ });
8680
+ resolved.push({
8681
+ ...action,
8682
+ url
8683
+ });
8684
+ }
8685
+ return resolved;
8686
+ }
8687
+ /** The registry the action plane claims against. */
8688
+ get actionTokenRegistry() {
8689
+ return this.actionTokens;
8690
+ }
8691
+ /**
8692
+ * Run the sequence a spent token granted.
8693
+ *
8694
+ * **The grant overrides `enabled`, deliberately.** A sequence reachable only
8695
+ * by a button is written as `enabled: false` — that is how "do not run this
8696
+ * automatically" is expressed — and the runner skips a disabled sequence. For
8697
+ * a granted run the BUTTON is the enablement, and the operator tapping it is
8698
+ * a more explicit instruction than the flag it overrides. The per-sequence
8699
+ * throttle still applies: `minDelaySec` is about how often a gate may
8700
+ * physically move, and a tap does not change that.
8701
+ *
8702
+ * Throws when the sequence is gone or a step failed — the plane turns that
8703
+ * into the page that says so.
8704
+ */
8705
+ async runGrantedAction(grant) {
8706
+ if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
8707
+ const rule = this.rules.get(grant.ruleId);
8708
+ if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
8709
+ const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
8710
+ if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
8711
+ const [outcome] = await this.actionRunner.run({
8712
+ ruleId: rule.id,
8713
+ ruleName: rule.name,
8714
+ deviceId: grant.deviceId,
8715
+ sequences: [{
8716
+ ...sequence,
8717
+ enabled: true
8718
+ }]
8719
+ });
8720
+ if (outcome === void 0) throw new Error("the sequence did not run");
8721
+ if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
8722
+ if (!outcome.ran) throw new Error(`"${sequence.name}" did not run (${outcome.skippedReason ?? "unknown"})`);
8723
+ }
7958
8724
  constructor(deps) {
7959
8725
  this.deps = deps;
7960
8726
  this.logger = deps.logger;
@@ -8265,9 +9031,105 @@ var NotificationCenter = class NotificationCenter {
8265
9031
  cancelSnooze: async ({ snoozeId, caller }) => {
8266
9032
  await this.cancelSnooze(snoozeId, caller);
8267
9033
  return { success: true };
9034
+ },
9035
+ getAlarmConfig: async () => this.alarmConfig(),
9036
+ setAlarmConfig: async ({ patch }) => {
9037
+ const panel = this.alarmPanel;
9038
+ if (panel === null) throw new Error("this node has no alarm panel");
9039
+ const settings = await panel.applySettings(patch);
9040
+ this.logger.info("alarm settings changed", {
9041
+ tags: { deviceId: panel.deviceId },
9042
+ meta: {
9043
+ exitDelaySec: settings.exitDelaySec,
9044
+ entryDelaySec: settings.entryDelaySec,
9045
+ triggeredDurationSec: settings.triggeredDurationSec,
9046
+ announceArm: settings.announceArm,
9047
+ announceTargets: settings.announceTargets.length
9048
+ }
9049
+ });
9050
+ return this.alarmConfig();
8268
9051
  }
8269
9052
  };
8270
9053
  }
9054
+ /**
9055
+ * Adopt the panel this node owns. Called once by the addon after
9056
+ * `ensureAlarmPanel`; a node without one never calls it and the cap then
9057
+ * answers `deviceId: null`.
9058
+ */
9059
+ setAlarmPanel(panel) {
9060
+ this.alarmPanel = panel;
9061
+ }
9062
+ /** Settings + the DERIVED coverage. Read together — see the cap doc. */
9063
+ alarmConfig() {
9064
+ const panel = this.alarmPanel;
9065
+ if (panel === null) return {
9066
+ deviceId: null,
9067
+ settings: NC_ALARM_SETTINGS_FALLBACK,
9068
+ coverage: []
9069
+ };
9070
+ return {
9071
+ deviceId: panel.deviceId,
9072
+ settings: panel.settings(),
9073
+ coverage: this.coverageFor(panel).map((c) => ({
9074
+ ...c,
9075
+ deviceIds: [...c.deviceIds]
9076
+ }))
9077
+ };
9078
+ }
9079
+ coverageFor(panel) {
9080
+ return alarmModeCoverage(this.rules.list(), panel.deviceId, panel.availableModes());
9081
+ }
9082
+ /**
9083
+ * Say which devices a mode just armed.
9084
+ *
9085
+ * Called by the panel on the transition INTO an armed mode. Fire-and-forget
9086
+ * and never able to fail the panel: `announceArm` returns void, and every
9087
+ * branch that sends nothing logs why — a mode that arms nothing is the most
9088
+ * important thing this can report, and it is exactly the case where no
9089
+ * message goes out.
9090
+ */
9091
+ announceArm(mode) {
9092
+ this.deliverArmAnnouncement(mode).catch((err) => {
9093
+ this.logger.warn("alarm arm announcement failed", { meta: {
9094
+ mode,
9095
+ error: String(err)
9096
+ } });
9097
+ });
9098
+ }
9099
+ async deliverArmAnnouncement(mode) {
9100
+ const panel = this.alarmPanel;
9101
+ if (panel === null) return;
9102
+ const settings = panel.settings();
9103
+ const tags = { deviceId: panel.deviceId };
9104
+ if (!settings.announceArm) return;
9105
+ if (settings.announceTargets.length === 0) {
9106
+ this.logger.info("alarm armed but no announcement target is configured", {
9107
+ tags,
9108
+ meta: { mode }
9109
+ });
9110
+ return;
9111
+ }
9112
+ const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
9113
+ if (coverage === void 0) return;
9114
+ const message = buildArmAnnouncement(coverage, await this.dispatcher.resolveDeviceNames(coverage.deviceIds));
9115
+ if (message === null) {
9116
+ this.logger.warn("alarm armed into a mode no enabled rule is gated on", {
9117
+ tags,
9118
+ meta: {
9119
+ mode,
9120
+ state: `armed_${mode}`
9121
+ }
9122
+ });
9123
+ return;
9124
+ }
9125
+ for (const targetId of settings.announceTargets) await this.dispatcher.deliverAnnouncement({
9126
+ reason: "alarm-arm",
9127
+ targetId,
9128
+ title: message.title,
9129
+ body: message.body,
9130
+ deviceId: panel.deviceId
9131
+ });
9132
+ }
8271
9133
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
8272
9134
  scheduleEvaluation(subject, kind, logContext) {
8273
9135
  this.evalChain = this.evalChain.then(async () => {
@@ -8355,6 +9217,15 @@ var NotificationCenter = class NotificationCenter {
8355
9217
  deviceId: subject.deviceId,
8356
9218
  sequences
8357
9219
  });
9220
+ const unresolvable = unresolvableButtons(rule.actions);
9221
+ if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
9222
+ tags: { deviceId: subject.deviceId },
9223
+ meta: {
9224
+ ruleId: rule.id,
9225
+ rule: rule.name,
9226
+ buttons: unresolvable.join(", ")
9227
+ }
9228
+ });
8358
9229
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
8359
9230
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
8360
9231
  anyMatched = true;
@@ -22052,6 +22923,95 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22052
22923
  }
22053
22924
  }
22054
22925
  /**
22926
+ * The ranked base-URL candidates a notification LINK may use.
22927
+ *
22928
+ * Shared by the artifact plane (media) and the action plane (buttons) — one
22929
+ * resolver, because a second copy would drift and the two would start
22930
+ * minting links on different hosts for the same notification.
22931
+ */
22932
+ async ncEndpoints() {
22933
+ return collectArtifactEndpoints({
22934
+ markedBaseUrl: await this.markedNotificationEndpoint(),
22935
+ configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
22936
+ getConnected: async () => {
22937
+ const status = await this.ctx.api.networkAccess.getStatus.query();
22938
+ this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
22939
+ connected: status.connected,
22940
+ url: status.endpoint?.url ?? null,
22941
+ protocol: status.endpoint?.protocol ?? null
22942
+ } });
22943
+ return status.connected && status.endpoint !== null ? {
22944
+ url: status.endpoint.url,
22945
+ protocol: status.endpoint.protocol
22946
+ } : null;
22947
+ },
22948
+ listExternal: async () => {
22949
+ return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
22950
+ url: e.url,
22951
+ protocol: e.protocol
22952
+ }));
22953
+ },
22954
+ listLan: async (port) => {
22955
+ return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
22956
+ baseUrl: e.baseUrl,
22957
+ kind: e.kind,
22958
+ priority: e.priority
22959
+ }));
22960
+ },
22961
+ logger: this.ctx.logger
22962
+ });
22963
+ }
22964
+ /**
22965
+ * Serve the NC ACTION plane: the public route a notification button lands on.
22966
+ *
22967
+ * A sibling of the artifact plane and a strictly more dangerous one — that
22968
+ * route serves bytes, this one MOVES something. Its authority is the token in
22969
+ * the link and nothing else, which is the operator's explicit choice; the
22970
+ * bounds are in `notification-center/action-token.ts` and D47.
22971
+ *
22972
+ * Best-effort like every plane here: no facility ⇒ no route, and the
22973
+ * dispatcher simply mints no buttons, because `mintActionUrl` returns null.
22974
+ */
22975
+ async serveNcActionPlane(center) {
22976
+ try {
22977
+ const secretState = this.state("ncActionSecret", NcArtifactSecretSchema, "");
22978
+ let secret = await secretState.get();
22979
+ if (secret === "") {
22980
+ secret = (0, node_crypto.randomUUID)().replace(/-/g, "");
22981
+ await secretState.set(secret);
22982
+ }
22983
+ const routePrefix = `/addon/${this.ctx.id}/nc-action`;
22984
+ const plane = new NcActionPlane({
22985
+ registry: center.actionTokenRegistry,
22986
+ secret,
22987
+ logger: this.ctx.logger.child("nc-actions"),
22988
+ run: (grant) => center.runGrantedAction(grant)
22989
+ });
22990
+ const served = await this.ctx.dataPlane?.serve({
22991
+ prefix: "nc-action",
22992
+ access: "public",
22993
+ handler: plane.handler
22994
+ }) ?? null;
22995
+ this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
22996
+ const baseUrl = pickArtifactBaseUrl(await this.ncEndpoints());
22997
+ if (baseUrl === null) return null;
22998
+ return buildActionUrl({
22999
+ baseUrl,
23000
+ routePrefix,
23001
+ id,
23002
+ secret,
23003
+ expMs
23004
+ });
23005
+ };
23006
+ this.ctx.logger.info("nc-action data-plane served", { meta: { served: served !== null } });
23007
+ } catch (err) {
23008
+ this.ctx.logger.warn("nc-action data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
23009
+ }
23010
+ }
23011
+ /** Mints a button's callback URL once the action plane is served; null until
23012
+ * then, and null forever on an install with no reachable public address. */
23013
+ ncActionMintUrl = null;
23014
+ /**
22055
23015
  * Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
22056
23016
  * each link (see `notification-center/artifact-url.ts`). It exists because
22057
23017
  * attachments used to carry BYTES only, and the degrade engine drops a
@@ -22079,36 +23039,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22079
23039
  secret,
22080
23040
  logger: this.ctx.logger.child("nc-artifacts"),
22081
23041
  routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
22082
- listEndpoints: async () => collectArtifactEndpoints({
22083
- markedBaseUrl: await this.markedNotificationEndpoint(),
22084
- configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
22085
- getConnected: async () => {
22086
- const status = await this.ctx.api.networkAccess.getStatus.query();
22087
- this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
22088
- connected: status.connected,
22089
- url: status.endpoint?.url ?? null,
22090
- protocol: status.endpoint?.protocol ?? null
22091
- } });
22092
- return status.connected && status.endpoint !== null ? {
22093
- url: status.endpoint.url,
22094
- protocol: status.endpoint.protocol
22095
- } : null;
22096
- },
22097
- listExternal: async () => {
22098
- return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
22099
- url: e.url,
22100
- protocol: e.protocol
22101
- }));
22102
- },
22103
- listLan: async (port) => {
22104
- return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
22105
- baseUrl: e.baseUrl,
22106
- kind: e.kind,
22107
- priority: e.priority
22108
- }));
22109
- },
22110
- logger: this.ctx.logger
22111
- })
23042
+ listEndpoints: () => this.ncEndpoints()
22112
23043
  });
22113
23044
  this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
22114
23045
  prefix: "nc-artifact",
@@ -22177,6 +23108,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22177
23108
  * `notification-rules` provider (CRUD over the central store) is
22178
23109
  * registered from every node. */
22179
23110
  notificationCenter = null;
23111
+ /**
23112
+ * The live alarm panel on THIS node, or null.
23113
+ *
23114
+ * Held, where the earlier note said not to. The objection then was a field
23115
+ * nothing reads growing into a second source of truth for which device the
23116
+ * alarm is — and it stands: the panel is still addressed by stable id
23117
+ * everywhere else. This reference exists because two things now READ it, and
23118
+ * neither can go through the device id: the settings tab, which needs the
23119
+ * panel's own config, and the arm announcement, which the panel pushes.
23120
+ */
23121
+ alarmPanelDevice = null;
22180
23122
  /** THE single owner of capture pressure (S3, refactor spec 2026-07-22):
22181
23123
  * every native-fetch call site routed so far (the per-frame batch media
22182
23124
  * dispatch + `persistKeyFrames`; part B routes the rest) goes through ONE
@@ -22467,14 +23409,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22467
23409
  return found === void 0 ? null : { id: found.id };
22468
23410
  },
22469
23411
  createDevice: async ({ stableId, integrationId, name }) => {
22470
- return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
23412
+ const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
22471
23413
  type: NC_ALARM_DEVICE_TYPE,
22472
23414
  name,
22473
23415
  integrationId
22474
- })).id };
23416
+ });
23417
+ this.holdAlarmPanel(device);
23418
+ return { id: device.id };
22475
23419
  },
22476
23420
  adoptDevice: async ({ stableId }) => {
22477
- return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0)).id };
23421
+ const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
23422
+ this.holdAlarmPanel(device);
23423
+ return { id: device.id };
22478
23424
  }
22479
23425
  });
22480
23426
  if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
@@ -22482,6 +23428,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22482
23428
  this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
22483
23429
  }
22484
23430
  }
23431
+ /**
23432
+ * Keep the constructed panel, if that is what came back.
23433
+ *
23434
+ * An `instanceof` narrowing rather than a cast: `devices.create` promises an
23435
+ * `IDevice`, and the only honest way to know it is the alarm is to ask. A
23436
+ * cast here would compile against any future device class and fail at the
23437
+ * first `settings()` call, in production, with the tab already open.
23438
+ */
23439
+ holdAlarmPanel(device) {
23440
+ if (!(device instanceof NcAlarmPanelDevice)) {
23441
+ this.ctx.logger.warn("alarm panel is not the expected device class — settings tab disabled");
23442
+ return;
23443
+ }
23444
+ this.alarmPanelDevice = device;
23445
+ }
23446
+ /**
23447
+ * Join the panel and the Notification Center, once both exist.
23448
+ *
23449
+ * They are built in that order (`ensureAlarmPanel` runs before the centre is
23450
+ * constructed) and neither can reach the other on its own, so the join is
23451
+ * here. A node with no panel simply never calls it, and the cap answers
23452
+ * `deviceId: null` — which is what an agent node should say.
23453
+ */
23454
+ wireAlarmPanel(center) {
23455
+ const panel = this.alarmPanelDevice;
23456
+ if (panel === null) return;
23457
+ center.setAlarmPanel({
23458
+ deviceId: panel.id,
23459
+ settings: () => panel.settings(),
23460
+ applySettings: (patch) => panel.applySettings(patch),
23461
+ availableModes: () => NC_ALARM_MODES
23462
+ });
23463
+ panel.setArmedHook((mode) => center.announceArm(mode));
23464
+ this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
23465
+ }
22485
23466
  async declareCollections(api) {
22486
23467
  await TrackStore.declare(api.settingsStore);
22487
23468
  await MediaStore.declare(api.settingsStore);
@@ -22506,7 +23487,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22506
23487
  let storage = this.ctx.kernel.storage;
22507
23488
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
22508
23489
  if (mediaRoot) {
22509
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-B1HAVV_-.js"));
23490
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BNgGpVnp.js"));
22510
23491
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
22511
23492
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
22512
23493
  }
@@ -22876,6 +23857,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22876
23857
  });
22877
23858
  },
22878
23859
  isCapDeviceScoped: require_dist.isDeviceScopedCap,
23860
+ mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
22879
23861
  readDeviceStates: async (ids) => {
22880
23862
  const out = /* @__PURE__ */ new Map();
22881
23863
  for (const id of ids) try {
@@ -22937,18 +23919,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22937
23919
  });
22938
23920
  },
22939
23921
  send: async (input) => {
22940
- const { attachments, ...notification } = input.notification;
23922
+ const { attachments, actions, ...notification } = input.notification;
22941
23923
  return api.notificationOutput.send.mutate({
22942
23924
  addonId: input.addonId,
22943
23925
  targetId: input.targetId,
22944
23926
  notification: {
22945
23927
  ...notification,
22946
- ...attachments !== void 0 ? { attachments: [...attachments] } : {}
23928
+ ...attachments !== void 0 ? { attachments: [...attachments] } : {},
23929
+ ...actions !== void 0 ? { actions: [...actions] } : {}
22947
23930
  }
22948
23931
  });
22949
23932
  },
22950
23933
  getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
22951
- getDeviceName
23934
+ getDeviceName,
23935
+ buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
22952
23936
  },
22953
23937
  listObjectEventsSince: (since, limit) => stores.eventStore.queryObjectSince({
22954
23938
  since,
@@ -22964,6 +23948,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22964
23948
  })).tracks;
22965
23949
  }
22966
23950
  });
23951
+ this.wireAlarmPanel(this.notificationCenter);
23952
+ await this.serveNcActionPlane(this.notificationCenter);
22967
23953
  await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
22968
23954
  }
22969
23955
  /** Event-media data-plane: serve thumbnail JPEGs at