@camstack/addon-post-analysis 1.2.33 → 1.2.34

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-7ujnICn7.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.
@@ -5473,7 +5676,7 @@ var NcDispatcher = class {
5473
5676
  return { ok: true };
5474
5677
  }
5475
5678
  }
5476
- const notification = await this.buildNotification(entry);
5679
+ const notification = await this.buildNotification(entry, target);
5477
5680
  try {
5478
5681
  const result = await this.deps.send({
5479
5682
  addonId: target.addonId,
@@ -5603,7 +5806,37 @@ var NcDispatcher = class {
5603
5806
  if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
5604
5807
  return this.targetCache.get(targetId) ?? null;
5605
5808
  }
5606
- async buildNotification(entry) {
5809
+ /**
5810
+ * This delivery's buttons, or none.
5811
+ *
5812
+ * Never throws. A notification that arrives without its buttons is a
5813
+ * degraded notification; one that does not arrive because the token registry
5814
+ * hiccupped is a missed event. The failure is logged rather than swallowed —
5815
+ * a button the operator authored and never saw is exactly the silence this
5816
+ * repo keeps paying for.
5817
+ */
5818
+ async mintActions(entry, target) {
5819
+ if (this.deps.buildActions === void 0) return [];
5820
+ try {
5821
+ return await this.deps.buildActions({
5822
+ ruleId: entry.ruleId,
5823
+ ruleName: entry.payload.ruleName,
5824
+ deviceId: entry.deviceId,
5825
+ targetId: target.id
5826
+ });
5827
+ } catch (err) {
5828
+ this.deps.logger.warn("notification buttons could not be minted — sending without them", {
5829
+ tags: { deviceId: entry.deviceId },
5830
+ meta: {
5831
+ ruleId: entry.ruleId,
5832
+ targetId: target.id,
5833
+ error: String(err)
5834
+ }
5835
+ });
5836
+ return [];
5837
+ }
5838
+ }
5839
+ async buildNotification(entry, target) {
5607
5840
  const subject = entry.payload.subject;
5608
5841
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
5609
5842
  const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
@@ -5612,6 +5845,7 @@ var NcDispatcher = class {
5612
5845
  const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
5613
5846
  const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
5614
5847
  const params = pickParams(entry.payload.params);
5848
+ const actions = await this.mintActions(entry, target);
5615
5849
  return {
5616
5850
  body,
5617
5851
  title,
@@ -5620,7 +5854,8 @@ var NcDispatcher = class {
5620
5854
  tag: entry.ruleId,
5621
5855
  deviceId: subject.deviceId,
5622
5856
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
5623
- ...attachments.length > 0 ? { attachments } : {}
5857
+ ...attachments.length > 0 ? { attachments } : {},
5858
+ ...actions.length > 0 ? { actions } : {}
5624
5859
  };
5625
5860
  }
5626
5861
  /**
@@ -6738,6 +6973,60 @@ function rowToEntry$1(id, data) {
6738
6973
  };
6739
6974
  }
6740
6975
  //#endregion
6976
+ //#region src/notification-center/action-buttons.ts
6977
+ /** Sequence names the rule actually declares. A button may only name one. */
6978
+ function declaredSequences(actions) {
6979
+ const names = /* @__PURE__ */ new Set();
6980
+ for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
6981
+ return names;
6982
+ }
6983
+ /**
6984
+ * Build the notification's buttons.
6985
+ *
6986
+ * Returns an empty array rather than `undefined` — the caller decides whether
6987
+ * to set the field, and an empty array is the honest answer to "which buttons
6988
+ * survived", which `undefined` would conflate with "the rule declared none".
6989
+ */
6990
+ function buildActionButtons(input) {
6991
+ const actions = input.actions;
6992
+ if (actions === void 0) return [];
6993
+ const buttons = actions.buttons ?? [];
6994
+ if (buttons.length === 0) return [];
6995
+ const known = declaredSequences(actions);
6996
+ const seen = /* @__PURE__ */ new Set();
6997
+ const out = [];
6998
+ for (const button of buttons) {
6999
+ if (!known.has(button.sequence)) continue;
7000
+ if (seen.has(button.id)) continue;
7001
+ seen.add(button.id);
7002
+ out.push({
7003
+ id: button.id,
7004
+ label: button.label,
7005
+ url: input.mintUrl({
7006
+ sequence: button.sequence,
7007
+ actionId: button.id
7008
+ }),
7009
+ ...button.icon !== void 0 ? { icon: button.icon } : {},
7010
+ ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
7011
+ });
7012
+ }
7013
+ return out;
7014
+ }
7015
+ /**
7016
+ * Which of a rule's buttons name a sequence it does not have.
7017
+ *
7018
+ * Exported so the caller can LOG the drop. A button silently missing from a
7019
+ * notification is the exact shape of failure this repo keeps paying for — the
7020
+ * operator authored it, it never appeared, and nothing said why.
7021
+ */
7022
+ function unresolvableButtons(actions) {
7023
+ if (actions === void 0) return [];
7024
+ const buttons = actions.buttons ?? [];
7025
+ if (buttons.length === 0) return [];
7026
+ const known = declaredSequences(actions);
7027
+ return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
7028
+ }
7029
+ //#endregion
6741
7030
  //#region src/notification-center/rule-actions.ts
6742
7031
  var NcRuleActionRunner = class {
6743
7032
  deps;
@@ -7816,6 +8105,31 @@ var TimelapseStore = class {
7816
8105
  //#endregion
7817
8106
  //#region src/notification-center/index.ts
7818
8107
  /**
8108
+ * NotificationCenter — the P1 core of the Notification Center (spec
8109
+ * `2026-07-22-notification-center-requirements.md`, decisions D-1/D-2/D-3).
8110
+ *
8111
+ * A deps-injected, extractable module (TrackCloser pattern) hosted by
8112
+ * pipeline-analytics. It couples rule evaluation to the DURABLE persist
8113
+ * moments (D-2), never to the telemetry bus:
8114
+ *
8115
+ * object-event insert ─▶ onObjectEventPersisted ─▶ immediate rules
8116
+ * TrackCloser.closeExpired ─▶ onTrackClosed ─▶ track-end rules
8117
+ * │ match + throttle
8118
+ * ▼
8119
+ * NcOutbox (durable rows, unique key rule:track:target)
8120
+ * │ drain loop (backoff / dead-letter)
8121
+ * ▼
8122
+ * NcDispatcher ─▶ ctx.api notification-output.send (RPC, hub-routed)
8123
+ *
8124
+ * Crash gap: on start (evaluation mode) the module scans object events
8125
+ * newer than the persisted watermark and re-evaluates them — idempotent
8126
+ * through the outbox dedup key (D8: reconcile, never trust an event).
8127
+ *
8128
+ * Multi-node: rules live in the centralized settings-store; only the
8129
+ * designated post-processing node runs evaluation + the drain loop, so a
8130
+ * CRUD served elsewhere becomes effective within one rule-reload tick.
8131
+ */
8132
+ /**
7819
8133
  * How often the "matched NO rule" report may fire per device. Long enough that
7820
8134
  * a busy camera prints one line rather than one per event, short enough that a
7821
8135
  * rule which has stopped matching is visible within minutes rather than by
@@ -7935,6 +8249,9 @@ var NotificationCenter = class NotificationCenter {
7935
8249
  deviceStates;
7936
8250
  /** Runs a matched rule's action sequences. Null when no actuator is wired. */
7937
8251
  actionRunner;
8252
+ /** Grants behind the tap-through buttons. In-process and single-use — read
8253
+ * `action-token.ts` before assuming anything about what a tap proves. */
8254
+ actionTokens = new NcActionTokenRegistry();
7938
8255
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
7939
8256
  * the watcher is idle (zero per-frame cost) otherwise. */
7940
8257
  occupancyEnabled = false;
@@ -7955,6 +8272,109 @@ var NotificationCenter = class NotificationCenter {
7955
8272
  reloadTimer = null;
7956
8273
  drainTicks = 0;
7957
8274
  evaluationActive = false;
8275
+ /**
8276
+ * The tap-through buttons for ONE delivery, each with its own single-use
8277
+ * token.
8278
+ *
8279
+ * Per delivery, not per notification: the first tap spends the token, so two
8280
+ * recipients sharing one would mean the second person's button was already
8281
+ * dead when it arrived.
8282
+ *
8283
+ * Returns `[]` for every ordinary reason — the rule declares no buttons, no
8284
+ * actuator is wired, no reachable URL exists. A button is an enhancement; not
8285
+ * being able to mint one must never cost the notification.
8286
+ */
8287
+ async mintButtons(input) {
8288
+ const mint = this.deps.mintActionUrl;
8289
+ if (mint === void 0 || this.actionRunner === null) return [];
8290
+ const rule = this.rules.get(input.ruleId);
8291
+ if (rule === null) return [];
8292
+ if ((rule.actions?.buttons ?? []).length === 0) return [];
8293
+ const expMs = this.now() + DEFAULT_ACTION_TTL_MS;
8294
+ const pending = [];
8295
+ const out = buildActionButtons({
8296
+ actions: rule.actions,
8297
+ mintUrl: ({ sequence, actionId }) => {
8298
+ const id = (0, node_crypto.randomUUID)();
8299
+ pending.push({
8300
+ id,
8301
+ url: "",
8302
+ sequence,
8303
+ actionId
8304
+ });
8305
+ return id;
8306
+ }
8307
+ });
8308
+ if (out.length === 0) return [];
8309
+ const resolved = [];
8310
+ for (const [index, action] of out.entries()) {
8311
+ const slot = pending[index];
8312
+ if (slot === void 0) continue;
8313
+ const url = await mint({
8314
+ id: slot.id,
8315
+ expMs
8316
+ });
8317
+ if (url === null) {
8318
+ this.logger.debug("no reachable base URL — notification buttons omitted", {
8319
+ tags: { deviceId: input.deviceId },
8320
+ meta: { ruleId: input.ruleId }
8321
+ });
8322
+ return [];
8323
+ }
8324
+ this.actionTokens.put({
8325
+ id: slot.id,
8326
+ ruleId: input.ruleId,
8327
+ ruleName: input.ruleName,
8328
+ sequence: slot.sequence,
8329
+ deviceId: input.deviceId,
8330
+ actionId: slot.actionId,
8331
+ targetId: input.targetId,
8332
+ expiresAt: expMs
8333
+ });
8334
+ resolved.push({
8335
+ ...action,
8336
+ url
8337
+ });
8338
+ }
8339
+ return resolved;
8340
+ }
8341
+ /** The registry the action plane claims against. */
8342
+ get actionTokenRegistry() {
8343
+ return this.actionTokens;
8344
+ }
8345
+ /**
8346
+ * Run the sequence a spent token granted.
8347
+ *
8348
+ * **The grant overrides `enabled`, deliberately.** A sequence reachable only
8349
+ * by a button is written as `enabled: false` — that is how "do not run this
8350
+ * automatically" is expressed — and the runner skips a disabled sequence. For
8351
+ * a granted run the BUTTON is the enablement, and the operator tapping it is
8352
+ * a more explicit instruction than the flag it overrides. The per-sequence
8353
+ * throttle still applies: `minDelaySec` is about how often a gate may
8354
+ * physically move, and a tap does not change that.
8355
+ *
8356
+ * Throws when the sequence is gone or a step failed — the plane turns that
8357
+ * into the page that says so.
8358
+ */
8359
+ async runGrantedAction(grant) {
8360
+ if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
8361
+ const rule = this.rules.get(grant.ruleId);
8362
+ if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
8363
+ const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
8364
+ if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
8365
+ const [outcome] = await this.actionRunner.run({
8366
+ ruleId: rule.id,
8367
+ ruleName: rule.name,
8368
+ deviceId: grant.deviceId,
8369
+ sequences: [{
8370
+ ...sequence,
8371
+ enabled: true
8372
+ }]
8373
+ });
8374
+ if (outcome === void 0) throw new Error("the sequence did not run");
8375
+ if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
8376
+ if (!outcome.ran) throw new Error(`"${sequence.name}" did not run (${outcome.skippedReason ?? "unknown"})`);
8377
+ }
7958
8378
  constructor(deps) {
7959
8379
  this.deps = deps;
7960
8380
  this.logger = deps.logger;
@@ -8355,6 +8775,15 @@ var NotificationCenter = class NotificationCenter {
8355
8775
  deviceId: subject.deviceId,
8356
8776
  sequences
8357
8777
  });
8778
+ const unresolvable = unresolvableButtons(rule.actions);
8779
+ if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
8780
+ tags: { deviceId: subject.deviceId },
8781
+ meta: {
8782
+ ruleId: rule.id,
8783
+ rule: rule.name,
8784
+ buttons: unresolvable.join(", ")
8785
+ }
8786
+ });
8358
8787
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
8359
8788
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
8360
8789
  anyMatched = true;
@@ -22052,6 +22481,95 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22052
22481
  }
22053
22482
  }
22054
22483
  /**
22484
+ * The ranked base-URL candidates a notification LINK may use.
22485
+ *
22486
+ * Shared by the artifact plane (media) and the action plane (buttons) — one
22487
+ * resolver, because a second copy would drift and the two would start
22488
+ * minting links on different hosts for the same notification.
22489
+ */
22490
+ async ncEndpoints() {
22491
+ return collectArtifactEndpoints({
22492
+ markedBaseUrl: await this.markedNotificationEndpoint(),
22493
+ configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
22494
+ getConnected: async () => {
22495
+ const status = await this.ctx.api.networkAccess.getStatus.query();
22496
+ this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
22497
+ connected: status.connected,
22498
+ url: status.endpoint?.url ?? null,
22499
+ protocol: status.endpoint?.protocol ?? null
22500
+ } });
22501
+ return status.connected && status.endpoint !== null ? {
22502
+ url: status.endpoint.url,
22503
+ protocol: status.endpoint.protocol
22504
+ } : null;
22505
+ },
22506
+ listExternal: async () => {
22507
+ return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
22508
+ url: e.url,
22509
+ protocol: e.protocol
22510
+ }));
22511
+ },
22512
+ listLan: async (port) => {
22513
+ return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
22514
+ baseUrl: e.baseUrl,
22515
+ kind: e.kind,
22516
+ priority: e.priority
22517
+ }));
22518
+ },
22519
+ logger: this.ctx.logger
22520
+ });
22521
+ }
22522
+ /**
22523
+ * Serve the NC ACTION plane: the public route a notification button lands on.
22524
+ *
22525
+ * A sibling of the artifact plane and a strictly more dangerous one — that
22526
+ * route serves bytes, this one MOVES something. Its authority is the token in
22527
+ * the link and nothing else, which is the operator's explicit choice; the
22528
+ * bounds are in `notification-center/action-token.ts` and D47.
22529
+ *
22530
+ * Best-effort like every plane here: no facility ⇒ no route, and the
22531
+ * dispatcher simply mints no buttons, because `mintActionUrl` returns null.
22532
+ */
22533
+ async serveNcActionPlane(center) {
22534
+ try {
22535
+ const secretState = this.state("ncActionSecret", NcArtifactSecretSchema, "");
22536
+ let secret = await secretState.get();
22537
+ if (secret === "") {
22538
+ secret = (0, node_crypto.randomUUID)().replace(/-/g, "");
22539
+ await secretState.set(secret);
22540
+ }
22541
+ const routePrefix = `/addon/${this.ctx.id}/nc-action`;
22542
+ const plane = new NcActionPlane({
22543
+ registry: center.actionTokenRegistry,
22544
+ secret,
22545
+ logger: this.ctx.logger.child("nc-actions"),
22546
+ run: (grant) => center.runGrantedAction(grant)
22547
+ });
22548
+ const served = await this.ctx.dataPlane?.serve({
22549
+ prefix: "nc-action",
22550
+ access: "public",
22551
+ handler: plane.handler
22552
+ }) ?? null;
22553
+ this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
22554
+ const baseUrl = pickArtifactBaseUrl(await this.ncEndpoints());
22555
+ if (baseUrl === null) return null;
22556
+ return buildActionUrl({
22557
+ baseUrl,
22558
+ routePrefix,
22559
+ id,
22560
+ secret,
22561
+ expMs
22562
+ });
22563
+ };
22564
+ this.ctx.logger.info("nc-action data-plane served", { meta: { served: served !== null } });
22565
+ } catch (err) {
22566
+ this.ctx.logger.warn("nc-action data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
22567
+ }
22568
+ }
22569
+ /** Mints a button's callback URL once the action plane is served; null until
22570
+ * then, and null forever on an install with no reachable public address. */
22571
+ ncActionMintUrl = null;
22572
+ /**
22055
22573
  * Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
22056
22574
  * each link (see `notification-center/artifact-url.ts`). It exists because
22057
22575
  * attachments used to carry BYTES only, and the degrade engine drops a
@@ -22079,36 +22597,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22079
22597
  secret,
22080
22598
  logger: this.ctx.logger.child("nc-artifacts"),
22081
22599
  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
- })
22600
+ listEndpoints: () => this.ncEndpoints()
22112
22601
  });
22113
22602
  this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
22114
22603
  prefix: "nc-artifact",
@@ -22506,7 +22995,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22506
22995
  let storage = this.ctx.kernel.storage;
22507
22996
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
22508
22997
  if (mediaRoot) {
22509
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-B1HAVV_-.js"));
22998
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-B4EfMwxa.js"));
22510
22999
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
22511
23000
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
22512
23001
  }
@@ -22876,6 +23365,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22876
23365
  });
22877
23366
  },
22878
23367
  isCapDeviceScoped: require_dist.isDeviceScopedCap,
23368
+ mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
22879
23369
  readDeviceStates: async (ids) => {
22880
23370
  const out = /* @__PURE__ */ new Map();
22881
23371
  for (const id of ids) try {
@@ -22937,18 +23427,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22937
23427
  });
22938
23428
  },
22939
23429
  send: async (input) => {
22940
- const { attachments, ...notification } = input.notification;
23430
+ const { attachments, actions, ...notification } = input.notification;
22941
23431
  return api.notificationOutput.send.mutate({
22942
23432
  addonId: input.addonId,
22943
23433
  targetId: input.targetId,
22944
23434
  notification: {
22945
23435
  ...notification,
22946
- ...attachments !== void 0 ? { attachments: [...attachments] } : {}
23436
+ ...attachments !== void 0 ? { attachments: [...attachments] } : {},
23437
+ ...actions !== void 0 ? { actions: [...actions] } : {}
22947
23438
  }
22948
23439
  });
22949
23440
  },
22950
23441
  getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
22951
- getDeviceName
23442
+ getDeviceName,
23443
+ buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
22952
23444
  },
22953
23445
  listObjectEventsSince: (since, limit) => stores.eventStore.queryObjectSince({
22954
23446
  since,
@@ -22964,6 +23456,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22964
23456
  })).tracks;
22965
23457
  }
22966
23458
  });
23459
+ await this.serveNcActionPlane(this.notificationCenter);
22967
23460
  await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
22968
23461
  }
22969
23462
  /** Event-media data-plane: serve thumbnail JPEGs at