@camstack/types 1.2.51 → 1.2.52

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/addon.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_event_category = require("./event-category-BE4PDZ_3.js");
3
- const require_sleep = require("./sleep-ocMLM2o5.js");
3
+ const require_sleep = require("./sleep-DfF6vKCf.js");
4
4
  const require_err_msg = require("./err-msg-COpsHMw2.js");
5
5
  //#region src/generated/collection-array-methods.ts
6
6
  /**
package/dist/addon.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as EventCategory } from "./event-category-41fKf-q9.mjs";
2
- import { C as DeviceType, F as readNodePin, L as ReadinessRegistry, O as expandCapMethods, P as nodePin, R as ReadinessTimeoutError, V as scopeKey, a as asJsonObject, d as DEVICE_SCOPED_CAPS, f as isDeviceScopedCap, gt as normalizeAddonInitResult, ht as BaseAddon, mt as DisposerChain, p as createDeviceProxy, pt as DATAPLANE_SECRET_HEADER, s as asString, t as sleep, u as parseJsonUnknown, v as deviceOpsCapability, w as adminUiCapability, y as viewerUiCapability, yt as emitReadiness } from "./sleep-7WqNZVcL.mjs";
2
+ import { C as DeviceType, F as readNodePin, L as ReadinessRegistry, O as expandCapMethods, P as nodePin, R as ReadinessTimeoutError, V as scopeKey, a as asJsonObject, d as DEVICE_SCOPED_CAPS, f as isDeviceScopedCap, gt as normalizeAddonInitResult, ht as BaseAddon, mt as DisposerChain, p as createDeviceProxy, pt as DATAPLANE_SECRET_HEADER, s as asString, t as sleep, u as parseJsonUnknown, v as deviceOpsCapability, w as adminUiCapability, y as viewerUiCapability, yt as emitReadiness } from "./sleep-CT4lN1ij.mjs";
3
3
  import { t as errMsg } from "./err-msg-IQTHeDzc.mjs";
4
4
  //#region src/generated/collection-array-methods.ts
5
5
  /**
@@ -82,6 +82,62 @@ export declare const snapshotCapability: {
82
82
  }, z.core.$strip>>, import("./capability-definition.js").CapabilityMethodKind> & {
83
83
  readonly systemOnly: true;
84
84
  };
85
+ /**
86
+ * Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
87
+ * that makes those frames current.
88
+ *
89
+ * ## The problem it replaces
90
+ *
91
+ * `getSnapshotOverview` is cache-only by contract: it answers from whatever
92
+ * the wrapper happens to hold and never captures. Under D93 the client
93
+ * versions its image URL on that answer, and an image REQUEST is what enrols
94
+ * a camera in the keep-warm loop. Both of those are satisfiable by the
95
+ * client's own image cache — `expo-image` is URL-keyed and never revalidates
96
+ * — so a URL painted in a previous session comes off disk with no network,
97
+ * no enrolment, and nothing warming. Measured on the live hub: reopening
98
+ * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
99
+ * HTTP requests, and the fleet only recovered because a later poll happened
100
+ * to observe a different identity.
101
+ *
102
+ * ## The two properties that fix it
103
+ *
104
+ * **It is an RPC, so no client cache can answer it.** The demand signal
105
+ * always reaches the wrapper. This method therefore MAY create keep-warm
106
+ * subscriptions, where `getSnapshotOverview` must never (D93) — the
107
+ * distinction is not "one is newer" but that the overview poll is app-wide
108
+ * (a creating overview would warm every camera on the install) while this is
109
+ * called by a rendered surface naming the tiles it is actually painting, at
110
+ * the width it is painting them.
111
+ *
112
+ * **It waits, briefly and boundedly, for the capture it triggered.** The
113
+ * returned `capturedAt` is the frame the link will serve, not the frame the
114
+ * cache held when the client asked, so a first paint is honest and current
115
+ * instead of a generation behind. A device that does not settle inside the
116
+ * bound still gets a link and its real (older) `capturedAt` — the next poll
117
+ * carries it forward.
118
+ *
119
+ * `force` is never set on behalf of a client here. A sleeping battery camera
120
+ * is reported with `sleeping: true` and the last frame it produced, however
121
+ * old; the wrapper's existing sleep gate owns that decision and this method
122
+ * adds no second one.
123
+ */
124
+ readonly getSnapshotLinks: import("./capability-definition.js").CapabilityMethodSchema<z.ZodObject<{
125
+ targets: z.ZodArray<z.ZodObject<{
126
+ deviceId: z.ZodNumber;
127
+ width: z.ZodOptional<z.ZodNumber>;
128
+ }, z.core.$strip>>;
129
+ }, z.core.$strip>, z.ZodArray<z.ZodObject<{
130
+ deviceId: z.ZodNumber;
131
+ url: z.ZodNullable<z.ZodString>;
132
+ capturedAt: z.ZodNullable<z.ZodNumber>;
133
+ ageMs: z.ZodNullable<z.ZodNumber>;
134
+ expiresAt: z.ZodNullable<z.ZodNumber>;
135
+ width: z.ZodNullable<z.ZodNumber>;
136
+ neverCaptured: z.ZodBoolean;
137
+ sleeping: z.ZodBoolean;
138
+ }, z.core.$strip>>, import("./capability-definition.js").CapabilityMethodKind> & {
139
+ readonly systemOnly: true;
140
+ };
85
141
  };
86
142
  readonly status: {
87
143
  readonly schema: z.ZodObject<{
@@ -6575,6 +6575,13 @@ export type AppRouter = TrpcCoreRouter<{
6575
6575
  output: z.infer<typeof snapshotCapability.methods.getSnapshotOverview.output>;
6576
6576
  meta: object;
6577
6577
  }>;
6578
+ getSnapshotLinks: TRPCQueryProcedure<{
6579
+ input: {
6580
+ [x: string]: unknown;
6581
+ } & z.input<typeof snapshotCapability.methods.getSnapshotLinks.input>;
6582
+ output: z.infer<typeof snapshotCapability.methods.getSnapshotLinks.output>;
6583
+ meta: object;
6584
+ }>;
6578
6585
  }>>;
6579
6586
  ssoBridge: TRPCBuiltRouter<{
6580
6587
  ctx: TrpcContext;
@@ -6,7 +6,7 @@
6
6
  * scope+access check inside `protectedProcedure` (see
7
7
  * `server/backend/src/api/trpc/trpc.middleware.ts`).
8
8
  *
9
- * Coverage: 900 method paths across 121 capabilities.
9
+ * Coverage: 901 method paths across 121 capabilities.
10
10
  */
11
11
  import type { CapabilityMethodAccess } from '../capabilities/capability-definition.js';
12
12
  export interface MethodAccessRecord {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_event_category = require("./event-category-BE4PDZ_3.js");
3
- const require_sleep = require("./sleep-ocMLM2o5.js");
3
+ const require_sleep = require("./sleep-DfF6vKCf.js");
4
4
  const require_canonical_hash = require("./canonical-hash-DNV8S5ET.js");
5
5
  const require_enums = require("./enums.js");
6
6
  const require_err_msg = require("./err-msg-COpsHMw2.js");
@@ -17308,6 +17308,77 @@ var snapshotCapability = {
17308
17308
  lastCapturedAt: zod.z.number().nullable(),
17309
17309
  cacheAgeMs: zod.z.number().nullable(),
17310
17310
  etag: zod.z.string().nullable()
17311
+ }))),
17312
+ /**
17313
+ * Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
17314
+ * that makes those frames current.
17315
+ *
17316
+ * ## The problem it replaces
17317
+ *
17318
+ * `getSnapshotOverview` is cache-only by contract: it answers from whatever
17319
+ * the wrapper happens to hold and never captures. Under D93 the client
17320
+ * versions its image URL on that answer, and an image REQUEST is what enrols
17321
+ * a camera in the keep-warm loop. Both of those are satisfiable by the
17322
+ * client's own image cache — `expo-image` is URL-keyed and never revalidates
17323
+ * — so a URL painted in a previous session comes off disk with no network,
17324
+ * no enrolment, and nothing warming. Measured on the live hub: reopening
17325
+ * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
17326
+ * HTTP requests, and the fleet only recovered because a later poll happened
17327
+ * to observe a different identity.
17328
+ *
17329
+ * ## The two properties that fix it
17330
+ *
17331
+ * **It is an RPC, so no client cache can answer it.** The demand signal
17332
+ * always reaches the wrapper. This method therefore MAY create keep-warm
17333
+ * subscriptions, where `getSnapshotOverview` must never (D93) — the
17334
+ * distinction is not "one is newer" but that the overview poll is app-wide
17335
+ * (a creating overview would warm every camera on the install) while this is
17336
+ * called by a rendered surface naming the tiles it is actually painting, at
17337
+ * the width it is painting them.
17338
+ *
17339
+ * **It waits, briefly and boundedly, for the capture it triggered.** The
17340
+ * returned `capturedAt` is the frame the link will serve, not the frame the
17341
+ * cache held when the client asked, so a first paint is honest and current
17342
+ * instead of a generation behind. A device that does not settle inside the
17343
+ * bound still gets a link and its real (older) `capturedAt` — the next poll
17344
+ * carries it forward.
17345
+ *
17346
+ * `force` is never set on behalf of a client here. A sleeping battery camera
17347
+ * is reported with `sleeping: true` and the last frame it produced, however
17348
+ * old; the wrapper's existing sleep gate owns that decision and this method
17349
+ * adds no second one.
17350
+ */
17351
+ getSnapshotLinks: require_sleep.systemMethod(zod.z.object({
17352
+ /** The tiles a surface is actually rendering. One entry per (device,
17353
+ * width) the caller will paint — the width is snapped to the server's
17354
+ * ladder and becomes part of the link's SIGNED identity. */
17355
+ targets: zod.z.array(zod.z.object({
17356
+ deviceId: zod.z.number(),
17357
+ /** Target width in px. Omit for the frame as captured — correct
17358
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
17359
+ width: zod.z.number().int().positive().optional()
17360
+ })).min(1).max(200) }), zod.z.array(zod.z.object({
17361
+ deviceId: zod.z.number(),
17362
+ /** Root-relative signed path, or null when the link plane is not
17363
+ * served (no data-plane facility). Present even for a device that has
17364
+ * never captured — the request is what triggers the first one (D94). */
17365
+ url: zod.z.string().nullable(),
17366
+ /** Epoch ms of the frame this link serves. Null = never captured.
17367
+ * THE honest age: the tRPC path carried none before this. */
17368
+ capturedAt: zod.z.number().nullable(),
17369
+ /** Age of that frame at the moment the answer was built. */
17370
+ ageMs: zod.z.number().nullable(),
17371
+ /** Epoch ms after which `url` stops verifying. */
17372
+ expiresAt: zod.z.number().nullable(),
17373
+ /** Ladder rung the bytes are at; null = the frame as captured. */
17374
+ width: zod.z.number().nullable(),
17375
+ /** The device has never produced a frame. An empty state, not a
17376
+ * failure — and never a reason to withhold the link (D94). */
17377
+ neverCaptured: zod.z.boolean(),
17378
+ /** A sleeping battery camera: the frame is deliberately stale and will
17379
+ * NOT refresh in the background. A surface should say so rather than
17380
+ * present it as current. */
17381
+ sleeping: zod.z.boolean()
17311
17382
  })))
17312
17383
  },
17313
17384
  status: {
@@ -34981,6 +35052,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
34981
35052
  addonId: null,
34982
35053
  access: "view"
34983
35054
  },
35055
+ "snapshot.getSnapshotLinks": {
35056
+ capName: "snapshot",
35057
+ capScope: "device",
35058
+ addonId: null,
35059
+ access: "view"
35060
+ },
34984
35061
  "snapshot.getSnapshotOverview": {
34985
35062
  capName: "snapshot",
34986
35063
  capScope: "device",
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as EventCategory } from "./event-category-41fKf-q9.mjs";
2
- import { $ as FrameHandleSchema, A as method, B as readinessKey, C as DeviceType, Ct as collectHydratedFieldEntries, D as event, E as DEVICE_STATUS_METHOD, Et as resolveHydratedFieldValue, F as readNodePin, G as CamProfileSchema, H as BrokerStatsSchema, I as toNodeId, J as CameraStreamSchema, K as CamStreamKindSchema, L as ReadinessRegistry, M as systemMethod, N as CAP_NODE_PIN_CONTEXT_KEY, O as expandCapMethods, P as nodePin, Q as FrameHandleFormatSchema, R as ReadinessTimeoutError, S as DeviceRole, St as WELL_KNOWN_TAB_MAP, T as DEVICE_SETTINGS_CONTRIBUTION_METHODS, Tt as hydrateSchema, U as BrokerStatusSchema, V as scopeKey, W as CAM_PROFILE_ORDER, X as DecodedFrameSchema, Y as DecodedAudioChunkSchema, Z as EncodedPacketSchema, _ as RawStateResultSchema, _t as createDurableState, a as asJsonObject, at as SubscribeAudioChunksInputSchema, b as ChargingStatus, bt as isEvent, c as parseJsonArray, ct as SubscribeFramesResultSchema, d as DEVICE_SCOPED_CAPS, dt as parseProfileBrokerId, et as ProfileRtspEntrySchema, f as isDeviceScopedCap, ft as selectAssignedProfileSlots, g as createSliceHandle, gt as normalizeAddonInitResult, h as createMirrorSource, ht as BaseAddon, i as asJsonArray, it as StreamSourceSchema, j as resolveCapMount, k as isDeviceConfigCap, l as parseJsonObject, lt as makeProfileBrokerId, m as createLazyTrpcSource, mt as DisposerChain, n as sleepCancellable, nt as ProfileSlotStatusSchema, o as asNumber, ot as SubscribeAudioChunksResultSchema, p as createDeviceProxy, pt as DATAPLANE_SECRET_HEADER, q as CamStreamResolutionSchema, r as asBoolean, rt as StreamSourceEntrySchema, s as asString, st as SubscribeFramesInputSchema, t as sleep, tt as ProfileSlotSchema, u as parseJsonUnknown, ut as makeSourceBrokerId, v as deviceOpsCapability, vt as createEvent, w as adminUiCapability, wt as collectHydratedFieldValues, x as DeviceFeature, xt as WELL_KNOWN_TABS, y as viewerUiCapability, yt as emitReadiness, z as emitDownForOwnedCaps } from "./sleep-7WqNZVcL.mjs";
2
+ import { $ as FrameHandleSchema, A as method, B as readinessKey, C as DeviceType, Ct as collectHydratedFieldEntries, D as event, E as DEVICE_STATUS_METHOD, Et as resolveHydratedFieldValue, F as readNodePin, G as CamProfileSchema, H as BrokerStatsSchema, I as toNodeId, J as CameraStreamSchema, K as CamStreamKindSchema, L as ReadinessRegistry, M as systemMethod, N as CAP_NODE_PIN_CONTEXT_KEY, O as expandCapMethods, P as nodePin, Q as FrameHandleFormatSchema, R as ReadinessTimeoutError, S as DeviceRole, St as WELL_KNOWN_TAB_MAP, T as DEVICE_SETTINGS_CONTRIBUTION_METHODS, Tt as hydrateSchema, U as BrokerStatusSchema, V as scopeKey, W as CAM_PROFILE_ORDER, X as DecodedFrameSchema, Y as DecodedAudioChunkSchema, Z as EncodedPacketSchema, _ as RawStateResultSchema, _t as createDurableState, a as asJsonObject, at as SubscribeAudioChunksInputSchema, b as ChargingStatus, bt as isEvent, c as parseJsonArray, ct as SubscribeFramesResultSchema, d as DEVICE_SCOPED_CAPS, dt as parseProfileBrokerId, et as ProfileRtspEntrySchema, f as isDeviceScopedCap, ft as selectAssignedProfileSlots, g as createSliceHandle, gt as normalizeAddonInitResult, h as createMirrorSource, ht as BaseAddon, i as asJsonArray, it as StreamSourceSchema, j as resolveCapMount, k as isDeviceConfigCap, l as parseJsonObject, lt as makeProfileBrokerId, m as createLazyTrpcSource, mt as DisposerChain, n as sleepCancellable, nt as ProfileSlotStatusSchema, o as asNumber, ot as SubscribeAudioChunksResultSchema, p as createDeviceProxy, pt as DATAPLANE_SECRET_HEADER, q as CamStreamResolutionSchema, r as asBoolean, rt as StreamSourceEntrySchema, s as asString, st as SubscribeFramesInputSchema, t as sleep, tt as ProfileSlotSchema, u as parseJsonUnknown, ut as makeSourceBrokerId, v as deviceOpsCapability, vt as createEvent, w as adminUiCapability, wt as collectHydratedFieldValues, x as DeviceFeature, xt as WELL_KNOWN_TABS, y as viewerUiCapability, yt as emitReadiness, z as emitDownForOwnedCaps } from "./sleep-CT4lN1ij.mjs";
3
3
  import { a as buildAudioArgs, c as buildVideoArgs, d as logBannerArgs, f as pickVideoEncoder, i as audioPlanFromEncodeProfile, l as invocationFromEncodeProfile, n as Fmp4BoxSplitter, o as buildFfmpegArgs, r as AUDIO_PRESETS, s as buildInputArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-rO1sRmEK.mjs";
4
4
  import { EventSourceType } from "./enums.mjs";
5
5
  import { t as errMsg } from "./err-msg-IQTHeDzc.mjs";
@@ -17307,6 +17307,77 @@ var snapshotCapability = {
17307
17307
  lastCapturedAt: z.number().nullable(),
17308
17308
  cacheAgeMs: z.number().nullable(),
17309
17309
  etag: z.string().nullable()
17310
+ }))),
17311
+ /**
17312
+ * Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
17313
+ * that makes those frames current.
17314
+ *
17315
+ * ## The problem it replaces
17316
+ *
17317
+ * `getSnapshotOverview` is cache-only by contract: it answers from whatever
17318
+ * the wrapper happens to hold and never captures. Under D93 the client
17319
+ * versions its image URL on that answer, and an image REQUEST is what enrols
17320
+ * a camera in the keep-warm loop. Both of those are satisfiable by the
17321
+ * client's own image cache — `expo-image` is URL-keyed and never revalidates
17322
+ * — so a URL painted in a previous session comes off disk with no network,
17323
+ * no enrolment, and nothing warming. Measured on the live hub: reopening
17324
+ * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
17325
+ * HTTP requests, and the fleet only recovered because a later poll happened
17326
+ * to observe a different identity.
17327
+ *
17328
+ * ## The two properties that fix it
17329
+ *
17330
+ * **It is an RPC, so no client cache can answer it.** The demand signal
17331
+ * always reaches the wrapper. This method therefore MAY create keep-warm
17332
+ * subscriptions, where `getSnapshotOverview` must never (D93) — the
17333
+ * distinction is not "one is newer" but that the overview poll is app-wide
17334
+ * (a creating overview would warm every camera on the install) while this is
17335
+ * called by a rendered surface naming the tiles it is actually painting, at
17336
+ * the width it is painting them.
17337
+ *
17338
+ * **It waits, briefly and boundedly, for the capture it triggered.** The
17339
+ * returned `capturedAt` is the frame the link will serve, not the frame the
17340
+ * cache held when the client asked, so a first paint is honest and current
17341
+ * instead of a generation behind. A device that does not settle inside the
17342
+ * bound still gets a link and its real (older) `capturedAt` — the next poll
17343
+ * carries it forward.
17344
+ *
17345
+ * `force` is never set on behalf of a client here. A sleeping battery camera
17346
+ * is reported with `sleeping: true` and the last frame it produced, however
17347
+ * old; the wrapper's existing sleep gate owns that decision and this method
17348
+ * adds no second one.
17349
+ */
17350
+ getSnapshotLinks: systemMethod(z.object({
17351
+ /** The tiles a surface is actually rendering. One entry per (device,
17352
+ * width) the caller will paint — the width is snapped to the server's
17353
+ * ladder and becomes part of the link's SIGNED identity. */
17354
+ targets: z.array(z.object({
17355
+ deviceId: z.number(),
17356
+ /** Target width in px. Omit for the frame as captured — correct
17357
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
17358
+ width: z.number().int().positive().optional()
17359
+ })).min(1).max(200) }), z.array(z.object({
17360
+ deviceId: z.number(),
17361
+ /** Root-relative signed path, or null when the link plane is not
17362
+ * served (no data-plane facility). Present even for a device that has
17363
+ * never captured — the request is what triggers the first one (D94). */
17364
+ url: z.string().nullable(),
17365
+ /** Epoch ms of the frame this link serves. Null = never captured.
17366
+ * THE honest age: the tRPC path carried none before this. */
17367
+ capturedAt: z.number().nullable(),
17368
+ /** Age of that frame at the moment the answer was built. */
17369
+ ageMs: z.number().nullable(),
17370
+ /** Epoch ms after which `url` stops verifying. */
17371
+ expiresAt: z.number().nullable(),
17372
+ /** Ladder rung the bytes are at; null = the frame as captured. */
17373
+ width: z.number().nullable(),
17374
+ /** The device has never produced a frame. An empty state, not a
17375
+ * failure — and never a reason to withhold the link (D94). */
17376
+ neverCaptured: z.boolean(),
17377
+ /** A sleeping battery camera: the frame is deliberately stale and will
17378
+ * NOT refresh in the background. A surface should say so rather than
17379
+ * present it as current. */
17380
+ sleeping: z.boolean()
17310
17381
  })))
17311
17382
  },
17312
17383
  status: {
@@ -34980,6 +35051,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
34980
35051
  addonId: null,
34981
35052
  access: "view"
34982
35053
  },
35054
+ "snapshot.getSnapshotLinks": {
35055
+ capName: "snapshot",
35056
+ capScope: "device",
35057
+ addonId: null,
35058
+ access: "view"
35059
+ },
34983
35060
  "snapshot.getSnapshotOverview": {
34984
35061
  capName: "snapshot",
34985
35062
  capScope: "device",
package/dist/node.d.ts CHANGED
@@ -4,6 +4,8 @@ export { ensureFfmpeg, getFfmpegDownloadUrl } from './deps/ffmpeg-downloader.js'
4
4
  export { ensurePython, installPythonPackages, installPythonRequirements, getPythonDownloadUrl, PYTHON_VERSION, } from './deps/python-downloader.js';
5
5
  export { FilesystemStorageProvider } from './storage/filesystem-storage-provider.js';
6
6
  export { canonicalHash } from './utils/canonical-hash.js';
7
+ export { signExpiringUrl, verifyExpiringUrl } from './utils/expiring-url-signature.js';
8
+ export type { VerifyExpiringUrlInput } from './utils/expiring-url-signature.js';
7
9
  export { canonicalDeviceFingerprint, diffExportTargets, resolveExportFingerprint, } from './utils/export-reconciler.js';
8
10
  export type { DeviceExportShape, ExportDelta, ExportTargetEntry, } from './utils/export-reconciler.js';
9
11
  export { FfmpegProcess } from './ffmpeg/process.js';
package/dist/node.js CHANGED
@@ -601,6 +601,61 @@ var FilesystemStorageProvider = class {
601
601
  }
602
602
  };
603
603
  //#endregion
604
+ //#region src/utils/expiring-url-signature.ts
605
+ /**
606
+ * The ONE derivation of "a signed, expiring URL".
607
+ *
608
+ * This repo mints unguessable, self-expiring links in three places now — the
609
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
610
+ * link plane. The first two grew independently and are byte-identical logic
611
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
612
+ * each restating the crypto locally because **addons never import each other**.
613
+ *
614
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
615
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
616
+ * different compare, or a missing expiry check, and nothing fails until a link
617
+ * that should have died keeps working. The fix is the same one D52 applies to
618
+ * crop geometry: one derivation, in a place every addon may depend on. A
619
+ * framework package is exactly that place — `@camstack/types/node`, off the root
620
+ * entry because `node:crypto` must never be traversed by a browser bundler.
621
+ *
622
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
623
+ * shape of `id`, and the access level of the route. Those are per-plane policy
624
+ * and each caller states them where a reader can see them.
625
+ */
626
+ /**
627
+ * The signature over `(id, expMs)`.
628
+ *
629
+ * `id` is whatever the plane uses to name the thing being served — an artifact
630
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
631
+ * must not put a `:` inside a field whose boundary matters; where a plane has
632
+ * more than one field, it composes them itself and owns that ambiguity.
633
+ */
634
+ function signExpiringUrl(secret, id, expMs) {
635
+ return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
636
+ }
637
+ /**
638
+ * Verify a request's `(id, exp, sig)`.
639
+ *
640
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
641
+ * whether or not its signature is valid — a leaked URL stops working on its own
642
+ * and cannot be kept alive by holding a correct signature. The compare itself is
643
+ * constant-time so a public route cannot be probed for the signature byte by
644
+ * byte.
645
+ */
646
+ function verifyExpiringUrl(input) {
647
+ const { secret, id, exp, sig, nowMs } = input;
648
+ if (exp === void 0 || sig === void 0) return false;
649
+ const expMs = typeof exp === "number" ? exp : Number(exp);
650
+ if (!Number.isFinite(expMs)) return false;
651
+ if (expMs <= nowMs) return false;
652
+ const expected = signExpiringUrl(secret, id, expMs);
653
+ const a = Buffer.from(expected, "utf8");
654
+ const b = Buffer.from(sig, "utf8");
655
+ if (a.length !== b.length) return false;
656
+ return (0, node_crypto.timingSafeEqual)(a, b);
657
+ }
658
+ //#endregion
604
659
  //#region src/utils/export-reconciler.ts
605
660
  /**
606
661
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
@@ -1446,3 +1501,5 @@ exports.getPythonDownloadUrl = getPythonDownloadUrl;
1446
1501
  exports.installPythonPackages = installPythonPackages;
1447
1502
  exports.installPythonRequirements = installPythonRequirements;
1448
1503
  exports.resolveExportFingerprint = resolveExportFingerprint;
1504
+ exports.signExpiringUrl = signExpiringUrl;
1505
+ exports.verifyExpiringUrl = verifyExpiringUrl;
package/dist/node.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { n as Fmp4BoxSplitter, o as buildFfmpegArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-rO1sRmEK.mjs";
2
2
  import { t as errMsg } from "./err-msg-IQTHeDzc.mjs";
3
- import { createHash, randomUUID } from "node:crypto";
3
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import * as fs from "node:fs";
5
5
  import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import * as path from "node:path";
@@ -578,6 +578,61 @@ var FilesystemStorageProvider = class {
578
578
  }
579
579
  };
580
580
  //#endregion
581
+ //#region src/utils/expiring-url-signature.ts
582
+ /**
583
+ * The ONE derivation of "a signed, expiring URL".
584
+ *
585
+ * This repo mints unguessable, self-expiring links in three places now — the
586
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
587
+ * link plane. The first two grew independently and are byte-identical logic
588
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
589
+ * each restating the crypto locally because **addons never import each other**.
590
+ *
591
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
592
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
593
+ * different compare, or a missing expiry check, and nothing fails until a link
594
+ * that should have died keeps working. The fix is the same one D52 applies to
595
+ * crop geometry: one derivation, in a place every addon may depend on. A
596
+ * framework package is exactly that place — `@camstack/types/node`, off the root
597
+ * entry because `node:crypto` must never be traversed by a browser bundler.
598
+ *
599
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
600
+ * shape of `id`, and the access level of the route. Those are per-plane policy
601
+ * and each caller states them where a reader can see them.
602
+ */
603
+ /**
604
+ * The signature over `(id, expMs)`.
605
+ *
606
+ * `id` is whatever the plane uses to name the thing being served — an artifact
607
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
608
+ * must not put a `:` inside a field whose boundary matters; where a plane has
609
+ * more than one field, it composes them itself and owns that ambiguity.
610
+ */
611
+ function signExpiringUrl(secret, id, expMs) {
612
+ return createHmac("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
613
+ }
614
+ /**
615
+ * Verify a request's `(id, exp, sig)`.
616
+ *
617
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
618
+ * whether or not its signature is valid — a leaked URL stops working on its own
619
+ * and cannot be kept alive by holding a correct signature. The compare itself is
620
+ * constant-time so a public route cannot be probed for the signature byte by
621
+ * byte.
622
+ */
623
+ function verifyExpiringUrl(input) {
624
+ const { secret, id, exp, sig, nowMs } = input;
625
+ if (exp === void 0 || sig === void 0) return false;
626
+ const expMs = typeof exp === "number" ? exp : Number(exp);
627
+ if (!Number.isFinite(expMs)) return false;
628
+ if (expMs <= nowMs) return false;
629
+ const expected = signExpiringUrl(secret, id, expMs);
630
+ const a = Buffer.from(expected, "utf8");
631
+ const b = Buffer.from(sig, "utf8");
632
+ if (a.length !== b.length) return false;
633
+ return timingSafeEqual(a, b);
634
+ }
635
+ //#endregion
581
636
  //#region src/utils/export-reconciler.ts
582
637
  /**
583
638
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
@@ -1403,4 +1458,4 @@ var Fmp4FragmentChild = class {
1403
1458
  }
1404
1459
  };
1405
1460
  //#endregion
1406
- export { FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, resolveExportFingerprint };
1461
+ export { FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
@@ -3279,6 +3279,7 @@ function createDeviceProxy(api, binding, opts) {
3279
3279
  getSnapshot: (input) => dispatch("snapshot", "snapshot", "getSnapshot", "query", input),
3280
3280
  invalidateCache: (input) => dispatch("snapshot", "snapshot", "invalidateCache", "mutation", input),
3281
3281
  getSnapshotOverview: (input) => dispatch("snapshot", "snapshot", "getSnapshotOverview", "query", input),
3282
+ getSnapshotLinks: (input) => dispatch("snapshot", "snapshot", "getSnapshotLinks", "query", input),
3282
3283
  getStatus: (input) => dispatch("snapshot", "snapshot", "getStatus", "query", input),
3283
3284
  getDeviceSettingsContribution: (input) => dispatch("snapshot", "snapshot", "getDeviceSettingsContribution", "query", input),
3284
3285
  getDeviceLiveContribution: (input) => dispatch("snapshot", "snapshot", "getDeviceLiveContribution", "query", input),
@@ -3279,6 +3279,7 @@ function createDeviceProxy(api, binding, opts) {
3279
3279
  getSnapshot: (input) => dispatch("snapshot", "snapshot", "getSnapshot", "query", input),
3280
3280
  invalidateCache: (input) => dispatch("snapshot", "snapshot", "invalidateCache", "mutation", input),
3281
3281
  getSnapshotOverview: (input) => dispatch("snapshot", "snapshot", "getSnapshotOverview", "query", input),
3282
+ getSnapshotLinks: (input) => dispatch("snapshot", "snapshot", "getSnapshotLinks", "query", input),
3282
3283
  getStatus: (input) => dispatch("snapshot", "snapshot", "getStatus", "query", input),
3283
3284
  getDeviceSettingsContribution: (input) => dispatch("snapshot", "snapshot", "getDeviceSettingsContribution", "query", input),
3284
3285
  getDeviceLiveContribution: (input) => dispatch("snapshot", "snapshot", "getDeviceLiveContribution", "query", input),
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The signature over `(id, expMs)`.
3
+ *
4
+ * `id` is whatever the plane uses to name the thing being served — an artifact
5
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
6
+ * must not put a `:` inside a field whose boundary matters; where a plane has
7
+ * more than one field, it composes them itself and owns that ambiguity.
8
+ */
9
+ export declare function signExpiringUrl(secret: string, id: string, expMs: number): string;
10
+ export interface VerifyExpiringUrlInput {
11
+ readonly secret: string;
12
+ readonly id: string;
13
+ /** Raw query value — a string off the wire, or already a number. */
14
+ readonly exp: string | number | undefined;
15
+ readonly sig: string | undefined;
16
+ readonly nowMs: number;
17
+ }
18
+ /**
19
+ * Verify a request's `(id, exp, sig)`.
20
+ *
21
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
22
+ * whether or not its signature is valid — a leaked URL stops working on its own
23
+ * and cannot be kept alive by holding a correct signature. The compare itself is
24
+ * constant-time so a public route cannot be probed for the signature byte by
25
+ * byte.
26
+ */
27
+ export declare function verifyExpiringUrl(input: VerifyExpiringUrlInput): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.2.51",
3
+ "version": "1.2.52",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",