@camstack/system 1.2.62 → 1.2.63

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 (83) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/core-blocks/block-package.d.ts +9 -27
  14. package/dist/builtins/core-blocks/block-supervisor.d.ts +15 -0
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +91 -24
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +91 -25
  17. package/dist/builtins/device-manager/device-bindings-store.d.ts +28 -5
  18. package/dist/builtins/device-manager/device-linked-devices.d.ts +2 -2
  19. package/dist/builtins/device-manager/device-manager.addon.d.ts +3 -45
  20. package/dist/builtins/device-manager/device-manager.addon.js +123 -884
  21. package/dist/builtins/device-manager/device-manager.addon.mjs +123 -884
  22. package/dist/builtins/device-manager/device-meta-actions.d.ts +0 -8
  23. package/dist/builtins/device-manager/device-meta-store.d.ts +1 -28
  24. package/dist/builtins/device-manager/device-meta-types.d.ts +2 -6
  25. package/dist/builtins/device-manager/device-projection.d.ts +1 -2
  26. package/dist/builtins/device-manager/device-provider-context.d.ts +2 -13
  27. package/dist/builtins/device-manager/device-queries.d.ts +1 -1
  28. package/dist/builtins/device-manager/device-state-mirror.d.ts +8 -49
  29. package/dist/builtins/device-manager/device-sync-resolvers.d.ts +0 -11
  30. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  31. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  32. package/dist/builtins/hub-forwarder/index.js +1 -1
  33. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  34. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  35. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  36. package/dist/builtins/local-auth/homeassistant-integration.d.ts +7 -0
  37. package/dist/builtins/local-auth/index.d.ts +1 -0
  38. package/dist/builtins/local-auth/index.js +1 -0
  39. package/dist/builtins/local-auth/index.mjs +2 -2
  40. package/dist/builtins/local-auth/local-auth.addon.js +561 -497
  41. package/dist/builtins/local-auth/local-auth.addon.mjs +580 -517
  42. package/dist/builtins/local-auth/oauth-grants.d.ts +11 -0
  43. package/dist/builtins/local-auth/oauth-session-manager.d.ts +1 -1
  44. package/dist/builtins/local-network/local-network.addon.js +1 -1
  45. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  46. package/dist/builtins/loki-logging/index.js +1 -1
  47. package/dist/builtins/loki-logging/index.mjs +1 -1
  48. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  49. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  50. package/dist/builtins/platform-probe/index.js +1 -1
  51. package/dist/builtins/platform-probe/index.mjs +1 -1
  52. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  53. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  54. package/dist/builtins/snapshot/index.js +319 -20
  55. package/dist/builtins/snapshot/index.mjs +319 -20
  56. package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +86 -0
  57. package/dist/builtins/snapshot/snapshot-media-handler.d.ts +48 -1
  58. package/dist/builtins/snapshot/snapshot.addon.d.ts +65 -1
  59. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  60. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  61. package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +15 -0
  62. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +45 -5
  63. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +45 -5
  64. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  65. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  66. package/dist/builtins/system-config/system-config.addon.js +1 -1
  67. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  68. package/dist/builtins/winston-logging/index.js +1 -1
  69. package/dist/builtins/winston-logging/index.mjs +1 -1
  70. package/dist/{dist-DnKpFaP1.mjs → dist-BJ2w3uoN.mjs} +1188 -1329
  71. package/dist/{dist-DgPUarcs.js → dist-LDrOFrd-.js} +1212 -1365
  72. package/dist/index.d.ts +1 -1
  73. package/dist/index.js +3 -2
  74. package/dist/index.mjs +4 -4
  75. package/dist/kernel/moleculer/device-cap-proxy.d.ts +2 -2
  76. package/dist/{manifest-python-deps-CX5tGRqf.mjs → manifest-python-deps-CopL1_5C.mjs} +20 -14
  77. package/dist/{manifest-python-deps-d4acYXVV.js → manifest-python-deps-DNfmBu20.js} +20 -14
  78. package/package.json +5 -1
  79. package/dist/builtins/device-manager/device-link-cycle.d.ts +0 -18
  80. package/dist/builtins/device-manager/device-link-overlay.d.ts +0 -33
  81. package/dist/builtins/device-manager/device-link-resolver.d.ts +0 -44
  82. package/dist/main-CVLq8IzA.js +0 -2356
  83. package/dist/main-CzH19GkU.mjs +0 -2353
@@ -5967,12 +5967,34 @@ var streamBrokerCapability = {
5967
5967
  kinds: zod.z.array(EventMediaKindSchema).min(1).default(["mp4"]),
5968
5968
  /** GIF geometry. The video keeps the source's own. */
5969
5969
  gifMaxWidth: zod.z.number().int().min(120).max(1280).default(640),
5970
- gifFps: zod.z.number().int().min(1).max(15).default(8),
5971
5970
  /**
5972
- * Playback rate, applied to EVERY container so they stay one clip.
5973
- * `1` is real time and is what allows the copy branch.
5971
+ * The gif's own PLAYBACK rate in frames per second what the finished
5972
+ * gif runs at, not how many source frames feed it. The decimation that
5973
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
5974
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
5974
5975
  */
5975
- speed: zod.z.number().min(1).max(8).default(1)
5976
+ gifFps: zod.z.number().int().min(1).max(15).default(12),
5977
+ /**
5978
+ * How fast the GIF plays against real time, independent of `speed`.
5979
+ *
5980
+ * 4× by default, by operator request: a notification gif is glanced at
5981
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
5982
+ * a separate knob from `speed` even though both now default to 4 —
5983
+ * a caller wanting a real-time video and a fast gif must not have to
5984
+ * choose.
5985
+ */
5986
+ gifSpeed: zod.z.number().min(1).max(8).default(4),
5987
+ /**
5988
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
5989
+ *
5990
+ * `1` is real time and is the ONLY value that allows the copy branch —
5991
+ * anything else forces `libx264` over the window. That was priced
5992
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
5993
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
5994
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
5995
+ * once the decode is forced the width stops being free.
5996
+ */
5997
+ speed: zod.z.number().min(1).max(8).default(4)
5976
5998
  }), EventMediaProductionSchema, {
5977
5999
  kind: "mutation",
5978
6000
  auth: "admin"
@@ -6565,6 +6587,28 @@ function kebabToCamel(s) {
6565
6587
  * instead of taking the hub with it (D6). Every method here is admin-only, and
6566
6588
  * must stay so.
6567
6589
  */
6590
+ /**
6591
+ * Prefix for a block's generated addon id AND its runner id — one addon, one
6592
+ * process, so the two are the same string (D2).
6593
+ *
6594
+ * It lives on the CONTRACT rather than beside the code that materialises the
6595
+ * package, because it is not an implementation detail: the operator surface
6596
+ * reads a block's logs by asking `logs.query` for `addonId = core-block-<id>`,
6597
+ * with no cap and no store of its own. Two copies of this string is one copy
6598
+ * too many — a drift would show as a logs pane that is simply always empty.
6599
+ */
6600
+ var CORE_BLOCK_ADDON_PREFIX = "core-block-";
6601
+ /** The addon/runner id a block's process runs under. */
6602
+ function coreBlockAddonId(blockId) {
6603
+ return `${CORE_BLOCK_ADDON_PREFIX}${blockId}`;
6604
+ }
6605
+ /** The block id behind a generated addon/runner id, or null when the id belongs
6606
+ * to something else. The inverse of {@link coreBlockAddonId}. */
6607
+ function coreBlockIdFromAddonId(addonId) {
6608
+ if (!addonId.startsWith("core-block-")) return null;
6609
+ const rest = addonId.slice(11);
6610
+ return rest.length > 0 ? rest : null;
6611
+ }
6568
6612
  /** Where a block runs. The operator chooses — a block driving a device on an
6569
6613
  * agent is the reason placement is not fixed to the hub. */
6570
6614
  var CoreBlockPlacementSchema = zod.z.union([zod.z.literal("hub"), zod.z.string().min(1)]);
@@ -6653,6 +6697,23 @@ var coreBlocksCapability = {
6653
6697
  auth: "admin"
6654
6698
  }),
6655
6699
  /**
6700
+ * Stop this block's runner and let the supervisor bring it back.
6701
+ *
6702
+ * **It stores nothing.** There is no "restart requested" flag and no second
6703
+ * lifecycle field: the supervisor already stops and respawns a runner when
6704
+ * the code hash changes, and this exposes that same path deliberately
6705
+ * (D62 — a switch writes the authority that already owned the function).
6706
+ * The next reconcile pass remains the authority, so a restart that lost a
6707
+ * race is corrected within one pass rather than leaving a stale flag behind.
6708
+ *
6709
+ * Refused for a DISABLED block: nothing would come back, and a button that
6710
+ * silently does nothing is worse than one that says why.
6711
+ */
6712
+ restart: method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ block: CoreBlockSchema }), {
6713
+ kind: "mutation",
6714
+ auth: "admin"
6715
+ }),
6716
+ /**
6656
6717
  * Type-check without saving — what the editor calls as the author types, so
6657
6718
  * the compiler's verdict is the same one the server will reach.
6658
6719
  */
@@ -7693,909 +7754,173 @@ var deviceExportCapability = {
7693
7754
  unexposeDevice: method(UnexposeInputSchema, zod.z.void(), { kind: "mutation" })
7694
7755
  }
7695
7756
  };
7757
+ var ProviderStatusSchema = zod.z.object({
7758
+ connected: zod.z.boolean(),
7759
+ deviceCount: zod.z.number(),
7760
+ error: zod.z.string().optional()
7761
+ });
7762
+ zod.z.object({
7763
+ externalId: zod.z.string(),
7764
+ name: zod.z.string(),
7765
+ type: zod.z.string(),
7766
+ metadata: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
7767
+ });
7696
7768
  /**
7697
- * Resource-bound constants for the safe expression engine.
7698
- *
7699
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
7700
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
7701
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
7702
- * work a single author-supplied expression can request, so a hostile or
7703
- * accidental pathological string can never spend unbounded CPU/memory.
7769
+ * Candidate handed back from discovery and accepted by
7770
+ * `adoptDiscoveredDevice`. Shape mirrors the in-process
7771
+ * `DiscoveredDevice` interface used by `DeviceDiscovery`.
7704
7772
  */
7705
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
7706
- * rejected without allocation. */
7707
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
7708
- /** A legal binding / identifier name. */
7709
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
7710
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
7711
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
7712
- var RESERVED_BINDING_NAMES = new Set([
7713
- "now",
7714
- "true",
7715
- "false",
7716
- "null"
7717
- ]);
7773
+ var DiscoveryCandidateSchema = zod.z.object({
7774
+ stableId: zod.z.string(),
7775
+ type: zod.z.enum(DeviceType),
7776
+ suggestedName: zod.z.string(),
7777
+ prefilledConfig: zod.z.record(zod.z.string(), zod.z.unknown()),
7778
+ /**
7779
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
7780
+ * Discovery pre-populates this for systems that know the upstream
7781
+ * identity ahead of adoption. Rendering metadata (unit, precision)
7782
+ * flows live through the cap STATUS SLICE after adoption.
7783
+ */
7784
+ sourceInfo: SourceInfoSchema.optional()
7785
+ });
7718
7786
  /**
7719
- * Error types for the safe expression engine. Two distinct classes so callers
7720
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
7721
- * failure both are non-fatal to the host: read paths degrade to "skip link".
7787
+ * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
7788
+ * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
7789
+ * tRPC layer can pass it through without reshaping.
7722
7790
  */
7723
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
7724
- * the failure is anchored to a character (author-facing inline feedback). */
7725
- var ExpressionParseError = class extends Error {
7726
- position;
7727
- constructor(message, position) {
7728
- super(message);
7729
- this.name = "ExpressionParseError";
7730
- this.position = position;
7731
- }
7732
- };
7733
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
7734
- * result, unknown builtin, step-budget exceeded). */
7735
- var ExpressionEvalError = class extends Error {
7736
- constructor(message) {
7737
- super(message);
7738
- this.name = "ExpressionEvalError";
7791
+ var DeviceSummarySchema = zod.z.object({
7792
+ id: zod.z.number(),
7793
+ stableId: zod.z.string(),
7794
+ addonId: zod.z.string(),
7795
+ type: zod.z.string(),
7796
+ name: zod.z.string(),
7797
+ parentDeviceId: zod.z.number().nullable(),
7798
+ online: zod.z.boolean(),
7799
+ features: zod.z.array(zod.z.string()),
7800
+ config: zod.z.record(zod.z.string(), zod.z.unknown()),
7801
+ /** Optional upstream-system identity (dispatch key + system tag).
7802
+ * See `SourceInfo`. Present when the device has a non-synthetic
7803
+ * source identifier (HA entities, vendor MAC, …); omitted when the
7804
+ * synthetic backfill is in effect. */
7805
+ sourceInfo: SourceInfoSchema.optional()
7806
+ });
7807
+ /**
7808
+ * Result of a live field test (e.g. probing an RTSP URL during device
7809
+ * creation). Matches the UI-side `FieldProbeResult` in
7810
+ * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
7811
+ * returned `labels` as chips next to the input.
7812
+ */
7813
+ var FieldProbeResultSchema = zod.z.object({
7814
+ status: zod.z.enum(["ok", "error"]),
7815
+ labels: zod.z.array(zod.z.string()).optional(),
7816
+ error: zod.z.string().optional()
7817
+ });
7818
+ /**
7819
+ * The output of `getChildCreationSchema` is a UI schema tree. We store
7820
+ * it as `unknown` at the capability layer — the router just passes it
7821
+ * through and the admin UI renders it via `FormBuilder`. The actual
7822
+ * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
7823
+ * but we deliberately avoid a Zod mirror because the union is large and
7824
+ * not meant for runtime validation at this seam.
7825
+ */
7826
+ var CreationSchemaOutputSchema = zod.z.unknown();
7827
+ var deviceProviderCapability = {
7828
+ name: "device-provider",
7829
+ scope: "system",
7830
+ mode: "collection",
7831
+ methods: {
7832
+ start: method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
7833
+ stop: method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
7834
+ getStatus: method(zod.z.void(), ProviderStatusSchema),
7835
+ getDevices: method(zod.z.void(), zod.z.array(zod.z.object({
7836
+ id: zod.z.string(),
7837
+ name: zod.z.string(),
7838
+ type: zod.z.string()
7839
+ }))),
7840
+ supportsDiscovery: method(zod.z.object({}), zod.z.boolean()),
7841
+ /**
7842
+ * Run a network scan. `params` carries optional provider-specific scan
7843
+ * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
7844
+ * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
7845
+ * (provider uses its local-network default).
7846
+ */
7847
+ discoverDevices: method(zod.z.object({ params: zod.z.record(zod.z.string(), zod.z.unknown()).optional() }), zod.z.array(DiscoveryCandidateSchema), {
7848
+ kind: "mutation",
7849
+ auth: "admin"
7850
+ }),
7851
+ /**
7852
+ * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
7853
+ * provider accepts (e.g. Gree's broadcast address for a different subnet).
7854
+ * `null` when the provider takes no extra scan params — the generic
7855
+ * aggregated scan never renders this; the per-integration scan does.
7856
+ */
7857
+ getDiscoveryParamsSchema: method(zod.z.object({}), CreationSchemaOutputSchema),
7858
+ /**
7859
+ * The DeviceType this provider creates via manual add (Camera for
7860
+ * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
7861
+ * provider does not support manual creation. Lets the Add-Device dialog
7862
+ * pick the right type instead of assuming Camera.
7863
+ */
7864
+ getManualCreationType: method(zod.z.object({}), zod.z.object({ deviceType: zod.z.enum(DeviceType).nullable() })),
7865
+ adoptDiscoveredDevice: method(zod.z.object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
7866
+ kind: "mutation",
7867
+ auth: "admin"
7868
+ }),
7869
+ supportsManualCreation: method(zod.z.object({}), zod.z.boolean()),
7870
+ /**
7871
+ * Fetch the creation form schema for a given DeviceType. Returns
7872
+ * `null` when the provider does not support manually creating
7873
+ * devices of that type. The output is a `ConfigUISchema` — the
7874
+ * router type-asserts it at the boundary.
7875
+ */
7876
+ getChildCreationSchema: method(zod.z.object({ type: zod.z.enum(DeviceType) }), CreationSchemaOutputSchema),
7877
+ createDevice: method(zod.z.object({
7878
+ type: zod.z.enum(DeviceType),
7879
+ config: zod.z.record(zod.z.string(), zod.z.unknown())
7880
+ }), DeviceSummarySchema, {
7881
+ kind: "mutation",
7882
+ auth: "admin"
7883
+ }),
7884
+ /**
7885
+ * Test a single field in the creation form before the device has
7886
+ * been persisted. Typical use: probing an RTSP URL entered by the
7887
+ * user. Providers that don't support field probing return
7888
+ * `{ success: true, message: 'Field test not supported' }`.
7889
+ *
7890
+ * `formValues` is the live snapshot of every field in the form at
7891
+ * the moment the user clicked Test — useful for probes that depend
7892
+ * on multiple fields together (e.g. Reolink autodetect needs host
7893
+ * + credentials + UID + transport mode in a single call). Optional
7894
+ * for backwards compatibility; providers free to ignore it.
7895
+ */
7896
+ testCreationField: method(zod.z.object({
7897
+ type: zod.z.enum(DeviceType),
7898
+ key: zod.z.string(),
7899
+ value: zod.z.unknown(),
7900
+ formValues: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
7901
+ }), FieldProbeResultSchema, {
7902
+ kind: "mutation",
7903
+ auth: "admin"
7904
+ })
7739
7905
  }
7740
7906
  };
7741
7907
  /**
7742
- * Frozen, null-prototype builtin function table for the expression engine
7743
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
7744
- * parser rejects any callee not in it, and the evaluator gates each call on an
7745
- * own-property check against it.
7908
+ * Device Manager capability hub-side singleton that unifies device persistence,
7909
+ * live registry access, and all management operations into a single tRPC surface.
7746
7910
  *
7747
- * Because the object has a NULL prototype AND is `Object.freeze`d:
7748
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
7749
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
7750
- * (there is no `Object.prototype` in the chain), so those names are not
7751
- * callable — they are simply "unknown function" at parse time.
7911
+ * Replaces:
7912
+ * - `device-persistence` capability (persistence methods absorbed here)
7913
+ * - `device-management.router.ts` (deleted in Phase 2)
7914
+ * - `device-ops.router.ts` (compat layer deleted; device-provider ops absorbed here)
7752
7915
  *
7753
- * Every numeric argument is validated as a finite number and every numeric
7754
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
7755
- * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
7756
- * closed rather than emitting a garbage value.
7757
- */
7758
- function asFiniteNumber(value, name, index) {
7759
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
7760
- return value;
7761
- }
7762
- function asString$1(value, name, index) {
7763
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
7764
- return value;
7765
- }
7766
- function finiteResult(value, name) {
7767
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
7768
- return value;
7769
- }
7770
- function allFiniteNumbers(args, name) {
7771
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
7772
- }
7773
- var INF = Number.POSITIVE_INFINITY;
7774
- var table = {
7775
- min: {
7776
- minArgs: 1,
7777
- maxArgs: INF,
7778
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
7779
- },
7780
- max: {
7781
- minArgs: 1,
7782
- maxArgs: INF,
7783
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
7784
- },
7785
- abs: {
7786
- minArgs: 1,
7787
- maxArgs: 1,
7788
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
7789
- },
7790
- floor: {
7791
- minArgs: 1,
7792
- maxArgs: 1,
7793
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
7794
- },
7795
- ceil: {
7796
- minArgs: 1,
7797
- maxArgs: 1,
7798
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
7799
- },
7800
- sqrt: {
7801
- minArgs: 1,
7802
- maxArgs: 1,
7803
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
7804
- },
7805
- round: {
7806
- minArgs: 1,
7807
- maxArgs: 2,
7808
- apply: (args) => {
7809
- const x = asFiniteNumber(args[0], "round", 0);
7810
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
7811
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
7812
- const factor = 10 ** digits;
7813
- return finiteResult(Math.round(x * factor) / factor, "round");
7814
- }
7815
- },
7816
- pow: {
7817
- minArgs: 2,
7818
- maxArgs: 2,
7819
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
7820
- },
7821
- clamp: {
7822
- minArgs: 3,
7823
- maxArgs: 3,
7824
- apply: (args) => {
7825
- const x = asFiniteNumber(args[0], "clamp", 0);
7826
- const lo = asFiniteNumber(args[1], "clamp", 1);
7827
- const hi = asFiniteNumber(args[2], "clamp", 2);
7828
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
7829
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
7830
- }
7831
- },
7832
- avg: {
7833
- minArgs: 1,
7834
- maxArgs: INF,
7835
- apply: (args) => {
7836
- const nums = allFiniteNumbers(args, "avg");
7837
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
7838
- }
7839
- },
7840
- sum: {
7841
- minArgs: 1,
7842
- maxArgs: INF,
7843
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
7844
- },
7845
- coalesce: {
7846
- minArgs: 1,
7847
- maxArgs: INF,
7848
- apply: (args) => {
7849
- for (const a of args) if (a !== null) return a;
7850
- return null;
7851
- }
7852
- },
7853
- age: {
7854
- minArgs: 2,
7855
- maxArgs: 2,
7856
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
7857
- },
7858
- convert: {
7859
- minArgs: 3,
7860
- maxArgs: 3,
7861
- apply: (args, hooks) => {
7862
- const x = asFiniteNumber(args[0], "convert", 0);
7863
- const from = asString$1(args[1], "convert", 1).trim();
7864
- const to = asString$1(args[2], "convert", 2).trim();
7865
- if (hooks.convert) {
7866
- const out = hooks.convert(x, from, to);
7867
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
7868
- return finiteResult(out, "convert");
7869
- }
7870
- if (from === to) return x;
7871
- throw new ExpressionEvalError("convert: unit conversion table not installed");
7872
- }
7873
- }
7874
- };
7875
- /** Frozen, null-prototype builtin table. */
7876
- var EXPRESSION_BUILTINS = Object.freeze(Object.assign(Object.create(null), table));
7877
- /** The set of valid builtin names — used by the parser to reject unknown
7878
- * callees at parse time (immediate author feedback). */
7879
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
7880
- /**
7881
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
7882
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
7883
- * single/double-quoted strings with a tiny escape set, identifiers, the three
7884
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
7885
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
7886
- * is a parse error with a source position, so member access / assignment /
7887
- * template literals are lexically impossible.
7888
- */
7889
- var KEYWORDS = new Set([
7890
- "true",
7891
- "false",
7892
- "null"
7893
- ]);
7894
- function isDigit(ch) {
7895
- return ch >= "0" && ch <= "9";
7896
- }
7897
- function isIdentStart(ch) {
7898
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
7899
- }
7900
- function isIdentPart(ch) {
7901
- return isIdentStart(ch) || isDigit(ch);
7902
- }
7903
- function isWhitespace(ch) {
7904
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
7905
- }
7906
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
7907
- * Throws `ExpressionParseError` on any illegal character or unterminated
7908
- * string. */
7909
- function tokenize(source) {
7910
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
7911
- const tokens = [];
7912
- let i = 0;
7913
- const n = source.length;
7914
- while (i < n) {
7915
- const ch = source[i];
7916
- if (isWhitespace(ch)) {
7917
- i += 1;
7918
- continue;
7919
- }
7920
- if (isDigit(ch)) {
7921
- const start = i;
7922
- while (i < n && isDigit(source[i])) i += 1;
7923
- if (i < n && source[i] === ".") {
7924
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
7925
- i += 1;
7926
- while (i < n && isDigit(source[i])) i += 1;
7927
- }
7928
- const text = source.slice(start, i);
7929
- const value = Number(text);
7930
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
7931
- tokens.push({
7932
- type: "number",
7933
- value,
7934
- pos: start
7935
- });
7936
- continue;
7937
- }
7938
- if (ch === "'" || ch === "\"") {
7939
- const quote = ch;
7940
- const start = i;
7941
- i += 1;
7942
- let out = "";
7943
- let closed = false;
7944
- while (i < n) {
7945
- const c = source[i];
7946
- if (c === "\\") {
7947
- const next = i + 1 < n ? source[i + 1] : "";
7948
- if (next === "\\" || next === "'" || next === "\"") {
7949
- out += next;
7950
- i += 2;
7951
- continue;
7952
- }
7953
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
7954
- }
7955
- if (c === quote) {
7956
- closed = true;
7957
- i += 1;
7958
- break;
7959
- }
7960
- out += c;
7961
- i += 1;
7962
- }
7963
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
7964
- tokens.push({
7965
- type: "string",
7966
- value: out,
7967
- pos: start
7968
- });
7969
- continue;
7970
- }
7971
- if (isIdentStart(ch)) {
7972
- const start = i;
7973
- while (i < n && isIdentPart(source[i])) i += 1;
7974
- const text = source.slice(start, i);
7975
- if (KEYWORDS.has(text)) tokens.push({
7976
- type: "keyword",
7977
- keyword: keywordOf(text),
7978
- pos: start
7979
- });
7980
- else tokens.push({
7981
- type: "identifier",
7982
- name: text,
7983
- pos: start
7984
- });
7985
- continue;
7986
- }
7987
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
7988
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
7989
- tokens.push({
7990
- type: "punct",
7991
- punct: two,
7992
- pos: i
7993
- });
7994
- i += 2;
7995
- continue;
7996
- }
7997
- if (isSinglePunct(ch)) {
7998
- tokens.push({
7999
- type: "punct",
8000
- punct: ch,
8001
- pos: i
8002
- });
8003
- i += 1;
8004
- continue;
8005
- }
8006
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
8007
- }
8008
- tokens.push({
8009
- type: "eof",
8010
- pos: n
8011
- });
8012
- return tokens;
8013
- }
8014
- function keywordOf(text) {
8015
- if (text === "true") return "true";
8016
- if (text === "false") return "false";
8017
- return "null";
8018
- }
8019
- function isSinglePunct(ch) {
8020
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
8021
- }
8022
- /**
8023
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
8024
- *
8025
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
8026
- * → relational → additive → multiplicative → unary `! -` → call / primary.
8027
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
8028
- * string validated against the builtin table at parse time, so an unknown
8029
- * function is rejected immediately (author feedback) and a persisted expression
8030
- * that references a since-removed builtin degrades at read.
8031
- *
8032
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
8033
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
8034
- */
8035
- /** Binary/logical operator precedence (higher binds tighter). */
8036
- var BINARY_PRECEDENCE = {
8037
- "||": 1,
8038
- "&&": 2,
8039
- "==": 3,
8040
- "!=": 3,
8041
- "<": 4,
8042
- "<=": 4,
8043
- ">": 4,
8044
- ">=": 4,
8045
- "+": 5,
8046
- "-": 5,
8047
- "*": 6,
8048
- "/": 6,
8049
- "%": 6
8050
- };
8051
- function isLogicalOp(op) {
8052
- return op === "&&" || op === "||";
8053
- }
8054
- function isBinaryOp(op) {
8055
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
8056
- }
8057
- var Parser = class {
8058
- tokens;
8059
- pos = 0;
8060
- nodeCount = 0;
8061
- identifiers = /* @__PURE__ */ new Set();
8062
- callees = /* @__PURE__ */ new Set();
8063
- constructor(tokens) {
8064
- this.tokens = tokens;
8065
- }
8066
- parse() {
8067
- const ast = this.parseTernary();
8068
- const tok = this.peek();
8069
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
8070
- return {
8071
- ast,
8072
- identifiers: this.identifiers,
8073
- callees: this.callees,
8074
- nodeCount: this.nodeCount
8075
- };
8076
- }
8077
- peek() {
8078
- return this.tokens[this.pos];
8079
- }
8080
- next() {
8081
- return this.tokens[this.pos++];
8082
- }
8083
- /** Consume a punctuator token, erroring if the next token isn't it. */
8084
- expectPunct(punct) {
8085
- const tok = this.peek();
8086
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
8087
- this.pos += 1;
8088
- }
8089
- matchPunct(punct) {
8090
- const tok = this.peek();
8091
- if (tok.type === "punct" && tok.punct === punct) {
8092
- this.pos += 1;
8093
- return true;
8094
- }
8095
- return false;
8096
- }
8097
- countNode() {
8098
- this.nodeCount += 1;
8099
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
8100
- }
8101
- parseTernary() {
8102
- const test = this.parseBinary(1);
8103
- if (this.matchPunct("?")) {
8104
- const consequent = this.parseTernary();
8105
- this.expectPunct(":");
8106
- const alternate = this.parseTernary();
8107
- this.countNode();
8108
- return {
8109
- kind: "conditional",
8110
- test,
8111
- consequent,
8112
- alternate
8113
- };
8114
- }
8115
- return test;
8116
- }
8117
- parseBinary(minPrec) {
8118
- let left = this.parseUnary();
8119
- for (;;) {
8120
- const tok = this.peek();
8121
- if (tok.type !== "punct") break;
8122
- const prec = BINARY_PRECEDENCE[tok.punct];
8123
- if (prec === void 0 || prec < minPrec) break;
8124
- const op = tok.punct;
8125
- this.pos += 1;
8126
- const right = this.parseBinary(prec + 1);
8127
- this.countNode();
8128
- if (isLogicalOp(op)) left = {
8129
- kind: "logical",
8130
- op,
8131
- left,
8132
- right
8133
- };
8134
- else if (isBinaryOp(op)) left = {
8135
- kind: "binary",
8136
- op,
8137
- left,
8138
- right
8139
- };
8140
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
8141
- }
8142
- return left;
8143
- }
8144
- parseUnary() {
8145
- const tok = this.peek();
8146
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
8147
- const op = tok.punct;
8148
- this.pos += 1;
8149
- const operand = this.parseUnary();
8150
- this.countNode();
8151
- return {
8152
- kind: "unary",
8153
- op,
8154
- operand
8155
- };
8156
- }
8157
- return this.parsePrimary();
8158
- }
8159
- parsePrimary() {
8160
- const tok = this.next();
8161
- switch (tok.type) {
8162
- case "number":
8163
- this.countNode();
8164
- return {
8165
- kind: "literal",
8166
- value: tok.value
8167
- };
8168
- case "string":
8169
- this.countNode();
8170
- return {
8171
- kind: "literal",
8172
- value: tok.value
8173
- };
8174
- case "keyword":
8175
- this.countNode();
8176
- return {
8177
- kind: "literal",
8178
- value: tok.keyword === "null" ? null : tok.keyword === "true"
8179
- };
8180
- case "identifier": {
8181
- const nextTok = this.peek();
8182
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
8183
- this.identifiers.add(tok.name);
8184
- this.countNode();
8185
- return {
8186
- kind: "identifier",
8187
- name: tok.name
8188
- };
8189
- }
8190
- case "punct":
8191
- if (tok.punct === "(") {
8192
- const inner = this.parseTernary();
8193
- this.expectPunct(")");
8194
- return inner;
8195
- }
8196
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
8197
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
8198
- }
8199
- }
8200
- parseCall(callee, pos) {
8201
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
8202
- this.expectPunct("(");
8203
- const args = [];
8204
- if (!this.matchPunct(")")) for (;;) {
8205
- args.push(this.parseTernary());
8206
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
8207
- if (this.matchPunct(",")) continue;
8208
- this.expectPunct(")");
8209
- break;
8210
- }
8211
- this.callees.add(callee);
8212
- this.countNode();
8213
- return {
8214
- kind: "call",
8215
- callee,
8216
- args
8217
- };
8218
- }
8219
- };
8220
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
8221
- * `ExpressionParseError` on any lexical or grammatical failure. */
8222
- function parseExpression(source) {
8223
- return new Parser(tokenize(source)).parse();
8224
- }
8225
- /**
8226
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
8227
- * by expr"). The cache stores BOTH successes and failures (negative caching),
8228
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
8229
- * one per read on a hot resolve path.
8230
- *
8231
- * The cache is a module-level singleton: entries are pure, content-addressed
8232
- * ASTs keyed by the raw source string, so sharing one instance across all
8233
- * callers is safe and maximises hit rate.
8234
- */
8235
- var cache = /* @__PURE__ */ new Map();
8236
- function getCached(source) {
8237
- const hit = cache.get(source);
8238
- if (hit !== void 0) {
8239
- cache.delete(source);
8240
- cache.set(source, hit);
8241
- return hit;
8242
- }
8243
- let result;
8244
- try {
8245
- result = {
8246
- ok: true,
8247
- parsed: parseExpression(source)
8248
- };
8249
- } catch (err) {
8250
- result = {
8251
- ok: false,
8252
- error: err instanceof ExpressionParseError ? err.message : String(err)
8253
- };
8254
- }
8255
- cache.set(source, result);
8256
- if (cache.size > 256) {
8257
- const oldest = cache.keys().next().value;
8258
- if (oldest !== void 0) cache.delete(oldest);
8259
- }
8260
- return result;
8261
- }
8262
- /** Compile `source`, returning a discriminated result instead of throwing.
8263
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
8264
- function compileExpressionSafe(source) {
8265
- return getCached(source);
8266
- }
8267
- /**
8268
- * Tree-walking evaluator for the safe expression mini-language.
8269
- *
8270
- * SECURITY (spec §4 rule 2/5):
8271
- * - The scope is an `Object.create(null)` copy of ONLY the caller's own
8272
- * enumerable binding entries, so `name in scope` is a pure own-key check and
8273
- * `constructor` / `__proto__` / `toString` are plain unknown identifiers.
8274
- * - Performs ZERO I/O and never touches `globalThis` / `Date` / `Math`
8275
- * directly — the only external calls are into the frozen builtin table.
8276
- * - The grammar has no loops/recursion/lambdas, so a walk is O(nodeCount) by
8277
- * construction; the step counter is defense-in-depth for a crafted max-size
8278
- * AST. Nothing blocks: there are no timers, awaits or unbounded loops.
8279
- */
8280
- var EMPTY_HOOKS = Object.freeze({});
8281
- /** Build a null-prototype scope from own-enumerable binding entries. Inherited
8282
- * keys of the input (e.g. from a `{__proto__: {...}}` payload) are NOT copied,
8283
- * so nothing smuggles in via the prototype chain. */
8284
- function createExpressionScope(bindings) {
8285
- const scope = Object.create(null);
8286
- for (const key of Object.keys(bindings)) if (Object.prototype.hasOwnProperty.call(bindings, key)) scope[key] = bindings[key];
8287
- return scope;
8288
- }
8289
- function isFiniteNumber(value) {
8290
- return typeof value === "number" && Number.isFinite(value);
8291
- }
8292
- /** JS truthiness of a primitive value. */
8293
- function truthy(value) {
8294
- return Boolean(value);
8295
- }
8296
- function requireFinite(value, context) {
8297
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${context} produced a non-finite result`);
8298
- return value;
8299
- }
8300
- function step(ctx) {
8301
- ctx.steps += 1;
8302
- if (ctx.steps > ctx.maxSteps) throw new ExpressionEvalError("expression evaluation step budget exceeded");
8303
- }
8304
- function evalNode(node, ctx) {
8305
- step(ctx);
8306
- switch (node.kind) {
8307
- case "literal": return node.value;
8308
- case "identifier":
8309
- if (!(node.name in ctx.scope)) throw new ExpressionEvalError(`unknown identifier: ${node.name}`);
8310
- return ctx.scope[node.name];
8311
- case "unary": return evalUnary(node.op, evalNode(node.operand, ctx));
8312
- case "binary": return evalBinary(node.op, evalNode(node.left, ctx), evalNode(node.right, ctx));
8313
- case "logical": {
8314
- const left = evalNode(node.left, ctx);
8315
- if (node.op === "&&") return truthy(left) ? evalNode(node.right, ctx) : left;
8316
- return truthy(left) ? left : evalNode(node.right, ctx);
8317
- }
8318
- case "conditional": return truthy(evalNode(node.test, ctx)) ? evalNode(node.consequent, ctx) : evalNode(node.alternate, ctx);
8319
- case "call": return evalCall(node.callee, node.args.map((a) => evalNode(a, ctx)), ctx.hooks);
8320
- }
8321
- }
8322
- function evalUnary(op, operand) {
8323
- if (op === "!") return !truthy(operand);
8324
- if (!isFiniteNumber(operand)) throw new ExpressionEvalError("unary \"-\" requires a finite number");
8325
- return requireFinite(-operand, "unary \"-\"");
8326
- }
8327
- function evalBinary(op, left, right) {
8328
- switch (op) {
8329
- case "==": return left === right;
8330
- case "!=": return left !== right;
8331
- case "+":
8332
- if (typeof left === "string" && typeof right === "string") return left + right;
8333
- if (isFiniteNumber(left) && isFiniteNumber(right)) return requireFinite(left + right, "\"+\"");
8334
- throw new ExpressionEvalError("\"+\" requires two numbers or two strings");
8335
- case "-":
8336
- case "*":
8337
- case "/":
8338
- case "%":
8339
- if (!isFiniteNumber(left) || !isFiniteNumber(right)) throw new ExpressionEvalError(`"${op}" requires two finite numbers`);
8340
- return requireFinite(op === "-" ? left - right : op === "*" ? left * right : op === "/" ? left / right : left % right, `"${op}"`);
8341
- case "<":
8342
- case "<=":
8343
- case ">":
8344
- case ">=":
8345
- if (isFiniteNumber(left) && isFiniteNumber(right)) return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
8346
- if (typeof left === "string" && typeof right === "string") return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
8347
- throw new ExpressionEvalError(`"${op}" requires two numbers or two strings`);
8348
- }
8349
- }
8350
- function evalCall(callee, args, hooks) {
8351
- if (!Object.prototype.hasOwnProperty.call(EXPRESSION_BUILTINS, callee)) throw new ExpressionEvalError(`unknown function: ${callee}`);
8352
- const builtin = EXPRESSION_BUILTINS[callee];
8353
- if (args.length < builtin.minArgs || args.length > builtin.maxArgs) throw new ExpressionEvalError(`${callee}: wrong number of arguments (${args.length})`);
8354
- return builtin.apply(args, hooks);
8355
- }
8356
- /** Evaluate an AST node against a scope. Throws `ExpressionEvalError` on any
8357
- * runtime failure (unknown identifier, type mismatch, non-finite result,
8358
- * step-budget exhaustion). */
8359
- function evaluateAst(node, scope, opts) {
8360
- return evalNode(node, {
8361
- scope,
8362
- hooks: opts?.hooks ?? EMPTY_HOOKS,
8363
- maxSteps: opts?.maxSteps ?? 4096,
8364
- steps: 0
8365
- });
8366
- }
8367
- /**
8368
- * Coerce an untrusted `getByPath` / mirror read to an `ExpressionValue`.
8369
- * Non-primitive values (objects, arrays, `undefined`, functions, bigint,
8370
- * symbol) and non-finite numbers become `undefined` so the caller can apply
8371
- * its binding-miss policy (→ `null`). `null` itself is a valid value.
8372
- */
8373
- function toExpressionValue(raw) {
8374
- if (raw === null) return null;
8375
- if (typeof raw === "string") return raw;
8376
- if (typeof raw === "boolean") return raw;
8377
- if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
8378
- }
8379
- /**
8380
- * Author-time validation. Returns `null` when the source is valid, else a
8381
- * human-readable error message. Checks: the expression compiles; binding count
8382
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
8383
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
8384
- * FREE identifier of the AST is covered by a binding or the injected `now`.
8385
- */
8386
- function validateExpressionSource(src) {
8387
- const names = Object.keys(src.bindings);
8388
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
8389
- for (const name of names) {
8390
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
8391
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
8392
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
8393
- }
8394
- const compiled = compileExpressionSafe(src.expr);
8395
- if (!compiled.ok) return compiled.error;
8396
- const bound = new Set(names);
8397
- for (const id of compiled.parsed.identifiers) {
8398
- if (id === "now") continue;
8399
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
8400
- }
8401
- return null;
8402
- }
8403
- /**
8404
- * Shared read-path evaluation for BOTH resolver channels. Builds a null-proto
8405
- * scope from `bindingValues` plus the injected `now` (supplied by the caller
8406
- * for determinism/testability), compiles via the LRU, and evaluates. Any
8407
- * failure (parse or eval) returns `{ ok: false }` — the caller treats that as
8408
- * "skip this link".
8409
- */
8410
- function evaluateLinkExpression(expr, bindingValues, now, opts) {
8411
- const compiled = compileExpressionSafe(expr);
8412
- if (!compiled.ok) return {
8413
- ok: false,
8414
- error: compiled.error
8415
- };
8416
- const scope = createExpressionScope({
8417
- ...bindingValues,
8418
- ["now"]: now
8419
- });
8420
- try {
8421
- return {
8422
- ok: true,
8423
- value: evaluateAst(compiled.parsed.ast, scope, opts)
8424
- };
8425
- } catch (err) {
8426
- return {
8427
- ok: false,
8428
- error: err instanceof ExpressionEvalError ? err.message : String(err)
8429
- };
8430
- }
8431
- }
8432
- var ProviderStatusSchema = zod.z.object({
8433
- connected: zod.z.boolean(),
8434
- deviceCount: zod.z.number(),
8435
- error: zod.z.string().optional()
8436
- });
8437
- zod.z.object({
8438
- externalId: zod.z.string(),
8439
- name: zod.z.string(),
8440
- type: zod.z.string(),
8441
- metadata: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
8442
- });
8443
- /**
8444
- * Candidate handed back from discovery and accepted by
8445
- * `adoptDiscoveredDevice`. Shape mirrors the in-process
8446
- * `DiscoveredDevice` interface used by `DeviceDiscovery`.
8447
- */
8448
- var DiscoveryCandidateSchema = zod.z.object({
8449
- stableId: zod.z.string(),
8450
- type: zod.z.enum(DeviceType),
8451
- suggestedName: zod.z.string(),
8452
- prefilledConfig: zod.z.record(zod.z.string(), zod.z.unknown()),
8453
- /**
8454
- * Optional upstream-system identity (HA entity_id, vendor MAC, …).
8455
- * Discovery pre-populates this for systems that know the upstream
8456
- * identity ahead of adoption. Rendering metadata (unit, precision)
8457
- * flows live through the cap STATUS SLICE after adoption.
8458
- */
8459
- sourceInfo: SourceInfoSchema.optional()
8460
- });
8461
- /**
8462
- * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
8463
- * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
8464
- * tRPC layer can pass it through without reshaping.
8465
- */
8466
- var DeviceSummarySchema = zod.z.object({
8467
- id: zod.z.number(),
8468
- stableId: zod.z.string(),
8469
- addonId: zod.z.string(),
8470
- type: zod.z.string(),
8471
- name: zod.z.string(),
8472
- parentDeviceId: zod.z.number().nullable(),
8473
- online: zod.z.boolean(),
8474
- features: zod.z.array(zod.z.string()),
8475
- config: zod.z.record(zod.z.string(), zod.z.unknown()),
8476
- /** Optional upstream-system identity (dispatch key + system tag).
8477
- * See `SourceInfo`. Present when the device has a non-synthetic
8478
- * source identifier (HA entities, vendor MAC, …); omitted when the
8479
- * synthetic backfill is in effect. */
8480
- sourceInfo: SourceInfoSchema.optional()
8481
- });
8482
- /**
8483
- * Result of a live field test (e.g. probing an RTSP URL during device
8484
- * creation). Matches the UI-side `FieldProbeResult` in
8485
- * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
8486
- * returned `labels` as chips next to the input.
8487
- */
8488
- var FieldProbeResultSchema = zod.z.object({
8489
- status: zod.z.enum(["ok", "error"]),
8490
- labels: zod.z.array(zod.z.string()).optional(),
8491
- error: zod.z.string().optional()
8492
- });
8493
- /**
8494
- * The output of `getChildCreationSchema` is a UI schema tree. We store
8495
- * it as `unknown` at the capability layer — the router just passes it
8496
- * through and the admin UI renders it via `FormBuilder`. The actual
8497
- * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
8498
- * but we deliberately avoid a Zod mirror because the union is large and
8499
- * not meant for runtime validation at this seam.
8500
- */
8501
- var CreationSchemaOutputSchema = zod.z.unknown();
8502
- var deviceProviderCapability = {
8503
- name: "device-provider",
8504
- scope: "system",
8505
- mode: "collection",
8506
- methods: {
8507
- start: method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
8508
- stop: method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
8509
- getStatus: method(zod.z.void(), ProviderStatusSchema),
8510
- getDevices: method(zod.z.void(), zod.z.array(zod.z.object({
8511
- id: zod.z.string(),
8512
- name: zod.z.string(),
8513
- type: zod.z.string()
8514
- }))),
8515
- supportsDiscovery: method(zod.z.object({}), zod.z.boolean()),
8516
- /**
8517
- * Run a network scan. `params` carries optional provider-specific scan
8518
- * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
8519
- * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
8520
- * (provider uses its local-network default).
8521
- */
8522
- discoverDevices: method(zod.z.object({ params: zod.z.record(zod.z.string(), zod.z.unknown()).optional() }), zod.z.array(DiscoveryCandidateSchema), {
8523
- kind: "mutation",
8524
- auth: "admin"
8525
- }),
8526
- /**
8527
- * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
8528
- * provider accepts (e.g. Gree's broadcast address for a different subnet).
8529
- * `null` when the provider takes no extra scan params — the generic
8530
- * aggregated scan never renders this; the per-integration scan does.
8531
- */
8532
- getDiscoveryParamsSchema: method(zod.z.object({}), CreationSchemaOutputSchema),
8533
- /**
8534
- * The DeviceType this provider creates via manual add (Camera for
8535
- * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
8536
- * provider does not support manual creation. Lets the Add-Device dialog
8537
- * pick the right type instead of assuming Camera.
8538
- */
8539
- getManualCreationType: method(zod.z.object({}), zod.z.object({ deviceType: zod.z.enum(DeviceType).nullable() })),
8540
- adoptDiscoveredDevice: method(zod.z.object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
8541
- kind: "mutation",
8542
- auth: "admin"
8543
- }),
8544
- supportsManualCreation: method(zod.z.object({}), zod.z.boolean()),
8545
- /**
8546
- * Fetch the creation form schema for a given DeviceType. Returns
8547
- * `null` when the provider does not support manually creating
8548
- * devices of that type. The output is a `ConfigUISchema` — the
8549
- * router type-asserts it at the boundary.
8550
- */
8551
- getChildCreationSchema: method(zod.z.object({ type: zod.z.enum(DeviceType) }), CreationSchemaOutputSchema),
8552
- createDevice: method(zod.z.object({
8553
- type: zod.z.enum(DeviceType),
8554
- config: zod.z.record(zod.z.string(), zod.z.unknown())
8555
- }), DeviceSummarySchema, {
8556
- kind: "mutation",
8557
- auth: "admin"
8558
- }),
8559
- /**
8560
- * Test a single field in the creation form before the device has
8561
- * been persisted. Typical use: probing an RTSP URL entered by the
8562
- * user. Providers that don't support field probing return
8563
- * `{ success: true, message: 'Field test not supported' }`.
8564
- *
8565
- * `formValues` is the live snapshot of every field in the form at
8566
- * the moment the user clicked Test — useful for probes that depend
8567
- * on multiple fields together (e.g. Reolink autodetect needs host
8568
- * + credentials + UID + transport mode in a single call). Optional
8569
- * for backwards compatibility; providers free to ignore it.
8570
- */
8571
- testCreationField: method(zod.z.object({
8572
- type: zod.z.enum(DeviceType),
8573
- key: zod.z.string(),
8574
- value: zod.z.unknown(),
8575
- formValues: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
8576
- }), FieldProbeResultSchema, {
8577
- kind: "mutation",
8578
- auth: "admin"
8579
- })
8580
- }
8581
- };
8582
- /**
8583
- * Device Manager capability — hub-side singleton that unifies device persistence,
8584
- * live registry access, and all management operations into a single tRPC surface.
8585
- *
8586
- * Replaces:
8587
- * - `device-persistence` capability (persistence methods absorbed here)
8588
- * - `device-management.router.ts` (deleted in Phase 2)
8589
- * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
8590
- *
8591
- * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
8592
- * fork into separate processes but never run on remote cluster agents. Therefore:
8593
- * - No nodeId routing needed — this is a pure hub singleton.
8594
- * - The hub's DeviceRegistry is the single source of truth for all live devices.
8595
- * - No shadow registry or cross-node aggregation required.
8596
- *
8597
- * Forked workers register devices back to the hub via `ctx.devices`
8598
- * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
7916
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
7917
+ * fork into separate processes but never run on remote cluster agents. Therefore:
7918
+ * - No nodeId routing needed this is a pure hub singleton.
7919
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
7920
+ * - No shadow registry or cross-node aggregation required.
7921
+ *
7922
+ * Forked workers register devices back to the hub via `ctx.devices`
7923
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
8599
7924
  */
8600
7925
  /** One child-placement directive on a container's `childLayout`. Structurally
8601
7926
  * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
@@ -8608,92 +7933,6 @@ var ChildLayoutEntrySchema = zod.z.object({
8608
7933
  order: zod.z.number().optional(),
8609
7934
  collapsed: zod.z.boolean().optional()
8610
7935
  });
8611
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
8612
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
8613
- * accessory's status field (`kind` optional/absent for wire compat); a
8614
- * LITERAL source carries a per-device constant (no sibling is read); a
8615
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
8616
- * source device's full re-sync-stable `stableId`. */
8617
- var DeviceLinkFieldSourceSchema = zod.z.object({
8618
- kind: zod.z.literal("field").optional(),
8619
- sourceKey: zod.z.string(),
8620
- cap: zod.z.string(),
8621
- fieldPath: zod.z.string()
8622
- });
8623
- var DeviceLinkLiteralSourceSchema = zod.z.object({
8624
- kind: zod.z.literal("literal"),
8625
- value: zod.z.union([
8626
- zod.z.string(),
8627
- zod.z.number(),
8628
- zod.z.boolean(),
8629
- zod.z.null()
8630
- ])
8631
- });
8632
- var DeviceLinkGlobalSourceSchema = zod.z.object({
8633
- kind: zod.z.literal("global"),
8634
- sourceStableId: zod.z.string(),
8635
- cap: zod.z.string(),
8636
- fieldPath: zod.z.string()
8637
- });
8638
- /** Expression source (Stage X): compute the target field from N named bindings
8639
- * via the safe expression engine. Bindings are field | literal | global — never
8640
- * another expression (no nesting). The `superRefine` runs the SAME author-time
8641
- * validation as `validateExpressionSource` (compiles the expr, checks binding
8642
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
8643
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
8644
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
8645
- var DeviceLinkExpressionSourceSchema = zod.z.object({
8646
- kind: zod.z.literal("expression"),
8647
- expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8648
- bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), zod.z.union([
8649
- DeviceLinkFieldSourceSchema,
8650
- DeviceLinkLiteralSourceSchema,
8651
- DeviceLinkGlobalSourceSchema
8652
- ]))
8653
- }).superRefine((src, ctx) => {
8654
- const err = validateExpressionSource(src);
8655
- if (err !== null) ctx.addIssue({
8656
- code: "custom",
8657
- message: err,
8658
- path: ["expr"]
8659
- });
8660
- });
8661
- var DeviceLinkSchema = zod.z.object({
8662
- id: zod.z.string(),
8663
- source: zod.z.union([
8664
- DeviceLinkFieldSourceSchema,
8665
- DeviceLinkLiteralSourceSchema,
8666
- DeviceLinkGlobalSourceSchema,
8667
- DeviceLinkExpressionSourceSchema
8668
- ]),
8669
- target: zod.z.object({
8670
- cap: zod.z.string(),
8671
- fieldPath: zod.z.string(),
8672
- itemKey: zod.z.string().optional()
8673
- }),
8674
- transform: zod.z.discriminatedUnion("kind", [
8675
- zod.z.object({ kind: zod.z.literal("identity") }),
8676
- zod.z.object({
8677
- kind: zod.z.literal("enum-map"),
8678
- mapping: zod.z.record(zod.z.string(), zod.z.union([
8679
- zod.z.string(),
8680
- zod.z.number(),
8681
- zod.z.boolean()
8682
- ])),
8683
- fallback: zod.z.union([
8684
- zod.z.string(),
8685
- zod.z.number(),
8686
- zod.z.boolean()
8687
- ]).optional()
8688
- }),
8689
- zod.z.object({
8690
- kind: zod.z.literal("linear"),
8691
- scale: zod.z.number(),
8692
- offset: zod.z.number(),
8693
- clamp: zod.z.tuple([zod.z.number(), zod.z.number()]).readonly().optional()
8694
- })
8695
- ]).optional()
8696
- });
8697
7936
  /** Cap-wire shape of a per-cap display refinement — mirrors
8698
7937
  * `DeviceCapDisplayOverride` in `device-management.ts`. */
8699
7938
  var DeviceCapDisplayOverrideSchema = zod.z.object({
@@ -8773,8 +8012,6 @@ var DeviceInfoSchema = zod.z.object({
8773
8012
  * named accordion sections (with optional intra-section order). See
8774
8013
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
8775
8014
  childLayout: zod.z.array(ChildLayoutEntrySchema).readonly().optional(),
8776
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
8777
- deviceLinks: zod.z.array(DeviceLinkSchema).readonly().optional(),
8778
8015
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
8779
8016
  display: DeviceDisplayOverrideSchema.optional()
8780
8017
  });
@@ -8783,7 +8020,7 @@ var ConfigEntrySchema = zod.z.object({
8783
8020
  value: zod.z.unknown(),
8784
8021
  description: zod.z.string().optional()
8785
8022
  });
8786
- var DeviceLinkModeSchema = zod.z.enum(["auto", "manual"]);
8023
+ var LinkedDevicesModeSchema = zod.z.enum(["auto", "manual"]);
8787
8024
  /** One resolved linked device — the compact projection consumers need. */
8788
8025
  var LinkedDeviceSchema = zod.z.object({
8789
8026
  deviceId: zod.z.number(),
@@ -8846,8 +8083,6 @@ var DeviceMetaSchema = zod.z.object({
8846
8083
  * accordion sections (with optional intra-section order). See
8847
8084
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
8848
8085
  childLayout: zod.z.array(ChildLayoutEntrySchema).readonly().optional(),
8849
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
8850
- deviceLinks: zod.z.array(DeviceLinkSchema).readonly().optional(),
8851
8086
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
8852
8087
  * Optional: only present for accessory children that carry a known role. */
8853
8088
  role: zod.z.string().nullable().optional(),
@@ -8999,16 +8234,6 @@ var deviceManagerCapability = {
8999
8234
  kind: "mutation",
9000
8235
  auth: "admin"
9001
8236
  }),
9002
- /** Set (or replace) the cross-device field wirings on a device's meta row.
9003
- * Mirrors `setChildLayout`; persisted, projected, preserved across
9004
- * re-register/restore. Idempotent. */
9005
- setDeviceLinks: method(zod.z.object({
9006
- deviceId: zod.z.number(),
9007
- deviceLinks: zod.z.array(DeviceLinkSchema).readonly()
9008
- }), zod.z.void(), {
9009
- kind: "mutation",
9010
- auth: "admin"
9011
- }),
9012
8237
  /** Set (or clear) the per-device display override on the meta row. Mirrors
9013
8238
  * `setChildLayout` persistence; `null` clears the override entirely. The
9014
8239
  * override unit(s) are normalized (`normalizeUnit`) at write so the render
@@ -9032,15 +8257,15 @@ var deviceManagerCapability = {
9032
8257
  kind: "mutation",
9033
8258
  auth: "admin"
9034
8259
  }),
9035
- /** List the wireable status-schema fields per cap bound to a device.
9036
- * Powers the Wiring tab's field pickers. Caps without a status schema are
9037
- * omitted. Item-array caps (`status.itemArray`, e.g. consumables) also
9038
- * emit their per-item fields tagged `item: true` (a link targeting one
9039
- * must carry a `target.itemKey`) plus the cap-level `itemArray`
9040
- * descriptor. `includeSynthesizable: true` (TARGET pickers only) unions
9041
- * in unbound device-scoped caps that declare `status.empty` and match
9042
- * the device's type so the FIRST link to a synthesize-only cap
9043
- * (consumables on an HA vacuum) can be authored. */
8260
+ /** The per-cap catalog of readable status-schema fields for one device.
8261
+ * This is the SOURCE PICKER it outlived the Wiring tab that first
8262
+ * needed it (deleted 2026-08-08) and becomes the picker of a composition
8263
+ * recipe: every field the old tab could target, a composed device can
8264
+ * read. Caps without a status schema are omitted. Item-array caps
8265
+ * (`status.itemArray`, e.g. consumables) also emit their per-item fields
8266
+ * tagged `item: true` plus the cap-level `itemArray` descriptor.
8267
+ * `includeSynthesizable: true` unions in unbound device-scoped caps that
8268
+ * declare `status.empty` and match the device's type. */
9044
8269
  getWireableFields: method(zod.z.object({
9045
8270
  deviceId: zod.z.number(),
9046
8271
  includeSynthesizable: zod.z.boolean().optional()
@@ -9182,7 +8407,7 @@ var deviceManagerCapability = {
9182
8407
  * (see the module docblock) — there are no bespoke link mutations.
9183
8408
  */
9184
8409
  getLinkedDevices: method(zod.z.object({ deviceId: zod.z.number() }), zod.z.object({
9185
- mode: DeviceLinkModeSchema,
8410
+ mode: LinkedDevicesModeSchema,
9186
8411
  devices: zod.z.array(LinkedDeviceSchema)
9187
8412
  })),
9188
8413
  /** Get stream sources for a camera device. */
@@ -9246,11 +8471,7 @@ var deviceManagerCapability = {
9246
8471
  deviceId: zod.z.number(),
9247
8472
  entries: zod.z.array(zod.z.object({
9248
8473
  capName: zod.z.string(),
9249
- kind: zod.z.enum([
9250
- "native",
9251
- "wrapped",
9252
- "linked"
9253
- ]),
8474
+ kind: zod.z.enum(["native", "wrapped"]),
9254
8475
  providerAddonId: zod.z.string(),
9255
8476
  providerNodeId: zod.z.string(),
9256
8477
  nativeAddonId: zod.z.string()
@@ -9268,11 +8489,7 @@ var deviceManagerCapability = {
9268
8489
  deviceId: zod.z.number(),
9269
8490
  entries: zod.z.array(zod.z.object({
9270
8491
  capName: zod.z.string(),
9271
- kind: zod.z.enum([
9272
- "native",
9273
- "wrapped",
9274
- "linked"
9275
- ]),
8492
+ kind: zod.z.enum(["native", "wrapped"]),
9276
8493
  providerAddonId: zod.z.string(),
9277
8494
  providerNodeId: zod.z.string(),
9278
8495
  nativeAddonId: zod.z.string()
@@ -10480,6 +9697,10 @@ var motionDetectionCapability = {
10480
9697
  mode: "singleton",
10481
9698
  kind: "wrapper",
10482
9699
  defaultActive: true,
9700
+ /** Frame-differencing motion analysis over a video stream — only a camera
9701
+ * produces the frames. Read by the `defaultActive` auto-bind in
9702
+ * `device-manager.getBindings` to decide which devices it may claim. */
9703
+ deviceTypes: [DeviceType.Camera],
10483
9704
  exposesDeviceSettings: true,
10484
9705
  methods: {
10485
9706
  analyze: method(zod.z.object({
@@ -12873,6 +12094,18 @@ var OauthIntegrationDescriptorSchema = zod.z.object({
12873
12094
  * redirect_uri that does not start with one of these. Required —
12874
12095
  * an empty list means the integration can never complete linking. */
12875
12096
  allowedRedirectPrefixes: zod.z.array(zod.z.string()).min(1),
12097
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
12098
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
12099
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
12100
+ * whose address the hub cannot know in advance (a Home Assistant at
12101
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
12102
+ * exactly; a public host never satisfies this branch, so it is not a
12103
+ * wildcard prefix by another name. */
12104
+ allowedPrivateHostPaths: zod.z.array(zod.z.string()).optional(),
12105
+ /** When true this is a PUBLIC client (source is published, no secret can be
12106
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
12107
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
12108
+ requiresPkce: zod.z.boolean().optional(),
12876
12109
  /** Optional public origin (no trailing slash) that this integration's
12877
12110
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
12878
12111
  * operator-selected external-access endpoint resolved by the addon. When
@@ -13029,7 +12262,7 @@ var TrackEnvelopeSchema = zod.z.object({
13029
12262
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
13030
12263
  * keeps every scalar the list surfaces actually render (ids, class(es),
13031
12264
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
13032
- * zonesVisited, bestEventId, envelope) and returns `positions` /
12265
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
13033
12266
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
13034
12267
  * `getTrack`. Mirrors the event-store `projection` convention
13035
12268
  * (`getObjectEvents` et al.).
@@ -13266,6 +12499,24 @@ var TrackSchema = zod.z.object({
13266
12499
  * Populated from the persisted envelope columns on historical reads;
13267
12500
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
13268
12501
  envelope: TrackEnvelopeSchema.optional(),
12502
+ /**
12503
+ * A face DETECTOR found a face on this track — nothing more. It says the
12504
+ * detail plane produced a `face` detail; it does NOT say the face was
12505
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
12506
+ * enabled. Set once and never cleared.
12507
+ *
12508
+ * **This exists so "face present but not recognised" is expressible.** A
12509
+ * recognised identity lands in `subLabel` (attributed to the face chain via
12510
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
12511
+ * and a track with no face at all were byte-identical on the wire and no
12512
+ * surface could tell them apart. The read is `hasFace === true && subLabel
12513
+ * === undefined`.
12514
+ *
12515
+ * **Absent ≠ false.** Every row written before the column existed omits it,
12516
+ * and so does every server that predates the field — a consumer must test
12517
+ * `=== true` and render nothing otherwise, never infer "no face".
12518
+ */
12519
+ hasFace: zod.z.boolean().optional(),
13269
12520
  ...TrackFlagFields,
13270
12521
  ...TrackRetrainFields
13271
12522
  });
@@ -17015,6 +16266,10 @@ var SsoBridgeClaimsSchema = zod.z.object({
17015
16266
  integrationId: zod.z.string().optional(),
17016
16267
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
17017
16268
  jti: zod.z.string().optional(),
16269
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
16270
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
16271
+ * so the requirement travels with the code and not with mutable config. */
16272
+ codeChallenge: zod.z.string().optional(),
17018
16273
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
17019
16274
  * tokens so the verify path can check the session is not revoked. */
17020
16275
  sessionId: zod.z.string().optional()
@@ -17771,6 +17026,10 @@ var videoclipsCapability = {
17771
17026
  mode: "singleton",
17772
17027
  kind: "wrapper",
17773
17028
  defaultActive: true,
17029
+ /** A clip is a window over a camera's footage — the cap is meaningless on a
17030
+ * sensor, a button or an event emitter, and the `defaultActive` auto-bind
17031
+ * reads this to decide which devices it may claim. */
17032
+ deviceTypes: [DeviceType.Camera],
17774
17033
  methods: {
17775
17034
  listClips: method(zod.z.object({
17776
17035
  deviceId: zod.z.number(),
@@ -25697,14 +24956,19 @@ var userManagementCapability = {
25697
24956
  username: zod.z.string(),
25698
24957
  scopes: zod.z.array(TokenScopeSchema),
25699
24958
  redirectUri: zod.z.string(),
25700
- hubUrl: zod.z.string()
24959
+ hubUrl: zod.z.string(),
24960
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24961
+ * that carries one can ONLY be exchanged with the matching verifier. */
24962
+ codeChallenge: zod.z.string().optional()
25701
24963
  }), zod.z.object({ code: zod.z.string() }), {
25702
24964
  kind: "mutation",
25703
24965
  access: "create"
25704
24966
  }),
25705
24967
  oauthExchangeCode: method(zod.z.object({
25706
24968
  code: zod.z.string(),
25707
- redirectUri: zod.z.string()
24969
+ redirectUri: zod.z.string(),
24970
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
24971
+ codeVerifier: zod.z.string().optional()
25708
24972
  }), zod.z.object({
25709
24973
  accessToken: zod.z.string(),
25710
24974
  refreshToken: zod.z.string(),
@@ -26246,277 +25510,872 @@ var zoneAnalyticsCapability = {
26246
25510
  runtimeState: CameraOccupancySnapshotSchema
26247
25511
  };
26248
25512
  /**
26249
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
26250
- * cap so a single CRUD surface backs every consumer; each stage has
26251
- * its own dev-state mirror slice (`motion-zone-rules`,
26252
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
26253
- *
26254
- * Extend the enum here when a new gating consumer comes online (audio
26255
- * gating, alert filtering, …) — no other surface needs to change.
25513
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
25514
+ * cap so a single CRUD surface backs every consumer; each stage has
25515
+ * its own dev-state mirror slice (`motion-zone-rules`,
25516
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
25517
+ *
25518
+ * Extend the enum here when a new gating consumer comes online (audio
25519
+ * gating, alert filtering, …) — no other surface needs to change.
25520
+ */
25521
+ var ZoneRuleStageEnum = zod.z.enum([
25522
+ "motion",
25523
+ "detection",
25524
+ "package"
25525
+ ]);
25526
+ /**
25527
+ * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
25528
+ * arrays that decide how each pipeline stage uses the polygon zones.
25529
+ *
25530
+ * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
25531
+ * so the operator has a single hub-side source of truth for both
25532
+ * geometry and behaviour. Per-stage rules are stored under the
25533
+ * `zoneRules.<stage>` key in the orchestrator's per-device store and
25534
+ * mirrored to the device-state slice `<stage>-zone-rules` on every
25535
+ * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
25536
+ * subscribe to that slice and refresh their gating without
25537
+ * round-tripping the cap.
25538
+ *
25539
+ * Sets are bulk-replace — the operator UI sends the new rule list
25540
+ * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
25541
+ * naturally in the rule editor without per-rule mutation chatter.
25542
+ */
25543
+ var zoneRulesCapability = {
25544
+ name: "zone-rules",
25545
+ scope: "device",
25546
+ mode: "singleton",
25547
+ deviceTypes: [DeviceType.Camera],
25548
+ methods: {
25549
+ /** Read the full rule list for a given stage (empty when no rules
25550
+ * are defined yet). */
25551
+ listRules: method(zod.z.object({
25552
+ deviceId: zod.z.number(),
25553
+ stage: ZoneRuleStageEnum
25554
+ }), zod.z.array(ZoneRuleSchema).readonly()),
25555
+ /** Bulk-replace the rule list for one stage. The provider validates
25556
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
25557
+ * thresholds in range) and rejects the whole patch if any entry
25558
+ * is invalid — partial writes are a configuration footgun. */
25559
+ setRules: method(zod.z.object({
25560
+ deviceId: zod.z.number(),
25561
+ stage: ZoneRuleStageEnum,
25562
+ rules: zod.z.array(ZoneRuleSchema).readonly()
25563
+ }), zod.z.void(), {
25564
+ kind: "mutation",
25565
+ auth: "admin"
25566
+ })
25567
+ },
25568
+ /**
25569
+ * Runtime-state slice — every stage mirrored together so consumers
25570
+ * see one reactive handle (`device.state.zoneRules.value`) instead
25571
+ * of one per stage. Bulk-replace mutations on any stage write the full
25572
+ * `{motion, detection, package}` shape, so subscribers always get the
25573
+ * complete current set. Consumers that only care about one stage
25574
+ * just read the matching property.
25575
+ *
25576
+ * `package` backs the package-drop detector — a package zone is a
25577
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
25578
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
25579
+ * The orchestrator provider writes this stage as a first-class slice
25580
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
25581
+ * package}` shape, so consumers read the current package rules directly
25582
+ * off `device.state.zoneRules.value.package`.
25583
+ */
25584
+ runtimeState: zod.z.object({
25585
+ motion: zod.z.array(ZoneRuleSchema).readonly(),
25586
+ detection: zod.z.array(ZoneRuleSchema).readonly(),
25587
+ package: zod.z.array(ZoneRuleSchema).readonly()
25588
+ })
25589
+ };
25590
+ /**
25591
+ * Runtime defaults -- used by ConfigManager.get() for backward compatibility
25592
+ * until Plan B wires all runtime settings to the system_settings SQL table.
25593
+ *
25594
+ * Moved from @camstack/system/config-schema to @camstack/types so that
25595
+ * @camstack/system can reference it without a cross-package kernel import.
25596
+ */
25597
+ var RUNTIME_DEFAULTS = {
25598
+ "features.streaming": true,
25599
+ "features.notifications": true,
25600
+ "features.objectDetection": false,
25601
+ "features.remoteAccess": true,
25602
+ "features.agentCluster": false,
25603
+ "features.smartHome": true,
25604
+ "features.recordings": true,
25605
+ "features.backup": true,
25606
+ "features.repl": true,
25607
+ "retention.detectionEventsDays": 30,
25608
+ "retention.audioLevelsDays": 7,
25609
+ "logging.level": "info",
25610
+ "logging.retentionDays": 30,
25611
+ "eventBus.ringBufferSize": 1e4,
25612
+ "storage.provider": "sqlite-storage",
25613
+ "storage.locations": {
25614
+ data: "camstack-data/data",
25615
+ media: "camstack-data/media",
25616
+ recordings: "camstack-data/recordings",
25617
+ cache: "/tmp/camstack-cache",
25618
+ logs: "camstack-data/logs",
25619
+ models: "camstack-data/models"
25620
+ },
25621
+ providers: [],
25622
+ "ffmpeg.binaryPath": "ffmpeg",
25623
+ "ffmpeg.hwAccel": "auto",
25624
+ "ffmpeg.threadCount": 0,
25625
+ "auth.tokenExpiry": "7d"
25626
+ };
25627
+ /**
25628
+ * Accessory device helpers — shared across drivers.
25629
+ *
25630
+ * Many vendor-specific drivers register accessory child devices on
25631
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
25632
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
25633
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
25634
+ * spawning, builds a name derived from the parent, and produces a
25635
+ * stableId tied to the parent so boot-restore can reconstruct the
25636
+ * relationship.
25637
+ *
25638
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
25639
+ * drivers may reasonably disagree on the right type for an accessory
25640
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
25641
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
25642
+ * one canonical mapping was over-prescriptive and added a layer of
25643
+ * indirection without saving meaningful code at call sites — the
25644
+ * driver knows its own hardware best.
25645
+ */
25646
+ /**
25647
+ * Subset of `DeviceRole` values that drivers register as child
25648
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
25649
+ * — `AccessoryKind` is the alias drivers use when building accessory
25650
+ * children, so the call site reads as
25651
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
25652
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
25653
+ * any role works, including non-accessory ones like Doorbell).
25654
+ */
25655
+ var AccessoryKind = {
25656
+ Siren: DeviceRole.Siren,
25657
+ Floodlight: DeviceRole.Floodlight,
25658
+ Spotlight: DeviceRole.Spotlight,
25659
+ PirSensor: DeviceRole.PirSensor,
25660
+ Chime: DeviceRole.Chime,
25661
+ Autotrack: DeviceRole.Autotrack,
25662
+ Nightvision: DeviceRole.Nightvision,
25663
+ PrivacyMask: DeviceRole.PrivacyMask
25664
+ };
25665
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
25666
+ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
25667
+ new Set(Object.values(DeviceType));
25668
+ DeviceFeature.BatteryOperated;
25669
+ /** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner type.
25670
+ * Zod v4 exposes `.unwrap()` on all three wrapper classes. */
25671
+ function unwrap(schema) {
25672
+ let s = schema;
25673
+ while (s instanceof zod.z.ZodNullable || s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodDefault) s = s.unwrap();
25674
+ return s;
25675
+ }
25676
+ function leafKind(schema) {
25677
+ const s = unwrap(schema);
25678
+ if (s instanceof zod.z.ZodEnum) return {
25679
+ kind: "enum",
25680
+ enumValues: s.options
25681
+ };
25682
+ if (s instanceof zod.z.ZodNumber) return { kind: "number" };
25683
+ if (s instanceof zod.z.ZodString) return { kind: "string" };
25684
+ if (s instanceof zod.z.ZodBoolean) return { kind: "boolean" };
25685
+ return null;
25686
+ }
25687
+ /** Walk a cap status schema into flat wireable leaf fields (dotted paths).
25688
+ * Recurses into nested ZodObject (unwrapping nullable/optional/default first),
25689
+ * so fields with null live values are still offered. Arrays / unknown shapes
25690
+ * are skipped — never throws. */
25691
+ function enumerateSchemaFields(schema, prefix = "") {
25692
+ const root = unwrap(schema);
25693
+ if (!(root instanceof zod.z.ZodObject)) return [];
25694
+ const out = [];
25695
+ const shape = root.shape;
25696
+ for (const [key, field] of Object.entries(shape)) {
25697
+ const path = prefix ? `${prefix}.${key}` : key;
25698
+ const inner = unwrap(field);
25699
+ if (inner instanceof zod.z.ZodObject) {
25700
+ out.push(...enumerateSchemaFields(inner, path));
25701
+ continue;
25702
+ }
25703
+ const leaf = leafKind(field);
25704
+ if (leaf) out.push({
25705
+ path,
25706
+ kind: leaf.kind,
25707
+ ...leaf.enumValues ? { enumValues: leaf.enumValues } : {}
25708
+ });
25709
+ }
25710
+ return out;
25711
+ }
25712
+ /** Enumerate the per-item wireable fields of an item-array cap (see
25713
+ * `CapabilityStatusItemArray`): the item schema's leaf fields, minus the
25714
+ * `keyField` (the key comes from the link's `itemKey`, never from a wired
25715
+ * source), each tagged `item: true` so the authoring UI collects an
25716
+ * `itemKey` alongside the field. Never throws. */
25717
+ function enumerateItemArrayFields(itemArray) {
25718
+ return enumerateSchemaFields(itemArray.itemSchema).filter((f) => f.path !== itemArray.keyField).map((f) => ({
25719
+ ...f,
25720
+ item: true
25721
+ }));
25722
+ }
25723
+ /**
25724
+ * Error types for the safe expression engine. Two distinct classes so callers
25725
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
25726
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
26256
25727
  */
26257
- var ZoneRuleStageEnum = zod.z.enum([
26258
- "motion",
26259
- "detection",
26260
- "package"
26261
- ]);
25728
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
25729
+ * the failure is anchored to a character (author-facing inline feedback). */
25730
+ var ExpressionParseError = class extends Error {
25731
+ position;
25732
+ constructor(message, position) {
25733
+ super(message);
25734
+ this.name = "ExpressionParseError";
25735
+ this.position = position;
25736
+ }
25737
+ };
25738
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
25739
+ * result, unknown builtin, step-budget exceeded). */
25740
+ var ExpressionEvalError = class extends Error {
25741
+ constructor(message) {
25742
+ super(message);
25743
+ this.name = "ExpressionEvalError";
25744
+ }
25745
+ };
26262
25746
  /**
26263
- * Zone rules capability per-camera CRUD over the {@link ZoneRule}
26264
- * arrays that decide how each pipeline stage uses the polygon zones.
25747
+ * Frozen, null-prototype builtin function table for the expression engine
25748
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
25749
+ * parser rejects any callee not in it, and the evaluator gates each call on an
25750
+ * own-property check against it.
26265
25751
  *
26266
- * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
26267
- * so the operator has a single hub-side source of truth for both
26268
- * geometry and behaviour. Per-stage rules are stored under the
26269
- * `zoneRules.<stage>` key in the orchestrator's per-device store and
26270
- * mirrored to the device-state slice `<stage>-zone-rules` on every
26271
- * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
26272
- * subscribe to that slice and refresh their gating without
26273
- * round-tripping the cap.
25752
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
25753
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
25754
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
25755
+ * (there is no `Object.prototype` in the chain), so those names are not
25756
+ * callable they are simply "unknown function" at parse time.
26274
25757
  *
26275
- * Sets are bulk-replace the operator UI sends the new rule list
26276
- * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
26277
- * naturally in the rule editor without per-rule mutation chatter.
25758
+ * Every numeric argument is validated as a finite number and every numeric
25759
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
25760
+ * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
25761
+ * closed rather than emitting a garbage value.
26278
25762
  */
26279
- var zoneRulesCapability = {
26280
- name: "zone-rules",
26281
- scope: "device",
26282
- mode: "singleton",
26283
- deviceTypes: [DeviceType.Camera],
26284
- methods: {
26285
- /** Read the full rule list for a given stage (empty when no rules
26286
- * are defined yet). */
26287
- listRules: method(zod.z.object({
26288
- deviceId: zod.z.number(),
26289
- stage: ZoneRuleStageEnum
26290
- }), zod.z.array(ZoneRuleSchema).readonly()),
26291
- /** Bulk-replace the rule list for one stage. The provider validates
26292
- * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
26293
- * thresholds in range) and rejects the whole patch if any entry
26294
- * is invalid partial writes are a configuration footgun. */
26295
- setRules: method(zod.z.object({
26296
- deviceId: zod.z.number(),
26297
- stage: ZoneRuleStageEnum,
26298
- rules: zod.z.array(ZoneRuleSchema).readonly()
26299
- }), zod.z.void(), {
26300
- kind: "mutation",
26301
- auth: "admin"
26302
- })
25763
+ function asFiniteNumber(value, name, index) {
25764
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
25765
+ return value;
25766
+ }
25767
+ function asString$1(value, name, index) {
25768
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
25769
+ return value;
25770
+ }
25771
+ function finiteResult(value, name) {
25772
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
25773
+ return value;
25774
+ }
25775
+ function allFiniteNumbers(args, name) {
25776
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
25777
+ }
25778
+ var INF = Number.POSITIVE_INFINITY;
25779
+ var table = {
25780
+ min: {
25781
+ minArgs: 1,
25782
+ maxArgs: INF,
25783
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
26303
25784
  },
26304
- /**
26305
- * Runtime-state slice — every stage mirrored together so consumers
26306
- * see one reactive handle (`device.state.zoneRules.value`) instead
26307
- * of one per stage. Bulk-replace mutations on any stage write the full
26308
- * `{motion, detection, package}` shape, so subscribers always get the
26309
- * complete current set. Consumers that only care about one stage
26310
- * just read the matching property.
26311
- *
26312
- * `package` backs the package-drop detector — a package zone is a
26313
- * `ZoneRule` on the `'package'` stage referencing drawn polygons
26314
- * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
26315
- * The orchestrator provider writes this stage as a first-class slice
26316
- * (Phase 4): every mutation mirrors the full `{motion, detection,
26317
- * package}` shape, so consumers read the current package rules directly
26318
- * off `device.state.zoneRules.value.package`.
26319
- */
26320
- runtimeState: zod.z.object({
26321
- motion: zod.z.array(ZoneRuleSchema).readonly(),
26322
- detection: zod.z.array(ZoneRuleSchema).readonly(),
26323
- package: zod.z.array(ZoneRuleSchema).readonly()
26324
- })
25785
+ max: {
25786
+ minArgs: 1,
25787
+ maxArgs: INF,
25788
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
25789
+ },
25790
+ abs: {
25791
+ minArgs: 1,
25792
+ maxArgs: 1,
25793
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
25794
+ },
25795
+ floor: {
25796
+ minArgs: 1,
25797
+ maxArgs: 1,
25798
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
25799
+ },
25800
+ ceil: {
25801
+ minArgs: 1,
25802
+ maxArgs: 1,
25803
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
25804
+ },
25805
+ sqrt: {
25806
+ minArgs: 1,
25807
+ maxArgs: 1,
25808
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
25809
+ },
25810
+ round: {
25811
+ minArgs: 1,
25812
+ maxArgs: 2,
25813
+ apply: (args) => {
25814
+ const x = asFiniteNumber(args[0], "round", 0);
25815
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
25816
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
25817
+ const factor = 10 ** digits;
25818
+ return finiteResult(Math.round(x * factor) / factor, "round");
25819
+ }
25820
+ },
25821
+ pow: {
25822
+ minArgs: 2,
25823
+ maxArgs: 2,
25824
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
25825
+ },
25826
+ clamp: {
25827
+ minArgs: 3,
25828
+ maxArgs: 3,
25829
+ apply: (args) => {
25830
+ const x = asFiniteNumber(args[0], "clamp", 0);
25831
+ const lo = asFiniteNumber(args[1], "clamp", 1);
25832
+ const hi = asFiniteNumber(args[2], "clamp", 2);
25833
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
25834
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
25835
+ }
25836
+ },
25837
+ avg: {
25838
+ minArgs: 1,
25839
+ maxArgs: INF,
25840
+ apply: (args) => {
25841
+ const nums = allFiniteNumbers(args, "avg");
25842
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
25843
+ }
25844
+ },
25845
+ sum: {
25846
+ minArgs: 1,
25847
+ maxArgs: INF,
25848
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
25849
+ },
25850
+ coalesce: {
25851
+ minArgs: 1,
25852
+ maxArgs: INF,
25853
+ apply: (args) => {
25854
+ for (const a of args) if (a !== null) return a;
25855
+ return null;
25856
+ }
25857
+ },
25858
+ age: {
25859
+ minArgs: 2,
25860
+ maxArgs: 2,
25861
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
25862
+ },
25863
+ convert: {
25864
+ minArgs: 3,
25865
+ maxArgs: 3,
25866
+ apply: (args, hooks) => {
25867
+ const x = asFiniteNumber(args[0], "convert", 0);
25868
+ const from = asString$1(args[1], "convert", 1).trim();
25869
+ const to = asString$1(args[2], "convert", 2).trim();
25870
+ if (hooks.convert) {
25871
+ const out = hooks.convert(x, from, to);
25872
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
25873
+ return finiteResult(out, "convert");
25874
+ }
25875
+ if (from === to) return x;
25876
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
25877
+ }
25878
+ }
26325
25879
  };
25880
+ Object.freeze(Object.assign(Object.create(null), table));
25881
+ /** The set of valid builtin names — used by the parser to reject unknown
25882
+ * callees at parse time (immediate author feedback). */
25883
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
26326
25884
  /**
26327
- * Runtime defaults -- used by ConfigManager.get() for backward compatibility
26328
- * until Plan B wires all runtime settings to the system_settings SQL table.
25885
+ * Resource-bound constants for the safe expression engine.
26329
25886
  *
26330
- * Moved from @camstack/system/config-schema to @camstack/types so that
26331
- * @camstack/system can reference it without a cross-package kernel import.
25887
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
25888
+ * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
25889
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
25890
+ * work a single author-supplied expression can request, so a hostile or
25891
+ * accidental pathological string can never spend unbounded CPU/memory.
26332
25892
  */
26333
- var RUNTIME_DEFAULTS = {
26334
- "features.streaming": true,
26335
- "features.notifications": true,
26336
- "features.objectDetection": false,
26337
- "features.remoteAccess": true,
26338
- "features.agentCluster": false,
26339
- "features.smartHome": true,
26340
- "features.recordings": true,
26341
- "features.backup": true,
26342
- "features.repl": true,
26343
- "retention.detectionEventsDays": 30,
26344
- "retention.audioLevelsDays": 7,
26345
- "logging.level": "info",
26346
- "logging.retentionDays": 30,
26347
- "eventBus.ringBufferSize": 1e4,
26348
- "storage.provider": "sqlite-storage",
26349
- "storage.locations": {
26350
- data: "camstack-data/data",
26351
- media: "camstack-data/media",
26352
- recordings: "camstack-data/recordings",
26353
- cache: "/tmp/camstack-cache",
26354
- logs: "camstack-data/logs",
26355
- models: "camstack-data/models"
26356
- },
26357
- providers: [],
26358
- "ffmpeg.binaryPath": "ffmpeg",
26359
- "ffmpeg.hwAccel": "auto",
26360
- "ffmpeg.threadCount": 0,
26361
- "auth.tokenExpiry": "7d"
26362
- };
25893
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
25894
+ * rejected without allocation. */
25895
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
25896
+ /** A legal binding / identifier name. */
25897
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
25898
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
25899
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
25900
+ var RESERVED_BINDING_NAMES = new Set([
25901
+ "now",
25902
+ "true",
25903
+ "false",
25904
+ "null"
25905
+ ]);
26363
25906
  /**
26364
- * Accessory device helpers shared across drivers.
26365
- *
26366
- * Many vendor-specific drivers register accessory child devices on
26367
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
26368
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
26369
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
26370
- * spawning, builds a name derived from the parent, and produces a
26371
- * stableId tied to the parent so boot-restore can reconstruct the
26372
- * relationship.
26373
- *
26374
- * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
26375
- * drivers may reasonably disagree on the right type for an accessory
26376
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
26377
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
26378
- * one canonical mapping was over-prescriptive and added a layer of
26379
- * indirection without saving meaningful code at call sites — the
26380
- * driver knows its own hardware best.
25907
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
25908
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
25909
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
25910
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
25911
+ * outside that a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
25912
+ * is a parse error with a source position, so member access / assignment /
25913
+ * template literals are lexically impossible.
26381
25914
  */
25915
+ var KEYWORDS = new Set([
25916
+ "true",
25917
+ "false",
25918
+ "null"
25919
+ ]);
25920
+ function isDigit(ch) {
25921
+ return ch >= "0" && ch <= "9";
25922
+ }
25923
+ function isIdentStart(ch) {
25924
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
25925
+ }
25926
+ function isIdentPart(ch) {
25927
+ return isIdentStart(ch) || isDigit(ch);
25928
+ }
25929
+ function isWhitespace(ch) {
25930
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
25931
+ }
25932
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
25933
+ * Throws `ExpressionParseError` on any illegal character or unterminated
25934
+ * string. */
25935
+ function tokenize(source) {
25936
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
25937
+ const tokens = [];
25938
+ let i = 0;
25939
+ const n = source.length;
25940
+ while (i < n) {
25941
+ const ch = source[i];
25942
+ if (isWhitespace(ch)) {
25943
+ i += 1;
25944
+ continue;
25945
+ }
25946
+ if (isDigit(ch)) {
25947
+ const start = i;
25948
+ while (i < n && isDigit(source[i])) i += 1;
25949
+ if (i < n && source[i] === ".") {
25950
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
25951
+ i += 1;
25952
+ while (i < n && isDigit(source[i])) i += 1;
25953
+ }
25954
+ const text = source.slice(start, i);
25955
+ const value = Number(text);
25956
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
25957
+ tokens.push({
25958
+ type: "number",
25959
+ value,
25960
+ pos: start
25961
+ });
25962
+ continue;
25963
+ }
25964
+ if (ch === "'" || ch === "\"") {
25965
+ const quote = ch;
25966
+ const start = i;
25967
+ i += 1;
25968
+ let out = "";
25969
+ let closed = false;
25970
+ while (i < n) {
25971
+ const c = source[i];
25972
+ if (c === "\\") {
25973
+ const next = i + 1 < n ? source[i + 1] : "";
25974
+ if (next === "\\" || next === "'" || next === "\"") {
25975
+ out += next;
25976
+ i += 2;
25977
+ continue;
25978
+ }
25979
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
25980
+ }
25981
+ if (c === quote) {
25982
+ closed = true;
25983
+ i += 1;
25984
+ break;
25985
+ }
25986
+ out += c;
25987
+ i += 1;
25988
+ }
25989
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
25990
+ tokens.push({
25991
+ type: "string",
25992
+ value: out,
25993
+ pos: start
25994
+ });
25995
+ continue;
25996
+ }
25997
+ if (isIdentStart(ch)) {
25998
+ const start = i;
25999
+ while (i < n && isIdentPart(source[i])) i += 1;
26000
+ const text = source.slice(start, i);
26001
+ if (KEYWORDS.has(text)) tokens.push({
26002
+ type: "keyword",
26003
+ keyword: keywordOf(text),
26004
+ pos: start
26005
+ });
26006
+ else tokens.push({
26007
+ type: "identifier",
26008
+ name: text,
26009
+ pos: start
26010
+ });
26011
+ continue;
26012
+ }
26013
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
26014
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
26015
+ tokens.push({
26016
+ type: "punct",
26017
+ punct: two,
26018
+ pos: i
26019
+ });
26020
+ i += 2;
26021
+ continue;
26022
+ }
26023
+ if (isSinglePunct(ch)) {
26024
+ tokens.push({
26025
+ type: "punct",
26026
+ punct: ch,
26027
+ pos: i
26028
+ });
26029
+ i += 1;
26030
+ continue;
26031
+ }
26032
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
26033
+ }
26034
+ tokens.push({
26035
+ type: "eof",
26036
+ pos: n
26037
+ });
26038
+ return tokens;
26039
+ }
26040
+ function keywordOf(text) {
26041
+ if (text === "true") return "true";
26042
+ if (text === "false") return "false";
26043
+ return "null";
26044
+ }
26045
+ function isSinglePunct(ch) {
26046
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
26047
+ }
26382
26048
  /**
26383
- * Subset of `DeviceRole` values that drivers register as child
26384
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
26385
- * `AccessoryKind` is the alias drivers use when building accessory
26386
- * children, so the call site reads as
26387
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
26388
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
26389
- * any role works, including non-accessory ones like Doorbell).
26049
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
26050
+ *
26051
+ * Precedence (low high): ternary `?:` (right-assoc) `||` → `&&` → equality
26052
+ * relational additive → multiplicative → unary `! -` → call / primary.
26053
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
26054
+ * string validated against the builtin table at parse time, so an unknown
26055
+ * function is rejected immediately (author feedback) and a persisted expression
26056
+ * that references a since-removed builtin degrades at read.
26057
+ *
26058
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
26059
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
26390
26060
  */
26391
- var AccessoryKind = {
26392
- Siren: DeviceRole.Siren,
26393
- Floodlight: DeviceRole.Floodlight,
26394
- Spotlight: DeviceRole.Spotlight,
26395
- PirSensor: DeviceRole.PirSensor,
26396
- Chime: DeviceRole.Chime,
26397
- Autotrack: DeviceRole.Autotrack,
26398
- Nightvision: DeviceRole.Nightvision,
26399
- PrivacyMask: DeviceRole.PrivacyMask
26061
+ /** Binary/logical operator precedence (higher binds tighter). */
26062
+ var BINARY_PRECEDENCE = {
26063
+ "||": 1,
26064
+ "&&": 2,
26065
+ "==": 3,
26066
+ "!=": 3,
26067
+ "<": 4,
26068
+ "<=": 4,
26069
+ ">": 4,
26070
+ ">=": 4,
26071
+ "+": 5,
26072
+ "-": 5,
26073
+ "*": 6,
26074
+ "/": 6,
26075
+ "%": 6
26400
26076
  };
26401
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
26402
- DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
26403
- new Set(Object.values(DeviceType));
26404
- /** Apply a link transform to a resolved source value. `identity`/undefined
26405
- * pass through; `enum-map` remaps strings (fallback or null when unknown);
26406
- * `linear` does scale*x+offset with optional clamp (null for non-numbers). */
26407
- function applyTransform(value, transform) {
26408
- if (!transform || transform.kind === "identity") return value;
26409
- if (transform.kind === "enum-map") {
26410
- const key = String(value);
26411
- if (Object.prototype.hasOwnProperty.call(transform.mapping, key)) return transform.mapping[key];
26412
- return transform.fallback ?? null;
26077
+ function isLogicalOp(op) {
26078
+ return op === "&&" || op === "||";
26079
+ }
26080
+ function isBinaryOp(op) {
26081
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
26082
+ }
26083
+ var Parser = class {
26084
+ tokens;
26085
+ pos = 0;
26086
+ nodeCount = 0;
26087
+ identifiers = /* @__PURE__ */ new Set();
26088
+ callees = /* @__PURE__ */ new Set();
26089
+ constructor(tokens) {
26090
+ this.tokens = tokens;
26091
+ }
26092
+ parse() {
26093
+ const ast = this.parseTernary();
26094
+ const tok = this.peek();
26095
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
26096
+ return {
26097
+ ast,
26098
+ identifiers: this.identifiers,
26099
+ callees: this.callees,
26100
+ nodeCount: this.nodeCount
26101
+ };
26102
+ }
26103
+ peek() {
26104
+ return this.tokens[this.pos];
26105
+ }
26106
+ next() {
26107
+ return this.tokens[this.pos++];
26108
+ }
26109
+ /** Consume a punctuator token, erroring if the next token isn't it. */
26110
+ expectPunct(punct) {
26111
+ const tok = this.peek();
26112
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
26113
+ this.pos += 1;
26413
26114
  }
26414
- if (transform.kind === "linear") {
26415
- if (typeof value !== "number" || Number.isNaN(value)) return null;
26416
- let out = value * transform.scale + transform.offset;
26417
- if (transform.clamp) {
26418
- const min = transform.clamp[0];
26419
- const max = transform.clamp[1];
26420
- out = Math.min(max, Math.max(min, out));
26115
+ matchPunct(punct) {
26116
+ const tok = this.peek();
26117
+ if (tok.type === "punct" && tok.punct === punct) {
26118
+ this.pos += 1;
26119
+ return true;
26421
26120
  }
26422
- return out;
26121
+ return false;
26423
26122
  }
26424
- return value;
26425
- }
26426
- DeviceFeature.BatteryOperated;
26427
- /** Returns true when `x` is a non-null, non-array plain object. */
26428
- function isRecord(x) {
26429
- return x !== null && typeof x === "object" && !Array.isArray(x);
26430
- }
26431
- /** Read a dot-path (`a.b.c`) out of an object. Returns `undefined` for any
26432
- * missing/non-object segment — never throws. */
26433
- function getByPath(obj, path) {
26434
- const parts = path.split(".");
26435
- let cur = obj;
26436
- for (const part of parts) {
26437
- if (!isRecord(cur)) return void 0;
26438
- cur = cur[part];
26123
+ countNode() {
26124
+ this.nodeCount += 1;
26125
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
26439
26126
  }
26440
- return cur;
26441
- }
26442
- /** Immutably set a dot-path on an object, returning a new object. Intermediate
26443
- * objects are created when a segment is missing or null. Arrays are NOT
26444
- * traversed (P1 has no array-index syntax — see `itemKey` grouping in P2): if a
26445
- * segment resolves to an array it is replaced by a fresh object, so the array
26446
- * is discarded — the only caller (`mergeLinkedStatus`) re-validates the result
26447
- * with the target cap's Zod schema and rejects a malformed overlay.
26448
- * The return type `T` preserves the OUTER shape; the rewritten key is not
26449
- * narrowed, so callers should re-validate against the status schema. */
26450
- function setByPath(obj, path, value) {
26451
- const dotIndex = path.indexOf(".");
26452
- if (dotIndex === -1) return {
26453
- ...obj,
26454
- [path]: value
26455
- };
26456
- const head = path.slice(0, dotIndex);
26457
- const tail = path.slice(dotIndex + 1);
26458
- const child = obj[head];
26459
- const childObj = isRecord(child) ? child : {};
26460
- return {
26461
- ...obj,
26462
- [head]: setByPath(childObj, tail, value)
26463
- };
26464
- }
26465
- /** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner type.
26466
- * Zod v4 exposes `.unwrap()` on all three wrapper classes. */
26467
- function unwrap(schema) {
26468
- let s = schema;
26469
- while (s instanceof zod.z.ZodNullable || s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodDefault) s = s.unwrap();
26470
- return s;
26471
- }
26472
- function leafKind(schema) {
26473
- const s = unwrap(schema);
26474
- if (s instanceof zod.z.ZodEnum) return {
26475
- kind: "enum",
26476
- enumValues: s.options
26477
- };
26478
- if (s instanceof zod.z.ZodNumber) return { kind: "number" };
26479
- if (s instanceof zod.z.ZodString) return { kind: "string" };
26480
- if (s instanceof zod.z.ZodBoolean) return { kind: "boolean" };
26481
- return null;
26482
- }
26483
- /** Walk a cap status schema into flat wireable leaf fields (dotted paths).
26484
- * Recurses into nested ZodObject (unwrapping nullable/optional/default first),
26485
- * so fields with null live values are still offered. Arrays / unknown shapes
26486
- * are skipped — never throws. */
26487
- function enumerateSchemaFields(schema, prefix = "") {
26488
- const root = unwrap(schema);
26489
- if (!(root instanceof zod.z.ZodObject)) return [];
26490
- const out = [];
26491
- const shape = root.shape;
26492
- for (const [key, field] of Object.entries(shape)) {
26493
- const path = prefix ? `${prefix}.${key}` : key;
26494
- const inner = unwrap(field);
26495
- if (inner instanceof zod.z.ZodObject) {
26496
- out.push(...enumerateSchemaFields(inner, path));
26497
- continue;
26127
+ parseTernary() {
26128
+ const test = this.parseBinary(1);
26129
+ if (this.matchPunct("?")) {
26130
+ const consequent = this.parseTernary();
26131
+ this.expectPunct(":");
26132
+ const alternate = this.parseTernary();
26133
+ this.countNode();
26134
+ return {
26135
+ kind: "conditional",
26136
+ test,
26137
+ consequent,
26138
+ alternate
26139
+ };
26498
26140
  }
26499
- const leaf = leafKind(field);
26500
- if (leaf) out.push({
26501
- path,
26502
- kind: leaf.kind,
26503
- ...leaf.enumValues ? { enumValues: leaf.enumValues } : {}
26504
- });
26141
+ return test;
26142
+ }
26143
+ parseBinary(minPrec) {
26144
+ let left = this.parseUnary();
26145
+ for (;;) {
26146
+ const tok = this.peek();
26147
+ if (tok.type !== "punct") break;
26148
+ const prec = BINARY_PRECEDENCE[tok.punct];
26149
+ if (prec === void 0 || prec < minPrec) break;
26150
+ const op = tok.punct;
26151
+ this.pos += 1;
26152
+ const right = this.parseBinary(prec + 1);
26153
+ this.countNode();
26154
+ if (isLogicalOp(op)) left = {
26155
+ kind: "logical",
26156
+ op,
26157
+ left,
26158
+ right
26159
+ };
26160
+ else if (isBinaryOp(op)) left = {
26161
+ kind: "binary",
26162
+ op,
26163
+ left,
26164
+ right
26165
+ };
26166
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
26167
+ }
26168
+ return left;
26169
+ }
26170
+ parseUnary() {
26171
+ const tok = this.peek();
26172
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
26173
+ const op = tok.punct;
26174
+ this.pos += 1;
26175
+ const operand = this.parseUnary();
26176
+ this.countNode();
26177
+ return {
26178
+ kind: "unary",
26179
+ op,
26180
+ operand
26181
+ };
26182
+ }
26183
+ return this.parsePrimary();
26184
+ }
26185
+ parsePrimary() {
26186
+ const tok = this.next();
26187
+ switch (tok.type) {
26188
+ case "number":
26189
+ this.countNode();
26190
+ return {
26191
+ kind: "literal",
26192
+ value: tok.value
26193
+ };
26194
+ case "string":
26195
+ this.countNode();
26196
+ return {
26197
+ kind: "literal",
26198
+ value: tok.value
26199
+ };
26200
+ case "keyword":
26201
+ this.countNode();
26202
+ return {
26203
+ kind: "literal",
26204
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
26205
+ };
26206
+ case "identifier": {
26207
+ const nextTok = this.peek();
26208
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
26209
+ this.identifiers.add(tok.name);
26210
+ this.countNode();
26211
+ return {
26212
+ kind: "identifier",
26213
+ name: tok.name
26214
+ };
26215
+ }
26216
+ case "punct":
26217
+ if (tok.punct === "(") {
26218
+ const inner = this.parseTernary();
26219
+ this.expectPunct(")");
26220
+ return inner;
26221
+ }
26222
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
26223
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
26224
+ }
26225
+ }
26226
+ parseCall(callee, pos) {
26227
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
26228
+ this.expectPunct("(");
26229
+ const args = [];
26230
+ if (!this.matchPunct(")")) for (;;) {
26231
+ args.push(this.parseTernary());
26232
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
26233
+ if (this.matchPunct(",")) continue;
26234
+ this.expectPunct(")");
26235
+ break;
26236
+ }
26237
+ this.callees.add(callee);
26238
+ this.countNode();
26239
+ return {
26240
+ kind: "call",
26241
+ callee,
26242
+ args
26243
+ };
26244
+ }
26245
+ };
26246
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
26247
+ * `ExpressionParseError` on any lexical or grammatical failure. */
26248
+ function parseExpression(source) {
26249
+ return new Parser(tokenize(source)).parse();
26250
+ }
26251
+ /**
26252
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
26253
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
26254
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
26255
+ * one per read on a hot resolve path.
26256
+ *
26257
+ * The cache is a module-level singleton: entries are pure, content-addressed
26258
+ * ASTs keyed by the raw source string, so sharing one instance across all
26259
+ * callers is safe and maximises hit rate.
26260
+ */
26261
+ var cache = /* @__PURE__ */ new Map();
26262
+ function getCached(source) {
26263
+ const hit = cache.get(source);
26264
+ if (hit !== void 0) {
26265
+ cache.delete(source);
26266
+ cache.set(source, hit);
26267
+ return hit;
26505
26268
  }
26506
- return out;
26269
+ let result;
26270
+ try {
26271
+ result = {
26272
+ ok: true,
26273
+ parsed: parseExpression(source)
26274
+ };
26275
+ } catch (err) {
26276
+ result = {
26277
+ ok: false,
26278
+ error: err instanceof ExpressionParseError ? err.message : String(err)
26279
+ };
26280
+ }
26281
+ cache.set(source, result);
26282
+ if (cache.size > 256) {
26283
+ const oldest = cache.keys().next().value;
26284
+ if (oldest !== void 0) cache.delete(oldest);
26285
+ }
26286
+ return result;
26507
26287
  }
26508
- /** Enumerate the per-item wireable fields of an item-array cap (see
26509
- * `CapabilityStatusItemArray`): the item schema's leaf fields, minus the
26510
- * `keyField` (the key comes from the link's `itemKey`, never from a wired
26511
- * source), each tagged `item: true` so the authoring UI collects an
26512
- * `itemKey` alongside the field. Never throws. */
26513
- function enumerateItemArrayFields(itemArray) {
26514
- return enumerateSchemaFields(itemArray.itemSchema).filter((f) => f.path !== itemArray.keyField).map((f) => ({
26515
- ...f,
26516
- item: true
26517
- }));
26288
+ /** Compile `source`, returning a discriminated result instead of throwing.
26289
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
26290
+ function compileExpressionSafe(source) {
26291
+ return getCached(source);
26292
+ }
26293
+ Object.freeze({});
26294
+ /**
26295
+ * Author-time validation. Returns `null` when the source is valid, else a
26296
+ * human-readable error message. Checks: the expression compiles; binding count
26297
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
26298
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
26299
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
26300
+ */
26301
+ function validateExpressionSource(src) {
26302
+ const names = Object.keys(src.bindings);
26303
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
26304
+ for (const name of names) {
26305
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
26306
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
26307
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
26308
+ }
26309
+ const compiled = compileExpressionSafe(src.expr);
26310
+ if (!compiled.ok) return compiled.error;
26311
+ const bound = new Set(names);
26312
+ for (const id of compiled.parsed.identifiers) {
26313
+ if (id === "now") continue;
26314
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
26315
+ }
26316
+ return null;
26518
26317
  }
26519
26318
  /**
26319
+ * What an expression's named bindings READ from.
26320
+ *
26321
+ * Salvaged verbatim from the deleted device-link mechanism. Wiring's source
26322
+ * kinds were the one part of it worth keeping: addressing a device field by
26323
+ * re-sync-stable `stableId`, a per-device constant, and a sibling-accessory
26324
+ * read are the vocabulary any cross-device derivation needs, and they were
26325
+ * already correct. What wiring got wrong was the DESTINATION — a field on
26326
+ * somebody else's device, with no identity — not the source.
26327
+ *
26328
+ * These shapes are therefore kept, re-homed next to the engine that consumes
26329
+ * them, and are the binding type of a composition recipe (the source picker
26330
+ * stays `deviceManager.getWireableFields`). They deliberately do NOT nest: a
26331
+ * binding is a read, never another expression.
26332
+ *
26333
+ * Schemas are authoritative; every type is `z.infer` of one, so a wire shape and
26334
+ * a TypeScript shape cannot drift apart (`scripts/check-schema-type-twins.ts`).
26335
+ */
26336
+ /** Read a sibling accessory's status field, addressed by the sibling's key.
26337
+ * `kind` is optional for wire compatibility — absent means `'field'`. */
26338
+ var ExpressionFieldBindingSchema = zod.z.object({
26339
+ kind: zod.z.literal("field").optional(),
26340
+ sourceKey: zod.z.string(),
26341
+ cap: zod.z.string(),
26342
+ fieldPath: zod.z.string()
26343
+ });
26344
+ /** A constant. No device is read. */
26345
+ var ExpressionLiteralBindingSchema = zod.z.object({
26346
+ kind: zod.z.literal("literal"),
26347
+ value: zod.z.union([
26348
+ zod.z.string(),
26349
+ zod.z.number(),
26350
+ zod.z.boolean(),
26351
+ zod.z.null()
26352
+ ])
26353
+ });
26354
+ /** Read ANY device's status field, addressed by its re-sync-stable `stableId` —
26355
+ * never by numeric id, which a re-adoption reissues. */
26356
+ var ExpressionGlobalBindingSchema = zod.z.object({
26357
+ kind: zod.z.literal("global"),
26358
+ sourceStableId: zod.z.string(),
26359
+ cap: zod.z.string(),
26360
+ fieldPath: zod.z.string()
26361
+ });
26362
+ var ExpressionBindingSourceSchema = zod.z.union([
26363
+ ExpressionFieldBindingSchema,
26364
+ ExpressionLiteralBindingSchema,
26365
+ ExpressionGlobalBindingSchema
26366
+ ]);
26367
+ zod.z.object({
26368
+ expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
26369
+ bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
26370
+ }).superRefine((src, ctx) => {
26371
+ const err = validateExpressionSource(src);
26372
+ if (err !== null) ctx.addIssue({
26373
+ code: "custom",
26374
+ message: err,
26375
+ path: ["expr"]
26376
+ });
26377
+ });
26378
+ /**
26520
26379
  * Runtime list of cap names with status. Used by the settings
26521
26380
  * aggregator to enumerate caps whose `status` should be polled +
26522
26381
  * streamed via `subscribeDeviceStatusAggregate`.
@@ -27572,6 +27431,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
27572
27431
  addonId: null,
27573
27432
  access: "view"
27574
27433
  },
27434
+ "coreBlocks.restart": {
27435
+ capName: "core-blocks",
27436
+ capScope: "system",
27437
+ addonId: null,
27438
+ access: "create"
27439
+ },
27575
27440
  "coreBlocks.setEnabled": {
27576
27441
  capName: "core-blocks",
27577
27442
  capScope: "system",
@@ -28208,12 +28073,6 @@ var METHOD_ACCESS_MAP = Object.freeze({
28208
28073
  addonId: null,
28209
28074
  access: "create"
28210
28075
  },
28211
- "deviceManager.setDeviceLinks": {
28212
- capName: "device-manager",
28213
- capScope: "system",
28214
- addonId: null,
28215
- access: "create"
28216
- },
28217
28076
  "deviceManager.setDisabled": {
28218
28077
  capName: "device-manager",
28219
28078
  capScope: "system",
@@ -32250,60 +32109,6 @@ TimelapseRuleInputSchema.extend({
32250
32109
  createdAt: zod.z.number(),
32251
32110
  updatedAt: zod.z.number()
32252
32111
  });
32253
- /**
32254
- * The addon id as the REGISTRY, the wire and the durable stores spell it.
32255
- *
32256
- * `AddonContext.id` does not agree with itself across the two context
32257
- * factories:
32258
- *
32259
- * - a FORKED addon gets the bare manifest id
32260
- * (`kernel/moleculer/addon-context-factory.ts` → `id: addonId`);
32261
- * - an addon co-located in hub-main gets it PREFIXED
32262
- * (`server/backend/src/core/addon/addon-registry.service.ts` →
32263
- * ``id: `addon:${addonId}` ``).
32264
- *
32265
- * Everything an addon might compare `ctx.id` AGAINST carries the bare form:
32266
- * `DeviceBindingEntry.providerAddonId`, the `device-manager` bindings store's
32267
- * `wrapperAddonId`, `CapabilityRegistry` provider keys, manifest ids.
32268
- *
32269
- * So `entry.providerAddonId === this.ctx.id` is silently, permanently false
32270
- * for a builtin — and only for a builtin, which is why it survives a green
32271
- * suite whose fake supplies the bare id. That is exactly how camera 615's
32272
- * virtual doorbell latched `unbound` for twelve hours on 2026-08-07 while its
32273
- * binding was intact ([D72](../../../../docs/decisions/adr-0072.md)).
32274
- *
32275
- * Route every comparison between `ctx.id` and a registry/store addon id
32276
- * through {@link isSameAddonId}. `scripts/check-addon-id-comparison.ts`
32277
- * enforces it in the processes where the prefix exists.
32278
- */
32279
- /** The prefix the hub-main context factory prepends to the manifest id. */
32280
- var ADDON_ID_PREFIX = "addon:";
32281
- /** The manifest id, whichever spelling of `ctx.id` you were handed. */
32282
- function bareAddonId(id) {
32283
- return id.startsWith(ADDON_ID_PREFIX) ? id.slice(6) : id;
32284
- }
32285
- /** True when both ids name the same addon, prefixed or not. */
32286
- function isSameAddonId(a, b) {
32287
- return bareAddonId(a) === bareAddonId(b);
32288
- }
32289
- /**
32290
- * Decode base64 little-endian Float32 back into a vector.
32291
- *
32292
- * Throws on a byte length that is not a multiple of 4 — a truncated vector
32293
- * would otherwise silently rank against a shorter one and produce a plausible
32294
- * score, which is worse than an error.
32295
- */
32296
- function decodeVectorBase64(encoded) {
32297
- const buffer = Buffer.from(encoded, "base64");
32298
- if (buffer.byteLength % 4 !== 0) throw new Error(`decodeVectorBase64: ${buffer.byteLength} bytes is not a whole number of Float32 values`);
32299
- const out = new Float32Array(buffer.byteLength / 4);
32300
- for (let i = 0; i < out.length; i += 1) out[i] = buffer.readFloatLE(i * 4);
32301
- return out;
32302
- }
32303
- /** Vector length implied by a base64 payload, without decoding it. */
32304
- function vectorDimFromBase64(encoded) {
32305
- return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
32306
- }
32307
32112
  zod.z.object({
32308
32113
  /**
32309
32114
  * Fraction of the box's own size added on EACH side before cutting.
@@ -32411,6 +32216,60 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
32411
32216
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
32412
32217
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
32413
32218
  /**
32219
+ * The addon id as the REGISTRY, the wire and the durable stores spell it.
32220
+ *
32221
+ * `AddonContext.id` does not agree with itself across the two context
32222
+ * factories:
32223
+ *
32224
+ * - a FORKED addon gets the bare manifest id
32225
+ * (`kernel/moleculer/addon-context-factory.ts` → `id: addonId`);
32226
+ * - an addon co-located in hub-main gets it PREFIXED
32227
+ * (`server/backend/src/core/addon/addon-registry.service.ts` →
32228
+ * ``id: `addon:${addonId}` ``).
32229
+ *
32230
+ * Everything an addon might compare `ctx.id` AGAINST carries the bare form:
32231
+ * `DeviceBindingEntry.providerAddonId`, the `device-manager` bindings store's
32232
+ * `wrapperAddonId`, `CapabilityRegistry` provider keys, manifest ids.
32233
+ *
32234
+ * So `entry.providerAddonId === this.ctx.id` is silently, permanently false
32235
+ * for a builtin — and only for a builtin, which is why it survives a green
32236
+ * suite whose fake supplies the bare id. That is exactly how camera 615's
32237
+ * virtual doorbell latched `unbound` for twelve hours on 2026-08-07 while its
32238
+ * binding was intact ([D72](../../../../docs/decisions/adr-0072.md)).
32239
+ *
32240
+ * Route every comparison between `ctx.id` and a registry/store addon id
32241
+ * through {@link isSameAddonId}. `scripts/check-addon-id-comparison.ts`
32242
+ * enforces it in the processes where the prefix exists.
32243
+ */
32244
+ /** The prefix the hub-main context factory prepends to the manifest id. */
32245
+ var ADDON_ID_PREFIX = "addon:";
32246
+ /** The manifest id, whichever spelling of `ctx.id` you were handed. */
32247
+ function bareAddonId(id) {
32248
+ return id.startsWith(ADDON_ID_PREFIX) ? id.slice(6) : id;
32249
+ }
32250
+ /** True when both ids name the same addon, prefixed or not. */
32251
+ function isSameAddonId(a, b) {
32252
+ return bareAddonId(a) === bareAddonId(b);
32253
+ }
32254
+ /**
32255
+ * Decode base64 little-endian Float32 back into a vector.
32256
+ *
32257
+ * Throws on a byte length that is not a multiple of 4 — a truncated vector
32258
+ * would otherwise silently rank against a shorter one and produce a plausible
32259
+ * score, which is worse than an error.
32260
+ */
32261
+ function decodeVectorBase64(encoded) {
32262
+ const buffer = Buffer.from(encoded, "base64");
32263
+ if (buffer.byteLength % 4 !== 0) throw new Error(`decodeVectorBase64: ${buffer.byteLength} bytes is not a whole number of Float32 values`);
32264
+ const out = new Float32Array(buffer.byteLength / 4);
32265
+ for (let i = 0; i < out.length; i += 1) out[i] = buffer.readFloatLE(i * 4);
32266
+ return out;
32267
+ }
32268
+ /** Vector length implied by a base64 payload, without decoding it. */
32269
+ function vectorDimFromBase64(encoded) {
32270
+ return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
32271
+ }
32272
+ /**
32414
32273
  * Scores all applicable inference backends for the given hardware.
32415
32274
  *
32416
32275
  * Rules (hardware-driven only — no Python import probes):
@@ -32560,6 +32419,12 @@ Object.defineProperty(exports, "CAP_NAMES_WITH_STATUS", {
32560
32419
  return CAP_NAMES_WITH_STATUS;
32561
32420
  }
32562
32421
  });
32422
+ Object.defineProperty(exports, "CORE_BLOCK_ADDON_PREFIX", {
32423
+ enumerable: true,
32424
+ get: function() {
32425
+ return CORE_BLOCK_ADDON_PREFIX;
32426
+ }
32427
+ });
32563
32428
  Object.defineProperty(exports, "CoreBlockSchema", {
32564
32429
  enumerable: true,
32565
32430
  get: function() {
@@ -32692,12 +32557,6 @@ Object.defineProperty(exports, "alertsCapability", {
32692
32557
  return alertsCapability;
32693
32558
  }
32694
32559
  });
32695
- Object.defineProperty(exports, "applyTransform", {
32696
- enumerable: true,
32697
- get: function() {
32698
- return applyTransform;
32699
- }
32700
- });
32701
32560
  Object.defineProperty(exports, "asJsonObject", {
32702
32561
  enumerable: true,
32703
32562
  get: function() {
@@ -32740,6 +32599,18 @@ Object.defineProperty(exports, "buildStreamParamsConfigSchema", {
32740
32599
  return buildStreamParamsConfigSchema;
32741
32600
  }
32742
32601
  });
32602
+ Object.defineProperty(exports, "coreBlockAddonId", {
32603
+ enumerable: true,
32604
+ get: function() {
32605
+ return coreBlockAddonId;
32606
+ }
32607
+ });
32608
+ Object.defineProperty(exports, "coreBlockIdFromAddonId", {
32609
+ enumerable: true,
32610
+ get: function() {
32611
+ return coreBlockIdFromAddonId;
32612
+ }
32613
+ });
32743
32614
  Object.defineProperty(exports, "coreBlocksCapability", {
32744
32615
  enumerable: true,
32745
32616
  get: function() {
@@ -32824,12 +32695,6 @@ Object.defineProperty(exports, "errMsg", {
32824
32695
  return errMsg;
32825
32696
  }
32826
32697
  });
32827
- Object.defineProperty(exports, "evaluateLinkExpression", {
32828
- enumerable: true,
32829
- get: function() {
32830
- return evaluateLinkExpression;
32831
- }
32832
- });
32833
32698
  Object.defineProperty(exports, "expandCapMethods", {
32834
32699
  enumerable: true,
32835
32700
  get: function() {
@@ -32848,12 +32713,6 @@ Object.defineProperty(exports, "filesystemBrowseCapability", {
32848
32713
  return filesystemBrowseCapability;
32849
32714
  }
32850
32715
  });
32851
- Object.defineProperty(exports, "getByPath", {
32852
- enumerable: true,
32853
- get: function() {
32854
- return getByPath;
32855
- }
32856
- });
32857
32716
  Object.defineProperty(exports, "hydrateSchema", {
32858
32717
  enumerable: true,
32859
32718
  get: function() {
@@ -32950,6 +32809,12 @@ Object.defineProperty(exports, "normalizeUnit", {
32950
32809
  return normalizeUnit;
32951
32810
  }
32952
32811
  });
32812
+ Object.defineProperty(exports, "oauthIntegrationCapability", {
32813
+ enumerable: true,
32814
+ get: function() {
32815
+ return oauthIntegrationCapability;
32816
+ }
32817
+ });
32953
32818
  Object.defineProperty(exports, "objectInputDeclaresAddonId", {
32954
32819
  enumerable: true,
32955
32820
  get: function() {
@@ -33010,12 +32875,6 @@ Object.defineProperty(exports, "scoreRuntimes", {
33010
32875
  return scoreRuntimes;
33011
32876
  }
33012
32877
  });
33013
- Object.defineProperty(exports, "setByPath", {
33014
- enumerable: true,
33015
- get: function() {
33016
- return setByPath;
33017
- }
33018
- });
33019
32878
  Object.defineProperty(exports, "settingsStoreCapability", {
33020
32879
  enumerable: true,
33021
32880
  get: function() {
@@ -33052,24 +32911,12 @@ Object.defineProperty(exports, "streamQualityLabel", {
33052
32911
  return streamQualityLabel;
33053
32912
  }
33054
32913
  });
33055
- Object.defineProperty(exports, "toExpressionValue", {
33056
- enumerable: true,
33057
- get: function() {
33058
- return toExpressionValue;
33059
- }
33060
- });
33061
32914
  Object.defineProperty(exports, "userManagementCapability", {
33062
32915
  enumerable: true,
33063
32916
  get: function() {
33064
32917
  return userManagementCapability;
33065
32918
  }
33066
32919
  });
33067
- Object.defineProperty(exports, "validateExpressionSource", {
33068
- enumerable: true,
33069
- get: function() {
33070
- return validateExpressionSource;
33071
- }
33072
- });
33073
32920
  Object.defineProperty(exports, "vectorDimFromBase64", {
33074
32921
  enumerable: true,
33075
32922
  get: function() {