@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.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +339 -2
- package/dist/builtins/snapshot/index.mjs +339 -2
- package/dist/builtins/snapshot/snapshot-link-url.d.ts +66 -0
- package/dist/builtins/snapshot/snapshot.addon.d.ts +79 -0
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-CbS6uoSb.mjs → dist-D2J8RJ7m.mjs} +77 -0
- package/dist/{dist-NRYjb8qK.js → dist-Dhpte164.js} +77 -0
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- 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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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";
|