@camstack/system 1.2.67 → 1.2.68

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.
Files changed (51) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/local-network.addon.js +1 -1
  24. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  25. package/dist/builtins/loki-logging/index.js +1 -1
  26. package/dist/builtins/loki-logging/index.mjs +1 -1
  27. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  29. package/dist/builtins/platform-probe/index.js +1 -1
  30. package/dist/builtins/platform-probe/index.mjs +1 -1
  31. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  33. package/dist/builtins/snapshot/index.js +339 -2
  34. package/dist/builtins/snapshot/index.mjs +339 -2
  35. package/dist/builtins/snapshot/snapshot-link-url.d.ts +66 -0
  36. package/dist/builtins/snapshot/snapshot.addon.d.ts +79 -0
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  41. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  43. package/dist/builtins/system-config/system-config.addon.js +1 -1
  44. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  45. package/dist/builtins/winston-logging/index.js +1 -1
  46. package/dist/builtins/winston-logging/index.mjs +1 -1
  47. package/dist/{dist-CbS6uoSb.mjs → dist-D2J8RJ7m.mjs} +77 -0
  48. package/dist/{dist-NRYjb8qK.js → dist-Dhpte164.js} +77 -0
  49. package/dist/index.js +1 -1
  50. package/dist/index.mjs +1 -1
  51. package/package.json +1 -1
@@ -1,4 +1,7 @@
1
- import { C as bareAddonId, Mt as nodePin, _t as DeviceFeature, at as snapshotCapability, ct as streamQualityLabel, ft as errMsg, i as BatteryStatusSchema, pt as BaseAddon, yt as DeviceType } from "../../dist-CbS6uoSb.mjs";
1
+ import { C as bareAddonId, Mt as nodePin, _t as DeviceFeature, at as snapshotCapability, ct as streamQualityLabel, ft as errMsg, i as BatteryStatusSchema, pt as BaseAddon, yt as DeviceType } from "../../dist-D2J8RJ7m.mjs";
2
+ import { signExpiringUrl, verifyExpiringUrl } from "@camstack/types/node";
3
+ import { z } from "zod";
4
+ import { randomUUID } from "node:crypto";
2
5
  import { execFile, spawn } from "node:child_process";
3
6
  //#region src/builtins/snapshot/snapshot-coalescing.ts
4
7
  /**
@@ -640,7 +643,146 @@ var SnapshotSubscriptions = class {
640
643
  }
641
644
  };
642
645
  //#endregion
646
+ //#region src/builtins/snapshot/snapshot-link-url.ts
647
+ /**
648
+ * Signed, expiring links to a CLIENT-SIZED snapshot frame.
649
+ *
650
+ * ## Why a link plane exists at all
651
+ *
652
+ * The authenticated `/addon/snapshot/media/<id>.jpg` plane works, and it is not
653
+ * going away. What it cannot do is guarantee that a client asking for a tile
654
+ * actually REACHES the server — and that turned out to be the whole bug.
655
+ *
656
+ * Under D93 the image URL is versioned by the frame identity, and an image
657
+ * request is what enrols a camera in the keep-warm loop. Both halves are
658
+ * satisfied by the client's own image cache: `expo-image` is URL-keyed and never
659
+ * revalidates, so a URL the app painted in a previous session is served from
660
+ * disk with **zero network**. Measured on the live hub, reopening the app after
661
+ * two minutes idle painted 15 of 16 tiles from disk — frames **168 s old**, with
662
+ * not one HTTP request, therefore not one enrolment, therefore nothing warming.
663
+ * The operator's report ("gli snapshot sono vecchi, devo aggiornare più volte")
664
+ * is that measurement.
665
+ *
666
+ * A minted link breaks the loop from both ends. It is produced by an RPC —
667
+ * `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
668
+ * signal always lands; and it carries the capture identity the RPC just WAITED
669
+ * for, rather than one a cache-only poll happened to be holding.
670
+ *
671
+ * ## What is signed, and what is only a cache key
672
+ *
673
+ * The signature covers `"<deviceId>:<width>"` and the expiry. The width is
674
+ * inside it deliberately: a leaked 240 px tile link must not be replayable as a
675
+ * request for the full 4 K frame. `v` (the capture identity) is NOT signed — it
676
+ * exists only to key the client's image cache, so an unchanged frame is a
677
+ * byte-identical URL and costs no bytes, and a new frame is a new URL and costs
678
+ * exactly one fetch.
679
+ *
680
+ * `exp` is bucketed rather than exact. A URL that were unique per mint would
681
+ * defeat the client cache completely — correct for freshness, and it would make
682
+ * a phone re-download every tile on every 5 s poll whether or not anything
683
+ * changed. Bucketing means the URL moves when the FRAME moves, and otherwise at
684
+ * most once per bucket.
685
+ *
686
+ * Pure and side-effect-free; the addon owns the secret and the clock. Unit
687
+ * tested in `__tests__/snapshot-link-url.spec.ts`.
688
+ */
689
+ /**
690
+ * How long a minted link stays valid.
691
+ *
692
+ * A snapshot is a live view of the operator's home, so this is short on purpose
693
+ * — the exposure of a leaked link is bounded by it. Two minutes is long enough
694
+ * that a page renders, re-renders and survives a brief backgrounding on the URL
695
+ * it was given, and short enough that a link pasted somewhere is dead before it
696
+ * is useful. The client re-mints on every overview poll (5 s), so it never
697
+ * depends on the tail of this window.
698
+ */
699
+ var SNAPSHOT_LINK_TTL_MS = 12e4;
700
+ /**
701
+ * Quantum the expiry is rounded UP to, so a link is stable between mints.
702
+ *
703
+ * Without it every mint produces a different `exp`, hence a different URL, hence
704
+ * a full re-download of an unchanged frame on every poll. With it the URL is a
705
+ * pure function of (device, width, frame, bucket) — so a tile fetches when its
706
+ * frame moves, and at most once more per bucket.
707
+ */
708
+ var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
709
+ /** The token the signature is computed over. Width is part of the identity so a
710
+ * tile link cannot be escalated into a full-frame request. */
711
+ function snapshotLinkId(deviceId, width) {
712
+ return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
713
+ }
714
+ /** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
715
+ function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
716
+ return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
717
+ }
718
+ /**
719
+ * The link, as a ROOT-RELATIVE path.
720
+ *
721
+ * Deliberately not absolute. The artifact and HA planes must mint absolute URLs
722
+ * because the fetcher is a phone or a notifier backend that has no idea where
723
+ * the hub is — and picking that base is the `hubUrl: localhost` trap the Alexa
724
+ * work paid for. Here the fetcher is a client that is already connected to the
725
+ * hub and holds its own `serverUrl`, so the correct base is the one it used to
726
+ * make the call. Returning a path makes it impossible to hand a client a link
727
+ * pointing somewhere it cannot reach.
728
+ */
729
+ function buildSnapshotLinkUrl(input) {
730
+ const base = (input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`).replace(/\/+$/, "");
731
+ const id = snapshotLinkId(input.deviceId, input.width);
732
+ const sig = signExpiringUrl(input.secret, id, input.expMs);
733
+ const params = new URLSearchParams();
734
+ if (input.width !== void 0) params.set("w", String(input.width));
735
+ if (input.capturedAt !== null) params.set("v", String(input.capturedAt));
736
+ params.set("exp", String(input.expMs));
737
+ params.set("sig", sig);
738
+ return `${base}/${String(input.deviceId)}.jpg?${params.toString()}`;
739
+ }
740
+ /**
741
+ * Parse and VERIFY a link request in one step, so a caller cannot accidentally
742
+ * use the device id before checking the signature. Null = refuse (404/403);
743
+ * there is deliberately no way to distinguish "bad signature" from "expired"
744
+ * from "malformed" at this boundary, so a public route cannot be probed.
745
+ */
746
+ function parseVerifiedSnapshotLink(input) {
747
+ const qIdx = input.url.indexOf("?");
748
+ const rawPath = qIdx === -1 ? input.url : input.url.slice(0, qIdx);
749
+ const query = qIdx === -1 ? "" : input.url.slice(qIdx + 1);
750
+ const segment = rawPath.replace(/^\/+/, "");
751
+ if (segment.length === 0 || segment.includes("/")) return null;
752
+ const idPart = segment.replace(/\.jpe?g$/i, "");
753
+ if (!/^\d+$/.test(idPart)) return null;
754
+ const deviceId = Number.parseInt(idPart, 10);
755
+ if (!Number.isSafeInteger(deviceId) || deviceId <= 0) return null;
756
+ const params = new URLSearchParams(query);
757
+ const rawWidth = params.get("w");
758
+ if (rawWidth !== null && !/^\d+$/.test(rawWidth)) return null;
759
+ const width = rawWidth === null ? void 0 : Number.parseInt(rawWidth, 10);
760
+ if (width !== void 0 && (!Number.isSafeInteger(width) || width <= 0)) return null;
761
+ return verifyExpiringUrl({
762
+ secret: input.secret,
763
+ id: snapshotLinkId(deviceId, width),
764
+ exp: params.get("exp") ?? void 0,
765
+ sig: params.get("sig") ?? void 0,
766
+ nowMs: input.nowMs
767
+ }) ? {
768
+ deviceId,
769
+ width
770
+ } : null;
771
+ }
772
+ //#endregion
643
773
  //#region src/builtins/snapshot/snapshot.addon.ts
774
+ /**
775
+ * How long `getSnapshotLinks` waits for the capture it triggered before minting
776
+ * the link over whatever frame it has.
777
+ *
778
+ * Sized against the paint budget, not against the slowest camera. A cold fleet
779
+ * is paced by `MAX_CONCURRENT_GRABS`, so waiting for the last of sixteen would
780
+ * hold the whole grid for tens of seconds — and the frame that lands late is not
781
+ * lost, it populates the cache and the client's next 5 s poll picks it up. The
782
+ * value matches `SWR_STALE_WAIT_MS`'s intent: long enough that a warm broker
783
+ * answers inside it, short enough that a dead camera cannot stall the batch.
784
+ */
785
+ var LINK_MINT_DEADLINE_MS = 2500;
644
786
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
645
787
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
646
788
  /** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
@@ -707,6 +849,17 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
707
849
  */
708
850
  mediaDataPlane = null;
709
851
  /**
852
+ * The PUBLIC, signature-authenticated twin of the media plane. Null when the
853
+ * host has no `ctx.dataPlane` facility, or before the per-install secret has
854
+ * been minted — in which case `getSnapshotLinks` reports `url: null` and the
855
+ * client keeps using the authenticated plane exactly as before.
856
+ */
857
+ linkDataPlane = null;
858
+ /** Per-install HMAC secret for link signatures. Minted once and persisted: a
859
+ * secret that rolled on every boot would invalidate every link a client is
860
+ * currently holding, which is a fleet-wide blank grid after each restart. */
861
+ linkSecret = null;
862
+ /**
710
863
  * Who is being looked at, inferred from data-plane traffic. The keep-warm
711
864
  * loop's entire bound — see `snapshot-keep-warm.ts`.
712
865
  */
@@ -738,9 +891,11 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
738
891
  getDeviceLiveContribution: async () => null,
739
892
  applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),
740
893
  getStatus: async (input) => this.getStatus(input.deviceId),
741
- getSnapshotOverview: (input) => this.getSnapshotOverview(input)
894
+ getSnapshotOverview: (input) => this.getSnapshotOverview(input),
895
+ getSnapshotLinks: (input) => this.getSnapshotLinks(input)
742
896
  };
743
897
  await this.serveMediaDataPlane();
898
+ await this.serveLinkDataPlane();
744
899
  this.scheduleWarmTick();
745
900
  return [{
746
901
  capability: snapshotCapability,
@@ -774,6 +929,184 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
774
929
  }
775
930
  }
776
931
  /**
932
+ * Serve the PUBLIC link plane: `GET /addon/snapshot/link/<deviceId>.jpg?w&v&exp&sig`.
933
+ *
934
+ * `access: 'public'` is deliberate and is the only interesting decision here.
935
+ * The authority is the HMAC, exactly as for the notification artifact plane
936
+ * and the Home Assistant media plane — but unlike those, what sits behind it
937
+ * is a LIVE view of the operator's home, so the compensating choices are
938
+ * explicit: a short TTL (`SNAPSHOT_LINK_TTL_MS`), the width inside the signed
939
+ * identity so a leaked tile cannot be escalated to the full frame, and
940
+ * **`force` is not honoured on this route at all** — a public URL must never
941
+ * be able to wake a battery camera.
942
+ *
943
+ * A leaked link therefore grants: one camera, at one thumbnail width, for at
944
+ * most the TTL. No token, no other device, no session.
945
+ */
946
+ async serveLinkDataPlane() {
947
+ try {
948
+ const secret = await this.ensureLinkSecret();
949
+ if (secret === null) return;
950
+ const prefix = this.linkRoutePrefix();
951
+ const handler = createSnapshotMediaHandler({
952
+ getMedia: (deviceId, streamId, _force, width) => this.resolveSnapshotMedia(deviceId, streamId, false, width),
953
+ peekFresh: (deviceId, streamId) => this.peekFreshFrame(deviceId, streamId)
954
+ });
955
+ this.linkDataPlane = await this.ctx.dataPlane?.serve({
956
+ prefix: "link",
957
+ access: "public",
958
+ handler: (req, res) => this.handleLinkRequest(req, res, secret, handler)
959
+ }) ?? null;
960
+ this.ctx.logger.info("snapshot link data-plane served", { meta: {
961
+ served: this.linkDataPlane !== null,
962
+ prefix
963
+ } });
964
+ } catch (err) {
965
+ this.ctx.logger.warn("snapshot link data-plane failed to serve", { meta: { error: errMsg(err) } });
966
+ this.linkDataPlane = null;
967
+ }
968
+ }
969
+ /**
970
+ * Verify first, then hand the request to the ordinary media handler with a
971
+ * REWRITTEN url carrying only the signed `(deviceId, width)`.
972
+ *
973
+ * Rewriting rather than passing the original through is what stops an unsigned
974
+ * query parameter reaching the capture path: `force`, `streamId` and any other
975
+ * lever the authenticated plane honours are simply not present in what the
976
+ * handler parses.
977
+ */
978
+ async handleLinkRequest(req, res, secret, inner) {
979
+ const verified = parseVerifiedSnapshotLink({
980
+ url: req.url ?? "/",
981
+ secret,
982
+ nowMs: Date.now()
983
+ });
984
+ if (verified === null) {
985
+ res.writeHead(404).end();
986
+ return;
987
+ }
988
+ const rewritten = `/${String(verified.deviceId)}.jpg${verified.width === void 0 ? "" : `?w=${String(verified.width)}`}`;
989
+ const original = req.url;
990
+ try {
991
+ req.url = rewritten;
992
+ await inner(req, res);
993
+ } finally {
994
+ req.url = original;
995
+ }
996
+ }
997
+ linkRoutePrefix() {
998
+ return `/addon/${bareAddonId(this.ctx.id)}/link`;
999
+ }
1000
+ /** Mint-once-and-persist the signing secret. Null if it cannot be stored — a
1001
+ * secret held only in memory would break every outstanding link on restart,
1002
+ * so no plane is better than a plane whose links die unpredictably. */
1003
+ async ensureLinkSecret() {
1004
+ if (this.linkSecret !== null) return this.linkSecret;
1005
+ try {
1006
+ const state = this.state("snapshotLinkSecret", z.string(), "");
1007
+ let secret = await state.get();
1008
+ if (secret === "") {
1009
+ secret = randomUUID().replace(/-/g, "");
1010
+ await state.set(secret);
1011
+ }
1012
+ this.linkSecret = secret;
1013
+ return secret;
1014
+ } catch (err) {
1015
+ this.ctx.logger.warn("snapshot: could not establish link signing secret", { meta: { error: errMsg(err) } });
1016
+ return null;
1017
+ }
1018
+ }
1019
+ /**
1020
+ * Mint signed links for the tiles a surface is rendering — and, in the same
1021
+ * call, make those tiles CURRENT.
1022
+ *
1023
+ * This is the demand signal the design was missing. Under D93 enrolment rode
1024
+ * on the image request, and D93/D94/D95 each fixed a different way for that
1025
+ * request not to happen; none of them could fix the last one, because the
1026
+ * client's own image cache answers a repeated URL with no network at all. An
1027
+ * RPC has no such cache in front of it.
1028
+ *
1029
+ * So: note every target (creating a subscription, which `getSnapshotOverview`
1030
+ * deliberately cannot), then join the ordinary non-forced capture path and
1031
+ * wait a BOUNDED time for it. What comes back is the identity of the frame the
1032
+ * link will actually serve.
1033
+ *
1034
+ * The bound matters more than its exact value. A cold fleet is paced by
1035
+ * `MAX_CONCURRENT_GRABS`, so sixteen cameras cannot all capture at once and
1036
+ * the last of them would hold the whole batch well past any sensible paint
1037
+ * budget. Devices that miss the bound return their real, older `capturedAt` —
1038
+ * honest rather than blocking — and the client's next poll carries them
1039
+ * forward.
1040
+ */
1041
+ async getSnapshotLinks(input) {
1042
+ const secret = await this.ensureLinkSecret();
1043
+ const now = Date.now();
1044
+ const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
1045
+ const served = this.linkDataPlane !== null && secret !== null;
1046
+ const prefix = this.linkRoutePrefix();
1047
+ const rows = await Promise.all(input.targets.map(async (target) => {
1048
+ const { deviceId } = target;
1049
+ const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
1050
+ this.subscriptions.note(deviceId, void 0, width, now);
1051
+ const sleeping = await this.isSleepingBatteryDevice(deviceId);
1052
+ if (!sleeping) await this.awaitFreshEnough(deviceId);
1053
+ const capturedAt = this.cache.latest(deviceId)?.ts ?? null;
1054
+ const answeredAt = Date.now();
1055
+ return {
1056
+ deviceId,
1057
+ url: served && secret !== null ? buildSnapshotLinkUrl({
1058
+ routePrefix: prefix,
1059
+ deviceId,
1060
+ width,
1061
+ capturedAt,
1062
+ secret,
1063
+ expMs
1064
+ }) : null,
1065
+ capturedAt,
1066
+ ageMs: capturedAt === null ? null : answeredAt - capturedAt,
1067
+ expiresAt: served ? expMs : null,
1068
+ width: width ?? null,
1069
+ neverCaptured: capturedAt === null,
1070
+ sleeping
1071
+ };
1072
+ }));
1073
+ this.ctx.logger.debug("snapshot: minted links", { meta: {
1074
+ targets: input.targets.length,
1075
+ served,
1076
+ stale: rows.filter((r) => r.ageMs !== null && r.ageMs > 15e3).length
1077
+ } });
1078
+ return rows;
1079
+ }
1080
+ /**
1081
+ * Join the ordinary capture path for one device and wait, bounded, for it.
1082
+ *
1083
+ * Deliberately `getSnapshot` with `force: false` and nothing else: every gate
1084
+ * that already owns this decision — the per-device max-age, the single flight,
1085
+ * the grab pool, the sleeping-battery refusal — applies unchanged. A fresh
1086
+ * cache returns immediately; a stale one starts the refresh. There is no
1087
+ * second freshness policy here, which is the mistake D93 called out.
1088
+ */
1089
+ async awaitFreshEnough(deviceId) {
1090
+ if (!(await raceForResult(this.getSnapshot({
1091
+ deviceId,
1092
+ force: false
1093
+ }).catch(() => null), LINK_MINT_DEADLINE_MS)).settled) this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
1094
+ tags: { deviceId },
1095
+ meta: { deadlineMs: LINK_MINT_DEADLINE_MS }
1096
+ });
1097
+ }
1098
+ /** True only for a BATTERY device that is currently asleep. Kept as one
1099
+ * question so the link path cannot accidentally treat "battery" as "asleep"
1100
+ * and stop refreshing a camera that is awake and streaming. */
1101
+ async isSleepingBatteryDevice(deviceId) {
1102
+ try {
1103
+ if ((await this.lookupDeviceMeta(deviceId))?.isBattery !== true) return false;
1104
+ return await this.isDeviceSleeping(deviceId);
1105
+ } catch {
1106
+ return false;
1107
+ }
1108
+ }
1109
+ /**
777
1110
  * Resolve a device (+ optional stream, + force) to renderable JPEG bytes for
778
1111
  * the HTTP endpoint. Runs the same coalesced capture ladder as the tRPC
779
1112
  * `getSnapshot` (so every consumer shares one upstream pull), then decodes the
@@ -970,6 +1303,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
970
1303
  await this.mediaDataPlane.dispose().catch(() => void 0);
971
1304
  this.mediaDataPlane = null;
972
1305
  }
1306
+ if (this.linkDataPlane) {
1307
+ await this.linkDataPlane.dispose().catch(() => void 0);
1308
+ this.linkDataPlane = null;
1309
+ }
973
1310
  this.cache.clear();
974
1311
  this.variants.clear();
975
1312
  this.captureFlight.clear();
@@ -0,0 +1,66 @@
1
+ /**
2
+ * How long a minted link stays valid.
3
+ *
4
+ * A snapshot is a live view of the operator's home, so this is short on purpose
5
+ * — the exposure of a leaked link is bounded by it. Two minutes is long enough
6
+ * that a page renders, re-renders and survives a brief backgrounding on the URL
7
+ * it was given, and short enough that a link pasted somewhere is dead before it
8
+ * is useful. The client re-mints on every overview poll (5 s), so it never
9
+ * depends on the tail of this window.
10
+ */
11
+ export declare const SNAPSHOT_LINK_TTL_MS = 120000;
12
+ /**
13
+ * Quantum the expiry is rounded UP to, so a link is stable between mints.
14
+ *
15
+ * Without it every mint produces a different `exp`, hence a different URL, hence
16
+ * a full re-download of an unchanged frame on every poll. With it the URL is a
17
+ * pure function of (device, width, frame, bucket) — so a tile fetches when its
18
+ * frame moves, and at most once more per bucket.
19
+ */
20
+ export declare const SNAPSHOT_LINK_EXP_BUCKET_MS = 30000;
21
+ /** The token the signature is computed over. Width is part of the identity so a
22
+ * tile link cannot be escalated into a full-frame request. */
23
+ export declare function snapshotLinkId(deviceId: number, width: number | undefined): string;
24
+ /** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
25
+ export declare function snapshotLinkExpiry(nowMs: number, ttlMs?: number, bucketMs?: number): number;
26
+ export interface BuildSnapshotLinkInput {
27
+ /** Root-relative plane prefix, e.g. `/addon/snapshot/link`. */
28
+ readonly routePrefix: string;
29
+ readonly deviceId: number;
30
+ /** Snapped ladder rung the bytes will be at, or undefined for the full frame. */
31
+ readonly width: number | undefined;
32
+ /** Capture identity of the frame this link names — the client's cache key. */
33
+ readonly capturedAt: number | null;
34
+ readonly secret: string;
35
+ readonly expMs: number;
36
+ }
37
+ /**
38
+ * The link, as a ROOT-RELATIVE path.
39
+ *
40
+ * Deliberately not absolute. The artifact and HA planes must mint absolute URLs
41
+ * because the fetcher is a phone or a notifier backend that has no idea where
42
+ * the hub is — and picking that base is the `hubUrl: localhost` trap the Alexa
43
+ * work paid for. Here the fetcher is a client that is already connected to the
44
+ * hub and holds its own `serverUrl`, so the correct base is the one it used to
45
+ * make the call. Returning a path makes it impossible to hand a client a link
46
+ * pointing somewhere it cannot reach.
47
+ */
48
+ export declare function buildSnapshotLinkUrl(input: BuildSnapshotLinkInput): string;
49
+ /** A verified link request. `width` is the SIGNED width, never the raw query. */
50
+ export interface ParsedSnapshotLink {
51
+ readonly deviceId: number;
52
+ readonly width: number | undefined;
53
+ }
54
+ export interface ParseSnapshotLinkInput {
55
+ /** Handler-relative url, e.g. `/615.jpg?w=240&exp=…&sig=…`. */
56
+ readonly url: string;
57
+ readonly secret: string;
58
+ readonly nowMs: number;
59
+ }
60
+ /**
61
+ * Parse and VERIFY a link request in one step, so a caller cannot accidentally
62
+ * use the device id before checking the signature. Null = refuse (404/403);
63
+ * there is deliberately no way to distinguish "bad signature" from "expired"
64
+ * from "malformed" at this boundary, so a public route cannot be probed.
65
+ */
66
+ export declare function parseVerifiedSnapshotLink(input: ParseSnapshotLinkInput): ParsedSnapshotLink | null;
@@ -61,6 +61,17 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
61
61
  * strictly-additive delivery path). Disposed on shutdown.
62
62
  */
63
63
  private mediaDataPlane;
64
+ /**
65
+ * The PUBLIC, signature-authenticated twin of the media plane. Null when the
66
+ * host has no `ctx.dataPlane` facility, or before the per-install secret has
67
+ * been minted — in which case `getSnapshotLinks` reports `url: null` and the
68
+ * client keeps using the authenticated plane exactly as before.
69
+ */
70
+ private linkDataPlane;
71
+ /** Per-install HMAC secret for link signatures. Minted once and persisted: a
72
+ * secret that rolled on every boot would invalidate every link a client is
73
+ * currently holding, which is a fleet-wide blank grid after each restart. */
74
+ private linkSecret;
64
75
  /**
65
76
  * Who is being looked at, inferred from data-plane traffic. The keep-warm
66
77
  * loop's entire bound — see `snapshot-keep-warm.ts`.
@@ -92,6 +103,74 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
92
103
  * base64 path.
93
104
  */
94
105
  private serveMediaDataPlane;
106
+ /**
107
+ * Serve the PUBLIC link plane: `GET /addon/snapshot/link/<deviceId>.jpg?w&v&exp&sig`.
108
+ *
109
+ * `access: 'public'` is deliberate and is the only interesting decision here.
110
+ * The authority is the HMAC, exactly as for the notification artifact plane
111
+ * and the Home Assistant media plane — but unlike those, what sits behind it
112
+ * is a LIVE view of the operator's home, so the compensating choices are
113
+ * explicit: a short TTL (`SNAPSHOT_LINK_TTL_MS`), the width inside the signed
114
+ * identity so a leaked tile cannot be escalated to the full frame, and
115
+ * **`force` is not honoured on this route at all** — a public URL must never
116
+ * be able to wake a battery camera.
117
+ *
118
+ * A leaked link therefore grants: one camera, at one thumbnail width, for at
119
+ * most the TTL. No token, no other device, no session.
120
+ */
121
+ private serveLinkDataPlane;
122
+ /**
123
+ * Verify first, then hand the request to the ordinary media handler with a
124
+ * REWRITTEN url carrying only the signed `(deviceId, width)`.
125
+ *
126
+ * Rewriting rather than passing the original through is what stops an unsigned
127
+ * query parameter reaching the capture path: `force`, `streamId` and any other
128
+ * lever the authenticated plane honours are simply not present in what the
129
+ * handler parses.
130
+ */
131
+ private handleLinkRequest;
132
+ private linkRoutePrefix;
133
+ /** Mint-once-and-persist the signing secret. Null if it cannot be stored — a
134
+ * secret held only in memory would break every outstanding link on restart,
135
+ * so no plane is better than a plane whose links die unpredictably. */
136
+ private ensureLinkSecret;
137
+ /**
138
+ * Mint signed links for the tiles a surface is rendering — and, in the same
139
+ * call, make those tiles CURRENT.
140
+ *
141
+ * This is the demand signal the design was missing. Under D93 enrolment rode
142
+ * on the image request, and D93/D94/D95 each fixed a different way for that
143
+ * request not to happen; none of them could fix the last one, because the
144
+ * client's own image cache answers a repeated URL with no network at all. An
145
+ * RPC has no such cache in front of it.
146
+ *
147
+ * So: note every target (creating a subscription, which `getSnapshotOverview`
148
+ * deliberately cannot), then join the ordinary non-forced capture path and
149
+ * wait a BOUNDED time for it. What comes back is the identity of the frame the
150
+ * link will actually serve.
151
+ *
152
+ * The bound matters more than its exact value. A cold fleet is paced by
153
+ * `MAX_CONCURRENT_GRABS`, so sixteen cameras cannot all capture at once and
154
+ * the last of them would hold the whole batch well past any sensible paint
155
+ * budget. Devices that miss the bound return their real, older `capturedAt` —
156
+ * honest rather than blocking — and the client's next poll carries them
157
+ * forward.
158
+ */
159
+ private getSnapshotLinks;
160
+ /**
161
+ * Join the ordinary capture path for one device and wait, bounded, for it.
162
+ *
163
+ * Deliberately `getSnapshot` with `force: false` and nothing else: every gate
164
+ * that already owns this decision — the per-device max-age, the single flight,
165
+ * the grab pool, the sleeping-battery refusal — applies unchanged. A fresh
166
+ * cache returns immediately; a stale one starts the refresh. There is no
167
+ * second freshness policy here, which is the mistake D93 called out.
168
+ */
169
+ private awaitFreshEnough;
170
+ /** True only for a BATTERY device that is currently asleep. Kept as one
171
+ * question so the link path cannot accidentally treat "battery" as "asleep"
172
+ * and stop refreshing a camera that is awake and streaming. */
173
+ private isSleepingBatteryDevice;
95
174
  /**
96
175
  * Resolve a device (+ optional stream, + force) to renderable JPEG bytes for
97
176
  * the HTTP endpoint. Runs the same coalesced capture ladder as the tRPC
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-NRYjb8qK.js");
6
+ const require_dist = require("../../dist-Dhpte164.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs = require("node:fs");
9
9
  node_fs = require_chunk.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { R as filesystemBrowseCapability, pt as BaseAddon, st as storageProviderCapability } from "../../dist-CbS6uoSb.mjs";
1
+ import { R as filesystemBrowseCapability, pt as BaseAddon, st as storageProviderCapability } from "../../dist-D2J8RJ7m.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-NRYjb8qK.js");
6
+ const require_dist = require("../../dist-Dhpte164.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs = require("node:fs");
9
9
  let node_module = require("node:module");
@@ -1,4 +1,4 @@
1
- import { C as bareAddonId, Ct as asJsonObject, O as dataStoreProviderCapability, Pt as parseJsonUnknown, dt as vectorStoreCapability, f as RUNTIME_DEFAULTS, ft as errMsg, k as decodeVectorBase64, pt as BaseAddon, ut as vectorDimFromBase64 } from "../../dist-CbS6uoSb.mjs";
1
+ import { C as bareAddonId, Ct as asJsonObject, O as dataStoreProviderCapability, Pt as parseJsonUnknown, dt as vectorStoreCapability, f as RUNTIME_DEFAULTS, ft as errMsg, k as decodeVectorBase64, pt as BaseAddon, ut as vectorDimFromBase64 } from "../../dist-D2J8RJ7m.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { statSync } from "node:fs";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-NRYjb8qK.js");
6
+ const require_dist = require("../../dist-Dhpte164.js");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_chunk.__toESM(node_path);
9
9
  let node_fs_promises = require("node:fs/promises");
@@ -1,4 +1,4 @@
1
- import { Nt as parseJsonObject, h as StorageLocationTypeSchema, it as settingsStoreCapability, ot as storageCapability, pt as BaseAddon } from "../../dist-CbS6uoSb.mjs";
1
+ import { Nt as parseJsonObject, h as StorageLocationTypeSchema, it as settingsStoreCapability, ot as storageCapability, pt as BaseAddon } from "../../dist-D2J8RJ7m.mjs";
2
2
  import * as path$1 from "node:path";
3
3
  import * as fs from "node:fs/promises";
4
4
  import { buildStorageLocationRegistry } from "@camstack/system";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-NRYjb8qK.js");
6
+ const require_dist = require("../../dist-Dhpte164.js");
7
7
  //#region src/builtins/system-config/system-config.addon.ts
8
8
  /**
9
9
  * Built-in `system-config` addon — Phase 4 of the settings redesign.
@@ -1,4 +1,4 @@
1
- import { At as hydrateSchema, ft as errMsg, pt as BaseAddon } from "../../dist-CbS6uoSb.mjs";
1
+ import { At as hydrateSchema, ft as errMsg, pt as BaseAddon } from "../../dist-D2J8RJ7m.mjs";
2
2
  //#region src/builtins/system-config/system-config.addon.ts
3
3
  /**
4
4
  * Built-in `system-config` addon — Phase 4 of the settings redesign.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-NRYjb8qK.js");
6
+ const require_dist = require("../../dist-Dhpte164.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  let node_path = require("node:path");
9
9
  node_path = require_chunk.__toESM(node_path);
@@ -1,4 +1,4 @@
1
- import { pt as BaseAddon, q as logDestinationCapability } from "../../dist-CbS6uoSb.mjs";
1
+ import { pt as BaseAddon, q as logDestinationCapability } from "../../dist-D2J8RJ7m.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  import * as path$1 from "node:path";
4
4
  import path from "node:path";