@camstack/types 1.2.51 → 1.2.53

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.
@@ -0,0 +1,113 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Adoption job — the background form of `device-adoption.adopt`.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * `adopt({childNativeIds: [...]})` materialises one CamStack device per
8
+ * candidate PLUS every accessory child, and the whole array shares ONE UDS
9
+ * request deadline (60s). Measured on the live hub against Home Assistant:
10
+ * each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
11
+ * meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
12
+ * `setRole`, … `persistConfig`) before the class is constructed — and an
13
+ * accessory child costs the same as its parent. So the real unit of work is
14
+ * the CHILD, not the candidate:
15
+ *
16
+ * - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
17
+ * - ONE candidate with 217 children → ~217 devices → **>60s, times out**
18
+ *
19
+ * That second line is why this is a job and not a smaller batch. No chunking,
20
+ * no bounded concurrency over candidates and no per-call tuning can fix a
21
+ * shape where **N=1 already exceeds the deadline** — the count that blows the
22
+ * budget is the source system's accessory fan-out, which the operator does not
23
+ * choose and cannot see. A design that only works below some N is the same bug
24
+ * deferred.
25
+ *
26
+ * ## What the timeout did NOT do
27
+ *
28
+ * It did not stop the work. The UDS deadline ends the CALLER's wait; the
29
+ * provider's loop runs to completion. Measured: a 25-candidate adopt that
30
+ * "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
31
+ * an error and had no way to learn that. Every field below exists so that
32
+ * question has an answer.
33
+ *
34
+ * ## Idempotency
35
+ *
36
+ * Jobs are in-RAM; a restart forgets them. That is safe here because adoption
37
+ * is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
38
+ * re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
39
+ * by the engine before any provider call and lands in `alreadyAdopted`. It is
40
+ * never a duplicate device, and never an error the operator has to interpret.
41
+ */
42
+ export declare const AdoptionJobStateSchema: z.ZodEnum<{
43
+ done: "done";
44
+ failed: "failed";
45
+ running: "running";
46
+ cancelled: "cancelled";
47
+ }>;
48
+ export type AdoptionJobState = z.infer<typeof AdoptionJobStateSchema>;
49
+ /**
50
+ * Per-candidate result. Every candidate the job was asked to adopt ends in
51
+ * exactly one of these buckets — there is no silent drop, and the operator can
52
+ * always answer "which of my 25 landed?".
53
+ *
54
+ * - `adopted` — created now by this job.
55
+ * - `already-adopted` — a device for this candidate existed before the job
56
+ * reached it (a re-run, or a retry after a timeout). Not an error.
57
+ * - `failed` — the provider threw; `error` carries the message.
58
+ * - `cancelled` — the operator cancelled before this candidate was reached.
59
+ */
60
+ export declare const AdoptionOutcomeSchema: z.ZodEnum<{
61
+ failed: "failed";
62
+ cancelled: "cancelled";
63
+ adopted: "adopted";
64
+ "already-adopted": "already-adopted";
65
+ }>;
66
+ export type AdoptionOutcome = z.infer<typeof AdoptionOutcomeSchema>;
67
+ export declare const AdoptionCandidateResultSchema: z.ZodObject<{
68
+ childNativeId: z.ZodString;
69
+ outcome: z.ZodEnum<{
70
+ failed: "failed";
71
+ cancelled: "cancelled";
72
+ adopted: "adopted";
73
+ "already-adopted": "already-adopted";
74
+ }>;
75
+ parentDeviceId: z.ZodNullable<z.ZodNumber>;
76
+ accessoryCount: z.ZodNumber;
77
+ error: z.ZodNullable<z.ZodString>;
78
+ }, z.core.$strip>;
79
+ export type AdoptionCandidateResult = z.infer<typeof AdoptionCandidateResultSchema>;
80
+ export declare const AdoptionJobSchema: z.ZodObject<{
81
+ jobId: z.ZodString;
82
+ addonId: z.ZodString;
83
+ integrationId: z.ZodString;
84
+ state: z.ZodEnum<{
85
+ done: "done";
86
+ failed: "failed";
87
+ running: "running";
88
+ cancelled: "cancelled";
89
+ }>;
90
+ total: z.ZodNumber;
91
+ processed: z.ZodNumber;
92
+ adopted: z.ZodNumber;
93
+ alreadyAdopted: z.ZodNumber;
94
+ failed: z.ZodNumber;
95
+ accessoriesCreated: z.ZodNumber;
96
+ currentChildNativeId: z.ZodNullable<z.ZodString>;
97
+ results: z.ZodReadonly<z.ZodArray<z.ZodObject<{
98
+ childNativeId: z.ZodString;
99
+ outcome: z.ZodEnum<{
100
+ failed: "failed";
101
+ cancelled: "cancelled";
102
+ adopted: "adopted";
103
+ "already-adopted": "already-adopted";
104
+ }>;
105
+ parentDeviceId: z.ZodNullable<z.ZodNumber>;
106
+ accessoryCount: z.ZodNumber;
107
+ error: z.ZodNullable<z.ZodString>;
108
+ }, z.core.$strip>>>;
109
+ startedAt: z.ZodNumber;
110
+ finishedAt: z.ZodNullable<z.ZodNumber>;
111
+ error: z.ZodNullable<z.ZodString>;
112
+ }, z.core.$strip>;
113
+ export type AdoptionJob = z.infer<typeof AdoptionJobSchema>;
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.53",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",