@camstack/addon-post-analysis 1.2.54 → 1.2.56

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.
@@ -5,6 +5,15 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __exportAll = (all, no_symbols) => {
9
+ let target = {};
10
+ for (var name in all) __defProp(target, name, {
11
+ get: all[name],
12
+ enumerable: true
13
+ });
14
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
15
+ return target;
16
+ };
8
17
  var __copyProps = (to, from, except, desc) => {
9
18
  if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
19
  key = keys[i];
@@ -8145,372 +8154,1146 @@ var ConvertResultSchema = object({
8145
8154
  })).readonly()
8146
8155
  });
8147
8156
  /**
8148
- * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
8149
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
8157
+ * Error types for the safe expression engine. Two distinct classes so callers
8158
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
8159
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
8160
+ */
8161
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
8162
+ * the failure is anchored to a character (author-facing inline feedback). */
8163
+ var ExpressionParseError = class extends Error {
8164
+ position;
8165
+ constructor(message, position) {
8166
+ super(message);
8167
+ this.name = "ExpressionParseError";
8168
+ this.position = position;
8169
+ }
8170
+ };
8171
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
8172
+ * result, unknown builtin, step-budget exceeded). */
8173
+ var ExpressionEvalError = class extends Error {
8174
+ constructor(message) {
8175
+ super(message);
8176
+ this.name = "ExpressionEvalError";
8177
+ }
8178
+ };
8179
+ /**
8180
+ * Frozen, null-prototype builtin function table for the expression engine
8181
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
8182
+ * parser rejects any callee not in it, and the evaluator gates each call on an
8183
+ * own-property check against it.
8150
8184
  *
8151
- * The provider iterates every `addon-pages-source` (collection) provider
8152
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8153
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8154
- * filesystem `mtime` cache-buster lets the browser pick up addon
8155
- * rebuilds without manual reload.
8185
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
8186
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
8187
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
8188
+ * (there is no `Object.prototype` in the chain), so those names are not
8189
+ * callable — they are simply "unknown function" at parse time.
8156
8190
  *
8157
- * The hub-local builtin `addon-pages-aggregator` (see
8158
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8159
- * provider. Splitting the public aggregator from the raw collection
8160
- * keeps both ends in codegen — there's no hand-written
8161
- * `addon-pages.router.ts` wrapper anymore.
8191
+ * Every numeric argument is validated as a finite number and every numeric
8192
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
8193
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
8194
+ * closed rather than emitting a garbage value.
8162
8195
  */
8163
- var AddonPageDeclarationSchema$1 = object({
8164
- id: string(),
8165
- label: string(),
8166
- icon: string(),
8167
- path: string(),
8168
- remoteName: string(),
8169
- bundle: string(),
8170
- section: string().optional(),
8171
- sectionLabel: string().optional()
8172
- });
8173
- var AddonPageInfoSchema = object({
8174
- addonId: string(),
8175
- page: AddonPageDeclarationSchema$1,
8176
- bundleUrl: string()
8177
- });
8178
- method(_void(), array(AddonPageInfoSchema).readonly());
8196
+ function asFiniteNumber(value, name, index) {
8197
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
8198
+ return value;
8199
+ }
8200
+ function asString$1(value, name, index) {
8201
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
8202
+ return value;
8203
+ }
8204
+ function finiteResult(value, name) {
8205
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
8206
+ return value;
8207
+ }
8208
+ function allFiniteNumbers(args, name) {
8209
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
8210
+ }
8211
+ var INF = Number.POSITIVE_INFINITY;
8212
+ var table = {
8213
+ min: {
8214
+ minArgs: 1,
8215
+ maxArgs: INF,
8216
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8217
+ },
8218
+ max: {
8219
+ minArgs: 1,
8220
+ maxArgs: INF,
8221
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8222
+ },
8223
+ abs: {
8224
+ minArgs: 1,
8225
+ maxArgs: 1,
8226
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8227
+ },
8228
+ floor: {
8229
+ minArgs: 1,
8230
+ maxArgs: 1,
8231
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8232
+ },
8233
+ ceil: {
8234
+ minArgs: 1,
8235
+ maxArgs: 1,
8236
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8237
+ },
8238
+ sqrt: {
8239
+ minArgs: 1,
8240
+ maxArgs: 1,
8241
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8242
+ },
8243
+ round: {
8244
+ minArgs: 1,
8245
+ maxArgs: 2,
8246
+ apply: (args) => {
8247
+ const x = asFiniteNumber(args[0], "round", 0);
8248
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8249
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8250
+ const factor = 10 ** digits;
8251
+ return finiteResult(Math.round(x * factor) / factor, "round");
8252
+ }
8253
+ },
8254
+ pow: {
8255
+ minArgs: 2,
8256
+ maxArgs: 2,
8257
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8258
+ },
8259
+ clamp: {
8260
+ minArgs: 3,
8261
+ maxArgs: 3,
8262
+ apply: (args) => {
8263
+ const x = asFiniteNumber(args[0], "clamp", 0);
8264
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8265
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8266
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8267
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8268
+ }
8269
+ },
8270
+ avg: {
8271
+ minArgs: 1,
8272
+ maxArgs: INF,
8273
+ apply: (args) => {
8274
+ const nums = allFiniteNumbers(args, "avg");
8275
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8276
+ }
8277
+ },
8278
+ sum: {
8279
+ minArgs: 1,
8280
+ maxArgs: INF,
8281
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8282
+ },
8283
+ coalesce: {
8284
+ minArgs: 1,
8285
+ maxArgs: INF,
8286
+ apply: (args) => {
8287
+ for (const a of args) if (a !== null) return a;
8288
+ return null;
8289
+ }
8290
+ },
8291
+ age: {
8292
+ minArgs: 2,
8293
+ maxArgs: 2,
8294
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8295
+ },
8296
+ convert: {
8297
+ minArgs: 3,
8298
+ maxArgs: 3,
8299
+ apply: (args, hooks) => {
8300
+ const x = asFiniteNumber(args[0], "convert", 0);
8301
+ const from = asString$1(args[1], "convert", 1).trim();
8302
+ const to = asString$1(args[2], "convert", 2).trim();
8303
+ if (hooks.convert) {
8304
+ const out = hooks.convert(x, from, to);
8305
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8306
+ return finiteResult(out, "convert");
8307
+ }
8308
+ if (from === to) return x;
8309
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8310
+ }
8311
+ }
8312
+ };
8313
+ Object.freeze(Object.assign(Object.create(null), table));
8314
+ /** The set of valid builtin names — used by the parser to reject unknown
8315
+ * callees at parse time (immediate author feedback). */
8316
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8179
8317
  /**
8180
- * `addon-pages-source` — collection cap exposing per-provider raw page
8181
- * declarations. Every addon that contributes a UI page registers a
8182
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8183
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8184
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8185
- * that admin-ui consumes.
8318
+ * Resource-bound constants for the safe expression engine.
8186
8319
  *
8187
- * The split exists because the public listing has a different output
8188
- * shape than the per-provider raw declarations, and we want both ends
8189
- * to flow through codegen instead of relying on a hand-written wrapper.
8320
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8321
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
8322
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
8323
+ * work a single author-supplied expression can request, so a hostile or
8324
+ * accidental pathological string can never spend unbounded CPU/memory.
8190
8325
  */
8191
- var AddonPageDeclarationSchema = object({
8192
- id: string(),
8193
- label: string(),
8194
- icon: string(),
8195
- path: string(),
8196
- /**
8197
- * Module Federation remote name — must match the `name` field on the
8198
- * page addon's `federation()` plugin config. Used by admin-ui's
8199
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8200
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8201
- * contain hyphens).
8202
- */
8203
- remoteName: string(),
8204
- /**
8205
- * Bundle filename inside the addon's `dist/` dir served at
8206
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8207
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
8208
- * the static-file route can compute an mtime-based cache-buster URL
8209
- * without a separate filesystem stat.
8210
- */
8211
- bundle: string(),
8212
- /**
8213
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
8214
- * `'cluster'`, `'administration'` — the page renders inside that group.
8215
- * Any OTHER string creates (or joins) a custom section rendered after
8216
- * the built-in groups; its label comes from `sectionLabel` (first
8217
- * declaration wins), falling back to the id. Absent → the legacy
8218
- * "Addon Pages" group.
8219
- */
8220
- section: string().optional(),
8221
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8222
- sectionLabel: string().optional()
8223
- });
8224
- method(_void(), array(AddonPageDeclarationSchema).readonly());
8225
- var AddonHttpRouteSchema = object({
8226
- method: _enum([
8227
- "GET",
8228
- "POST",
8229
- "PUT",
8230
- "DELETE",
8231
- "PATCH"
8232
- ]),
8233
- path: string(),
8234
- access: _enum([
8235
- "public",
8236
- "authenticated",
8237
- "admin"
8238
- ]).optional(),
8239
- description: string().optional()
8240
- });
8326
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
8327
+ * rejected without allocation. */
8328
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
8329
+ /** A legal binding / identifier name. */
8330
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8331
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
8332
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
8333
+ var RESERVED_BINDING_NAMES = new Set([
8334
+ "now",
8335
+ "true",
8336
+ "false",
8337
+ "null"
8338
+ ]);
8241
8339
  /**
8242
- * Cross-process route invocation envelope. The hub captures the
8243
- * request as plain data, ships it to the worker via Moleculer, and
8244
- * the worker runs the local handler against a capturing reply. The
8245
- * envelope returned describes what the handler intended (status,
8246
- * headers, body, or a redirect) so the hub can translate it back to
8247
- * the Fastify reply that's actually wired to the socket.
8340
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
8341
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
8342
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
8343
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
8344
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
8345
+ * is a parse error with a source position, so member access / assignment /
8346
+ * template literals are lexically impossible.
8248
8347
  */
8249
- var InvokeRequestSchema = object({
8250
- method: string(),
8251
- path: string(),
8252
- params: record(string(), string()),
8253
- query: record(string(), string()),
8254
- body: unknown(),
8255
- headers: record(string(), string()),
8256
- user: object({
8257
- id: string(),
8258
- username: string(),
8259
- isAdmin: boolean()
8260
- }).optional(),
8261
- scopedToken: unknown().optional()
8262
- });
8263
- var InvokeReplyEnvelopeSchema = object({
8264
- status: number().int(),
8265
- headers: record(string(), string()),
8266
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8267
- * sending `body`. Status defaults to 302 when this is set unless
8268
- * the handler called `reply.code(...)` explicitly. */
8269
- redirectUrl: string().nullable(),
8270
- /** JSON-serializable body. `undefined` is treated as "no body". */
8271
- body: unknown().optional(),
8272
- /** Set when the handler called `reply.type(mime)`. */
8273
- contentType: string().optional()
8274
- });
8275
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8276
- var ConfigTabDeclarationSchema = object({
8277
- id: string(),
8278
- label: string(),
8279
- icon: string(),
8280
- order: number().optional()
8281
- });
8282
- var ConfigSectionWithValuesSchema = object({
8283
- id: string(),
8284
- title: string(),
8285
- description: string().optional(),
8286
- style: _enum(["card", "accordion"]).optional(),
8287
- defaultCollapsed: boolean().optional(),
8288
- columns: union([
8289
- literal(1),
8290
- literal(2),
8291
- literal(3),
8292
- literal(4)
8293
- ]).optional(),
8294
- tab: string().optional(),
8295
- location: _enum(["settings", "top-tab"]).optional(),
8296
- order: number().optional(),
8297
- fields: array(any())
8298
- });
8299
- var SettingsSchemaWithValuesSchema = object({
8300
- tabs: array(ConfigTabDeclarationSchema).optional(),
8301
- sections: array(ConfigSectionWithValuesSchema)
8302
- });
8303
- /** Patch object — keys are field names, values are the new field values. */
8304
- var SettingsPatchSchema = record(string(), unknown());
8305
- /** Standard success response for update operations. */
8306
- var SettingsUpdateResultSchema = object({ success: literal(true) });
8307
- method(object({
8308
- addonId: string(),
8309
- nodeId: string().optional(),
8310
- overlay: record(string(), unknown()).optional(),
8311
- cap: string().optional()
8312
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8313
- addonId: string(),
8314
- nodeId: string().optional(),
8315
- patch: SettingsPatchSchema
8316
- }), SettingsUpdateResultSchema, {
8317
- kind: "mutation",
8318
- auth: "admin"
8319
- }), method(object({
8320
- addonId: string(),
8321
- deviceId: number(),
8322
- nodeId: string().optional()
8323
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8324
- addonId: string(),
8325
- deviceId: number(),
8326
- nodeId: string().optional(),
8327
- patch: SettingsPatchSchema
8328
- }), SettingsUpdateResultSchema, {
8329
- kind: "mutation",
8330
- auth: "admin"
8331
- });
8348
+ var KEYWORDS = new Set([
8349
+ "true",
8350
+ "false",
8351
+ "null"
8352
+ ]);
8353
+ function isDigit(ch) {
8354
+ return ch >= "0" && ch <= "9";
8355
+ }
8356
+ function isIdentStart(ch) {
8357
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
8358
+ }
8359
+ function isIdentPart(ch) {
8360
+ return isIdentStart(ch) || isDigit(ch);
8361
+ }
8362
+ function isWhitespace(ch) {
8363
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
8364
+ }
8365
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
8366
+ * Throws `ExpressionParseError` on any illegal character or unterminated
8367
+ * string. */
8368
+ function tokenize(source) {
8369
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
8370
+ const tokens = [];
8371
+ let i = 0;
8372
+ const n = source.length;
8373
+ while (i < n) {
8374
+ const ch = source[i];
8375
+ if (isWhitespace(ch)) {
8376
+ i += 1;
8377
+ continue;
8378
+ }
8379
+ if (isDigit(ch)) {
8380
+ const start = i;
8381
+ while (i < n && isDigit(source[i])) i += 1;
8382
+ if (i < n && source[i] === ".") {
8383
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
8384
+ i += 1;
8385
+ while (i < n && isDigit(source[i])) i += 1;
8386
+ }
8387
+ const text = source.slice(start, i);
8388
+ const value = Number(text);
8389
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
8390
+ tokens.push({
8391
+ type: "number",
8392
+ value,
8393
+ pos: start
8394
+ });
8395
+ continue;
8396
+ }
8397
+ if (ch === "'" || ch === "\"") {
8398
+ const quote = ch;
8399
+ const start = i;
8400
+ i += 1;
8401
+ let out = "";
8402
+ let closed = false;
8403
+ while (i < n) {
8404
+ const c = source[i];
8405
+ if (c === "\\") {
8406
+ const next = i + 1 < n ? source[i + 1] : "";
8407
+ if (next === "\\" || next === "'" || next === "\"") {
8408
+ out += next;
8409
+ i += 2;
8410
+ continue;
8411
+ }
8412
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
8413
+ }
8414
+ if (c === quote) {
8415
+ closed = true;
8416
+ i += 1;
8417
+ break;
8418
+ }
8419
+ out += c;
8420
+ i += 1;
8421
+ }
8422
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
8423
+ tokens.push({
8424
+ type: "string",
8425
+ value: out,
8426
+ pos: start
8427
+ });
8428
+ continue;
8429
+ }
8430
+ if (isIdentStart(ch)) {
8431
+ const start = i;
8432
+ while (i < n && isIdentPart(source[i])) i += 1;
8433
+ const text = source.slice(start, i);
8434
+ if (KEYWORDS.has(text)) tokens.push({
8435
+ type: "keyword",
8436
+ keyword: keywordOf(text),
8437
+ pos: start
8438
+ });
8439
+ else tokens.push({
8440
+ type: "identifier",
8441
+ name: text,
8442
+ pos: start
8443
+ });
8444
+ continue;
8445
+ }
8446
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
8447
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
8448
+ tokens.push({
8449
+ type: "punct",
8450
+ punct: two,
8451
+ pos: i
8452
+ });
8453
+ i += 2;
8454
+ continue;
8455
+ }
8456
+ if (isSinglePunct(ch)) {
8457
+ tokens.push({
8458
+ type: "punct",
8459
+ punct: ch,
8460
+ pos: i
8461
+ });
8462
+ i += 1;
8463
+ continue;
8464
+ }
8465
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
8466
+ }
8467
+ tokens.push({
8468
+ type: "eof",
8469
+ pos: n
8470
+ });
8471
+ return tokens;
8472
+ }
8473
+ function keywordOf(text) {
8474
+ if (text === "true") return "true";
8475
+ if (text === "false") return "false";
8476
+ return "null";
8477
+ }
8478
+ function isSinglePunct(ch) {
8479
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
8480
+ }
8332
8481
  /**
8333
- * `addon-widgets-source` — collection cap exposing per-addon raw widget
8334
- * declarations. Mirrors the addon-pages split: every addon shipping
8335
- * widgets registers a provider on this collection cap; the hub-local
8336
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8337
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
8338
- * exposes the public listing surface that admin-ui consumes.
8482
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8339
8483
  *
8340
- * The split exists because the public listing has a different output
8341
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8342
- * per-provider raw declarations. Both ends flow through codegen.
8484
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
8485
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
8486
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
8487
+ * string validated against the builtin table at parse time, so an unknown
8488
+ * function is rejected immediately (author feedback) and a persisted expression
8489
+ * that references a since-removed builtin degrades at read.
8343
8490
  *
8344
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
8345
- * `UiContribution` with `kind:'remote'`. The host renders it through the
8346
- * same `ContributionRenderer` / Module-Federation path as every other
8347
- * contributed UI surface — no bespoke widget-rendering path. The widget-
8348
- * only metadata (sizing hints, `requires`) lives as extra fields on the
8349
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8350
- * `kind` / `remote`) carries identity + placement + the MF remote.
8491
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
8492
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
8351
8493
  */
8352
- /** Where the widget makes sense to render — maps to a contribution `tab`. */
8353
- var WidgetHostEnum = _enum([
8354
- "device-tab",
8355
- "dashboard",
8356
- "integration-detail"
8494
+ /** Binary/logical operator precedence (higher binds tighter). */
8495
+ var BINARY_PRECEDENCE = {
8496
+ "||": 1,
8497
+ "&&": 2,
8498
+ "==": 3,
8499
+ "!=": 3,
8500
+ "<": 4,
8501
+ "<=": 4,
8502
+ ">": 4,
8503
+ ">=": 4,
8504
+ "+": 5,
8505
+ "-": 5,
8506
+ "*": 6,
8507
+ "/": 6,
8508
+ "%": 6
8509
+ };
8510
+ function isLogicalOp(op) {
8511
+ return op === "&&" || op === "||";
8512
+ }
8513
+ function isBinaryOp(op) {
8514
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
8515
+ }
8516
+ var Parser = class {
8517
+ tokens;
8518
+ pos = 0;
8519
+ nodeCount = 0;
8520
+ identifiers = /* @__PURE__ */ new Set();
8521
+ callees = /* @__PURE__ */ new Set();
8522
+ constructor(tokens) {
8523
+ this.tokens = tokens;
8524
+ }
8525
+ parse() {
8526
+ const ast = this.parseTernary();
8527
+ const tok = this.peek();
8528
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
8529
+ return {
8530
+ ast,
8531
+ identifiers: this.identifiers,
8532
+ callees: this.callees,
8533
+ nodeCount: this.nodeCount
8534
+ };
8535
+ }
8536
+ peek() {
8537
+ return this.tokens[this.pos];
8538
+ }
8539
+ next() {
8540
+ return this.tokens[this.pos++];
8541
+ }
8542
+ /** Consume a punctuator token, erroring if the next token isn't it. */
8543
+ expectPunct(punct) {
8544
+ const tok = this.peek();
8545
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
8546
+ this.pos += 1;
8547
+ }
8548
+ matchPunct(punct) {
8549
+ const tok = this.peek();
8550
+ if (tok.type === "punct" && tok.punct === punct) {
8551
+ this.pos += 1;
8552
+ return true;
8553
+ }
8554
+ return false;
8555
+ }
8556
+ countNode() {
8557
+ this.nodeCount += 1;
8558
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
8559
+ }
8560
+ parseTernary() {
8561
+ const test = this.parseBinary(1);
8562
+ if (this.matchPunct("?")) {
8563
+ const consequent = this.parseTernary();
8564
+ this.expectPunct(":");
8565
+ const alternate = this.parseTernary();
8566
+ this.countNode();
8567
+ return {
8568
+ kind: "conditional",
8569
+ test,
8570
+ consequent,
8571
+ alternate
8572
+ };
8573
+ }
8574
+ return test;
8575
+ }
8576
+ parseBinary(minPrec) {
8577
+ let left = this.parseUnary();
8578
+ for (;;) {
8579
+ const tok = this.peek();
8580
+ if (tok.type !== "punct") break;
8581
+ const prec = BINARY_PRECEDENCE[tok.punct];
8582
+ if (prec === void 0 || prec < minPrec) break;
8583
+ const op = tok.punct;
8584
+ this.pos += 1;
8585
+ const right = this.parseBinary(prec + 1);
8586
+ this.countNode();
8587
+ if (isLogicalOp(op)) left = {
8588
+ kind: "logical",
8589
+ op,
8590
+ left,
8591
+ right
8592
+ };
8593
+ else if (isBinaryOp(op)) left = {
8594
+ kind: "binary",
8595
+ op,
8596
+ left,
8597
+ right
8598
+ };
8599
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
8600
+ }
8601
+ return left;
8602
+ }
8603
+ parseUnary() {
8604
+ const tok = this.peek();
8605
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
8606
+ const op = tok.punct;
8607
+ this.pos += 1;
8608
+ const operand = this.parseUnary();
8609
+ this.countNode();
8610
+ return {
8611
+ kind: "unary",
8612
+ op,
8613
+ operand
8614
+ };
8615
+ }
8616
+ return this.parsePrimary();
8617
+ }
8618
+ parsePrimary() {
8619
+ const tok = this.next();
8620
+ switch (tok.type) {
8621
+ case "number":
8622
+ this.countNode();
8623
+ return {
8624
+ kind: "literal",
8625
+ value: tok.value
8626
+ };
8627
+ case "string":
8628
+ this.countNode();
8629
+ return {
8630
+ kind: "literal",
8631
+ value: tok.value
8632
+ };
8633
+ case "keyword":
8634
+ this.countNode();
8635
+ return {
8636
+ kind: "literal",
8637
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
8638
+ };
8639
+ case "identifier": {
8640
+ const nextTok = this.peek();
8641
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
8642
+ this.identifiers.add(tok.name);
8643
+ this.countNode();
8644
+ return {
8645
+ kind: "identifier",
8646
+ name: tok.name
8647
+ };
8648
+ }
8649
+ case "punct":
8650
+ if (tok.punct === "(") {
8651
+ const inner = this.parseTernary();
8652
+ this.expectPunct(")");
8653
+ return inner;
8654
+ }
8655
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
8656
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
8657
+ }
8658
+ }
8659
+ parseCall(callee, pos) {
8660
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
8661
+ this.expectPunct("(");
8662
+ const args = [];
8663
+ if (!this.matchPunct(")")) for (;;) {
8664
+ args.push(this.parseTernary());
8665
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
8666
+ if (this.matchPunct(",")) continue;
8667
+ this.expectPunct(")");
8668
+ break;
8669
+ }
8670
+ this.callees.add(callee);
8671
+ this.countNode();
8672
+ return {
8673
+ kind: "call",
8674
+ callee,
8675
+ args
8676
+ };
8677
+ }
8678
+ };
8679
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
8680
+ * `ExpressionParseError` on any lexical or grammatical failure. */
8681
+ function parseExpression(source) {
8682
+ return new Parser(tokenize(source)).parse();
8683
+ }
8684
+ /**
8685
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
8686
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
8687
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
8688
+ * one per read on a hot resolve path.
8689
+ *
8690
+ * The cache is a module-level singleton: entries are pure, content-addressed
8691
+ * ASTs keyed by the raw source string, so sharing one instance across all
8692
+ * callers is safe and maximises hit rate.
8693
+ */
8694
+ var cache = /* @__PURE__ */ new Map();
8695
+ function getCached(source) {
8696
+ const hit = cache.get(source);
8697
+ if (hit !== void 0) {
8698
+ cache.delete(source);
8699
+ cache.set(source, hit);
8700
+ return hit;
8701
+ }
8702
+ let result;
8703
+ try {
8704
+ result = {
8705
+ ok: true,
8706
+ parsed: parseExpression(source)
8707
+ };
8708
+ } catch (err) {
8709
+ result = {
8710
+ ok: false,
8711
+ error: err instanceof ExpressionParseError ? err.message : String(err)
8712
+ };
8713
+ }
8714
+ cache.set(source, result);
8715
+ if (cache.size > 256) {
8716
+ const oldest = cache.keys().next().value;
8717
+ if (oldest !== void 0) cache.delete(oldest);
8718
+ }
8719
+ return result;
8720
+ }
8721
+ /** Compile `source`, returning a discriminated result instead of throwing.
8722
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
8723
+ function compileExpressionSafe(source) {
8724
+ return getCached(source);
8725
+ }
8726
+ Object.freeze({});
8727
+ /**
8728
+ * Author-time validation. Returns `null` when the source is valid, else a
8729
+ * human-readable error message. Checks: the expression compiles; binding count
8730
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
8731
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
8732
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
8733
+ */
8734
+ function validateExpressionSource(src) {
8735
+ const names = Object.keys(src.bindings);
8736
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
8737
+ for (const name of names) {
8738
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
8739
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
8740
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
8741
+ }
8742
+ const compiled = compileExpressionSafe(src.expr);
8743
+ if (!compiled.ok) return compiled.error;
8744
+ const bound = new Set(names);
8745
+ for (const id of compiled.parsed.identifiers) {
8746
+ if (id === "now") continue;
8747
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
8748
+ }
8749
+ return null;
8750
+ }
8751
+ var ExpressionBindingSourceSchema = union([
8752
+ object({
8753
+ kind: literal("field").optional(),
8754
+ sourceKey: string(),
8755
+ cap: string(),
8756
+ fieldPath: string()
8757
+ }),
8758
+ object({
8759
+ kind: literal("literal"),
8760
+ value: union([
8761
+ string(),
8762
+ number(),
8763
+ boolean(),
8764
+ _null()
8765
+ ])
8766
+ }),
8767
+ object({
8768
+ kind: literal("global"),
8769
+ sourceStableId: string(),
8770
+ cap: string(),
8771
+ fieldPath: string()
8772
+ })
8357
8773
  ]);
8358
- var WidgetSizeEnum = _enum([
8359
- "xs",
8360
- "sm",
8361
- "md",
8362
- "lg",
8363
- "xl"
8774
+ object({
8775
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8776
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8777
+ }).superRefine((src, ctx) => {
8778
+ const err = validateExpressionSource(src);
8779
+ if (err !== null) ctx.addIssue({
8780
+ code: "custom",
8781
+ message: err,
8782
+ path: ["expr"]
8783
+ });
8784
+ });
8785
+ /** How a leaf compares a device field to a value. Derived from the field's
8786
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
8787
+ var AutomationConditionOperatorSchema = _enum([
8788
+ "eq",
8789
+ "ne",
8790
+ "gt",
8791
+ "gte",
8792
+ "lt",
8793
+ "lte",
8794
+ "contains",
8795
+ "in"
8364
8796
  ]);
8797
+ var AutomationConditionLeafSchema = object({
8798
+ kind: literal("condition"),
8799
+ deviceId: number().int().nonnegative(),
8800
+ cap: string().min(1),
8801
+ fieldPath: string().min(1),
8802
+ operator: AutomationConditionOperatorSchema,
8803
+ value: union([
8804
+ string(),
8805
+ number(),
8806
+ boolean(),
8807
+ array(union([string(), number()]))
8808
+ ])
8809
+ });
8365
8810
  /**
8366
- * MF remote descriptor — mirrors `UiContributionRemote` from
8367
- * `capability-definition.ts`. Widget remotes expose a single
8368
- * `'./widgets'` module whose default export is a
8369
- * `Record<componentKey, Component>` map; `componentKey` (the widget
8370
- * `stableId`) picks the entry the host mounts.
8811
+ * The expression leaf, declared as a plain object rather than an intersection
8812
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
8813
+ * read `kind` off each option, and an intersection hides it. The author-time
8814
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
8815
+ * cannot drift — an expression that one accepts, the other accepts.
8371
8816
  */
8372
- var WidgetRemoteSchema = object({
8373
- remoteName: string(),
8374
- exposedModule: string(),
8375
- componentKey: string().optional()
8817
+ var AutomationConditionExpressionSchema = object({
8818
+ kind: literal("expression"),
8819
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8820
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8821
+ }).superRefine((src, ctx) => {
8822
+ const err = validateExpressionSource(src);
8823
+ if (err !== null) ctx.addIssue({
8824
+ code: "custom",
8825
+ message: err,
8826
+ path: ["expr"]
8827
+ });
8376
8828
  });
8829
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
8830
+ object({
8831
+ kind: literal("all"),
8832
+ children: array(AutomationConditionSchema)
8833
+ }),
8834
+ object({
8835
+ kind: literal("any"),
8836
+ children: array(AutomationConditionSchema)
8837
+ }),
8838
+ object({
8839
+ kind: literal("not"),
8840
+ child: AutomationConditionSchema
8841
+ }),
8842
+ AutomationConditionLeafSchema,
8843
+ AutomationConditionExpressionSchema
8844
+ ]));
8377
8845
  /**
8378
- * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
8379
- * widget-only metadata. The `UiContribution` core fields:
8846
+ * What starts a run.
8380
8847
  *
8381
- * - `tab` — where the widget hosts. A widget that runs on the
8382
- * dashboard declares `tab:'dashboard'`; a device-tab
8383
- * widget declares the target device-detail tab id.
8384
- * - `subTab` — optional sub-tab within `tab`.
8385
- * - `label` — operator-facing label.
8386
- * - `order` — ordering within `(tab, subTab)`.
8387
- * - `kind` — always `'remote'` for widgets.
8388
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
8848
+ * D8 compliance, and it is the reason `device-state` is not merely an event
8849
+ * subscription: the trigger evaluates against the **state mirror**, which is
8850
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
8851
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
8852
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
8853
+ * not.
8854
+ */
8855
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
8856
+ object({
8857
+ kind: literal("device-state"),
8858
+ deviceId: number().int().nonnegative(),
8859
+ cap: string().min(1),
8860
+ fieldPath: string().min(1),
8861
+ /** Fire when the field takes this value. Omit to fire on any change. */
8862
+ becomes: union([
8863
+ string(),
8864
+ number(),
8865
+ boolean()
8866
+ ]).optional(),
8867
+ /** Only on a CHANGE of value, not on every re-report. */
8868
+ edge: boolean().optional(),
8869
+ /** The condition must hold this long before the run starts. */
8870
+ forMs: number().int().min(0).max(864e5).optional(),
8871
+ /** Collapse a burst into one run. */
8872
+ debounceMs: number().int().min(0).max(6e5).optional()
8873
+ }),
8874
+ object({
8875
+ kind: literal("device-event"),
8876
+ /** An `EventCategory` value. */
8877
+ category: string().min(1),
8878
+ deviceId: number().int().nonnegative().optional()
8879
+ }),
8880
+ object({
8881
+ kind: literal("schedule"),
8882
+ cron: string().min(1).max(120)
8883
+ }),
8884
+ object({ kind: literal("manual") })
8885
+ ]);
8886
+ /**
8887
+ * One action step.
8389
8888
  *
8390
- * Widget-only fields retained alongside the contribution core:
8889
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
8890
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
8891
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
8892
+ * to reuse it, and none of them are re-implemented here.
8391
8893
  *
8392
- * - `stableId` — stable identity within the addon (the MF
8393
- * `componentKey`; kept top-level so consumers have
8394
- * a stable key without reaching into `remote`).
8395
- * - `description` / `icon` — picker metadata.
8396
- * - `bundle` — entry filename inside the addon `dist/` dir; the
8397
- * aggregator stamps a versioned `bundleUrl` from it.
8398
- * - `hosts` — every host the widget supports (a widget can run
8399
- * both on the dashboard and a device tab). `tab`
8400
- * is the PRIMARY host; `hosts` is the full set the
8401
- * picker filters on.
8402
- * - `requires` — host-context requirements validated at mount.
8403
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
8404
- * — dashboard placement hints.
8894
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
8895
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
8896
+ * documents its subject device as *"for the log tag, never for routing"*. So an
8897
+ * NC action can never target the device that triggered it — which is fine for
8898
+ * the NC (its rules already scope to a device) and fatal for an automation
8899
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
8900
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
8901
+ * is called. The runner still receives a number and is untouched; the
8902
+ * resolution is the recipe's job, not the runner's.
8405
8903
  */
8406
- var WidgetMetadataSchema = object({
8407
- /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
8408
- tab: string(),
8409
- /** Optional sub-tab within `tab`. */
8410
- subTab: string().optional(),
8411
- /** Operator-facing label. */
8412
- label: string(),
8413
- /** Ordering within `(tab, subTab)`, ascending. */
8414
- order: number().optional(),
8415
- /** Always `'remote'` — a widget is a Module Federation remote. */
8416
- kind: literal("remote"),
8417
- /** MF remote descriptor. */
8418
- remote: WidgetRemoteSchema,
8419
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
8420
- stableId: string(),
8421
- description: string().optional(),
8422
- icon: string().optional(),
8423
- /**
8424
- * Bundle filename inside the addon's `dist/` dir served at
8425
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
8426
- * this is always `'remoteEntry.js'` — the value is kept on the
8427
- * metadata so the static-file route can compute an mtime-based
8428
- * cache-buster URL without a separate filesystem stat.
8429
- */
8430
- bundle: string(),
8431
- /** Every host the widget supports. The picker filters on this set. */
8432
- hosts: array(WidgetHostEnum).readonly(),
8433
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
8434
- requires: object({
8435
- deviceContext: boolean().default(false),
8436
- integrationContext: boolean().default(false)
8904
+ var AutomationActionSchema = discriminatedUnion("kind", [
8905
+ object({
8906
+ kind: literal("wait"),
8907
+ seconds: number().min(0).max(300)
8437
8908
  }),
8438
- /**
8439
- * Loadable BEFORE authentication. The normal widget registry listing
8440
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
8441
- * (the login page) cannot discover a widget through it. A widget that
8442
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
8443
- * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
8444
- * login-method contribution channel (see `login-method.cap.ts`) rather
8445
- * than the authenticated registry, and its bundle is served by the
8446
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
8447
- */
8448
- preAuth: boolean().optional().default(false),
8449
- /** Dashboard placement HINTS (operator can override per instance). */
8450
- defaultSize: WidgetSizeEnum.default("md"),
8451
- allowedSizes: array(WidgetSizeEnum).readonly().default([
8452
- "sm",
8453
- "md",
8454
- "lg"
8455
- ]),
8456
- defaultColumns: number().int().min(1).max(12).default(6),
8457
- defaultRows: number().int().min(1).max(12).default(1)
8909
+ object({
8910
+ kind: literal("cap"),
8911
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
8912
+ cap: string().min(1),
8913
+ method: string().min(1),
8914
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
8915
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
8916
+ args: record(string(), unknown()).optional()
8917
+ }),
8918
+ object({
8919
+ kind: literal("code"),
8920
+ /** Compiled into the automation's OWN block by esbuild — not a third
8921
+ * runtime, not a `vm`, and not dynamically evaluated. */
8922
+ code: string().min(1).max(2e4)
8923
+ })
8924
+ ]);
8925
+ object({
8926
+ triggers: array(AutomationTriggerSchema),
8927
+ conditions: AutomationConditionSchema.optional(),
8928
+ actions: array(AutomationActionSchema)
8458
8929
  });
8459
- var addonWidgetsSourceCapability = {
8460
- name: "addon-widgets-source",
8461
- scope: "system",
8462
- mode: "collection",
8463
- internal: true,
8464
- methods: { listWidgets: method(_void(), array(WidgetMetadataSchema).readonly()) }
8465
- };
8466
8930
  /**
8467
- * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
8468
- * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
8931
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
8932
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
8469
8933
  *
8470
- * The provider iterates every `addon-widgets-source` (collection)
8471
- * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
8472
- * `bundleUrl` strings pointing at
8473
- * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
8474
- * `mtime` cache-buster lets the browser pick up addon rebuilds without
8475
- * manual reload — same scheme used by `addon-pages`.
8934
+ * The provider iterates every `addon-pages-source` (collection) provider
8935
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8936
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8937
+ * filesystem `mtime` cache-buster lets the browser pick up addon
8938
+ * rebuilds without manual reload.
8476
8939
  *
8477
- * The hub-local builtin `addon-widgets-aggregator` (see
8478
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
8940
+ * The hub-local builtin `addon-pages-aggregator` (see
8941
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8479
8942
  * provider. Splitting the public aggregator from the raw collection
8480
- * keeps both ends in codegen — there's no hand-written wrapper.
8943
+ * keeps both ends in codegen — there's no hand-written
8944
+ * `addon-pages.router.ts` wrapper anymore.
8481
8945
  */
8482
- var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
8946
+ var AddonPageDeclarationSchema$1 = object({
8947
+ id: string(),
8948
+ label: string(),
8949
+ icon: string(),
8950
+ path: string(),
8951
+ remoteName: string(),
8952
+ bundle: string(),
8953
+ section: string().optional(),
8954
+ sectionLabel: string().optional()
8955
+ });
8956
+ var AddonPageInfoSchema = object({
8483
8957
  addonId: string(),
8958
+ page: AddonPageDeclarationSchema$1,
8484
8959
  bundleUrl: string()
8485
8960
  });
8486
- method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
8961
+ method(_void(), array(AddonPageInfoSchema).readonly());
8487
8962
  /**
8488
- * Alerts capability — collection-based internal alert system.
8963
+ * `addon-pages-source` — collection cap exposing per-provider raw page
8964
+ * declarations. Every addon that contributes a UI page registers a
8965
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8966
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8967
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8968
+ * that admin-ui consumes.
8489
8969
  *
8490
- * Multiple providers can register. Each provider filters by EventBus category
8491
- * and creates/updates alerts. The built-in Alert Center addon persists alerts
8492
- * in the DB and serves them to the admin UI.
8970
+ * The split exists because the public listing has a different output
8971
+ * shape than the per-provider raw declarations, and we want both ends
8972
+ * to flow through codegen instead of relying on a hand-written wrapper.
8493
8973
  */
8494
- var AlertSeveritySchema = _enum([
8495
- "info",
8496
- "success",
8497
- "warning",
8498
- "error"
8499
- ]);
8500
- var AlertStatusSchema = _enum([
8501
- "active",
8502
- "in-progress",
8503
- "completed",
8504
- "failed",
8505
- "dismissed"
8506
- ]);
8507
- var AlertSourceSchema = object({
8508
- type: string(),
8509
- id: string()
8510
- });
8511
- var AlertSchema = object({
8974
+ var AddonPageDeclarationSchema = object({
8512
8975
  id: string(),
8513
- category: string(),
8976
+ label: string(),
8977
+ icon: string(),
8978
+ path: string(),
8979
+ /**
8980
+ * Module Federation remote name — must match the `name` field on the
8981
+ * page addon's `federation()` plugin config. Used by admin-ui's
8982
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8983
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8984
+ * contain hyphens).
8985
+ */
8986
+ remoteName: string(),
8987
+ /**
8988
+ * Bundle filename inside the addon's `dist/` dir served at
8989
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8990
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
8991
+ * the static-file route can compute an mtime-based cache-buster URL
8992
+ * without a separate filesystem stat.
8993
+ */
8994
+ bundle: string(),
8995
+ /**
8996
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
8997
+ * `'cluster'`, `'administration'` — the page renders inside that group.
8998
+ * Any OTHER string creates (or joins) a custom section rendered after
8999
+ * the built-in groups; its label comes from `sectionLabel` (first
9000
+ * declaration wins), falling back to the id. Absent → the legacy
9001
+ * "Addon Pages" group.
9002
+ */
9003
+ section: string().optional(),
9004
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
9005
+ sectionLabel: string().optional()
9006
+ });
9007
+ method(_void(), array(AddonPageDeclarationSchema).readonly());
9008
+ var AddonHttpRouteSchema = object({
9009
+ method: _enum([
9010
+ "GET",
9011
+ "POST",
9012
+ "PUT",
9013
+ "DELETE",
9014
+ "PATCH"
9015
+ ]),
9016
+ path: string(),
9017
+ access: _enum([
9018
+ "public",
9019
+ "authenticated",
9020
+ "admin"
9021
+ ]).optional(),
9022
+ description: string().optional()
9023
+ });
9024
+ /**
9025
+ * Cross-process route invocation envelope. The hub captures the
9026
+ * request as plain data, ships it to the worker via Moleculer, and
9027
+ * the worker runs the local handler against a capturing reply. The
9028
+ * envelope returned describes what the handler intended (status,
9029
+ * headers, body, or a redirect) so the hub can translate it back to
9030
+ * the Fastify reply that's actually wired to the socket.
9031
+ */
9032
+ var InvokeRequestSchema = object({
9033
+ method: string(),
9034
+ path: string(),
9035
+ params: record(string(), string()),
9036
+ query: record(string(), string()),
9037
+ body: unknown(),
9038
+ headers: record(string(), string()),
9039
+ user: object({
9040
+ id: string(),
9041
+ username: string(),
9042
+ isAdmin: boolean()
9043
+ }).optional(),
9044
+ scopedToken: unknown().optional()
9045
+ });
9046
+ var InvokeReplyEnvelopeSchema = object({
9047
+ status: number().int(),
9048
+ headers: record(string(), string()),
9049
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
9050
+ * sending `body`. Status defaults to 302 when this is set unless
9051
+ * the handler called `reply.code(...)` explicitly. */
9052
+ redirectUrl: string().nullable(),
9053
+ /** JSON-serializable body. `undefined` is treated as "no body". */
9054
+ body: unknown().optional(),
9055
+ /** Set when the handler called `reply.type(mime)`. */
9056
+ contentType: string().optional()
9057
+ });
9058
+ method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
9059
+ var ConfigTabDeclarationSchema = object({
9060
+ id: string(),
9061
+ label: string(),
9062
+ icon: string(),
9063
+ order: number().optional()
9064
+ });
9065
+ var ConfigSectionWithValuesSchema = object({
9066
+ id: string(),
9067
+ title: string(),
9068
+ description: string().optional(),
9069
+ style: _enum(["card", "accordion"]).optional(),
9070
+ defaultCollapsed: boolean().optional(),
9071
+ columns: union([
9072
+ literal(1),
9073
+ literal(2),
9074
+ literal(3),
9075
+ literal(4)
9076
+ ]).optional(),
9077
+ tab: string().optional(),
9078
+ location: _enum(["settings", "top-tab"]).optional(),
9079
+ order: number().optional(),
9080
+ fields: array(any())
9081
+ });
9082
+ var SettingsSchemaWithValuesSchema = object({
9083
+ tabs: array(ConfigTabDeclarationSchema).optional(),
9084
+ sections: array(ConfigSectionWithValuesSchema)
9085
+ });
9086
+ /** Patch object — keys are field names, values are the new field values. */
9087
+ var SettingsPatchSchema = record(string(), unknown());
9088
+ /** Standard success response for update operations. */
9089
+ var SettingsUpdateResultSchema = object({ success: literal(true) });
9090
+ method(object({
9091
+ addonId: string(),
9092
+ nodeId: string().optional(),
9093
+ overlay: record(string(), unknown()).optional(),
9094
+ cap: string().optional()
9095
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9096
+ addonId: string(),
9097
+ nodeId: string().optional(),
9098
+ patch: SettingsPatchSchema
9099
+ }), SettingsUpdateResultSchema, {
9100
+ kind: "mutation",
9101
+ auth: "admin"
9102
+ }), method(object({
9103
+ addonId: string(),
9104
+ deviceId: number(),
9105
+ nodeId: string().optional()
9106
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9107
+ addonId: string(),
9108
+ deviceId: number(),
9109
+ nodeId: string().optional(),
9110
+ patch: SettingsPatchSchema
9111
+ }), SettingsUpdateResultSchema, {
9112
+ kind: "mutation",
9113
+ auth: "admin"
9114
+ });
9115
+ /**
9116
+ * `addon-widgets-source` — collection cap exposing per-addon raw widget
9117
+ * declarations. Mirrors the addon-pages split: every addon shipping
9118
+ * widgets registers a provider on this collection cap; the hub-local
9119
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
9120
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
9121
+ * exposes the public listing surface that admin-ui consumes.
9122
+ *
9123
+ * The split exists because the public listing has a different output
9124
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
9125
+ * per-provider raw declarations. Both ends flow through codegen.
9126
+ *
9127
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
9128
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
9129
+ * same `ContributionRenderer` / Module-Federation path as every other
9130
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
9131
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
9132
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
9133
+ * `kind` / `remote`) carries identity + placement + the MF remote.
9134
+ */
9135
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
9136
+ var WidgetHostEnum = _enum([
9137
+ "device-tab",
9138
+ "dashboard",
9139
+ "integration-detail"
9140
+ ]);
9141
+ var WidgetSizeEnum = _enum([
9142
+ "xs",
9143
+ "sm",
9144
+ "md",
9145
+ "lg",
9146
+ "xl"
9147
+ ]);
9148
+ /**
9149
+ * MF remote descriptor — mirrors `UiContributionRemote` from
9150
+ * `capability-definition.ts`. Widget remotes expose a single
9151
+ * `'./widgets'` module whose default export is a
9152
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
9153
+ * `stableId`) picks the entry the host mounts.
9154
+ */
9155
+ var WidgetRemoteSchema = object({
9156
+ remoteName: string(),
9157
+ exposedModule: string(),
9158
+ componentKey: string().optional()
9159
+ });
9160
+ /**
9161
+ * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
9162
+ * widget-only metadata. The `UiContribution` core fields:
9163
+ *
9164
+ * - `tab` — where the widget hosts. A widget that runs on the
9165
+ * dashboard declares `tab:'dashboard'`; a device-tab
9166
+ * widget declares the target device-detail tab id.
9167
+ * - `subTab` — optional sub-tab within `tab`.
9168
+ * - `label` — operator-facing label.
9169
+ * - `order` — ordering within `(tab, subTab)`.
9170
+ * - `kind` — always `'remote'` for widgets.
9171
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
9172
+ *
9173
+ * Widget-only fields retained alongside the contribution core:
9174
+ *
9175
+ * - `stableId` — stable identity within the addon (the MF
9176
+ * `componentKey`; kept top-level so consumers have
9177
+ * a stable key without reaching into `remote`).
9178
+ * - `description` / `icon` — picker metadata.
9179
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
9180
+ * aggregator stamps a versioned `bundleUrl` from it.
9181
+ * - `hosts` — every host the widget supports (a widget can run
9182
+ * both on the dashboard and a device tab). `tab`
9183
+ * is the PRIMARY host; `hosts` is the full set the
9184
+ * picker filters on.
9185
+ * - `requires` — host-context requirements validated at mount.
9186
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
9187
+ * — dashboard placement hints.
9188
+ */
9189
+ var WidgetMetadataSchema = object({
9190
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
9191
+ tab: string(),
9192
+ /** Optional sub-tab within `tab`. */
9193
+ subTab: string().optional(),
9194
+ /** Operator-facing label. */
9195
+ label: string(),
9196
+ /** Ordering within `(tab, subTab)`, ascending. */
9197
+ order: number().optional(),
9198
+ /** Always `'remote'` — a widget is a Module Federation remote. */
9199
+ kind: literal("remote"),
9200
+ /** MF remote descriptor. */
9201
+ remote: WidgetRemoteSchema,
9202
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9203
+ stableId: string(),
9204
+ description: string().optional(),
9205
+ icon: string().optional(),
9206
+ /**
9207
+ * Bundle filename inside the addon's `dist/` dir served at
9208
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9209
+ * this is always `'remoteEntry.js'` — the value is kept on the
9210
+ * metadata so the static-file route can compute an mtime-based
9211
+ * cache-buster URL without a separate filesystem stat.
9212
+ */
9213
+ bundle: string(),
9214
+ /** Every host the widget supports. The picker filters on this set. */
9215
+ hosts: array(WidgetHostEnum).readonly(),
9216
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9217
+ requires: object({
9218
+ deviceContext: boolean().default(false),
9219
+ integrationContext: boolean().default(false)
9220
+ }),
9221
+ /**
9222
+ * Loadable BEFORE authentication. The normal widget registry listing
9223
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9224
+ * (the login page) cannot discover a widget through it. A widget that
9225
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9226
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9227
+ * login-method contribution channel (see `login-method.cap.ts`) rather
9228
+ * than the authenticated registry, and its bundle is served by the
9229
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9230
+ */
9231
+ preAuth: boolean().optional().default(false),
9232
+ /** Dashboard placement HINTS (operator can override per instance). */
9233
+ defaultSize: WidgetSizeEnum.default("md"),
9234
+ allowedSizes: array(WidgetSizeEnum).readonly().default([
9235
+ "sm",
9236
+ "md",
9237
+ "lg"
9238
+ ]),
9239
+ defaultColumns: number().int().min(1).max(12).default(6),
9240
+ defaultRows: number().int().min(1).max(12).default(1)
9241
+ });
9242
+ var addonWidgetsSourceCapability = {
9243
+ name: "addon-widgets-source",
9244
+ scope: "system",
9245
+ mode: "collection",
9246
+ internal: true,
9247
+ methods: { listWidgets: method(_void(), array(WidgetMetadataSchema).readonly()) }
9248
+ };
9249
+ /**
9250
+ * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
9251
+ * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9252
+ *
9253
+ * The provider iterates every `addon-widgets-source` (collection)
9254
+ * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9255
+ * `bundleUrl` strings pointing at
9256
+ * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9257
+ * `mtime` cache-buster lets the browser pick up addon rebuilds without
9258
+ * manual reload — same scheme used by `addon-pages`.
9259
+ *
9260
+ * The hub-local builtin `addon-widgets-aggregator` (see
9261
+ * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9262
+ * provider. Splitting the public aggregator from the raw collection
9263
+ * keeps both ends in codegen — there's no hand-written wrapper.
9264
+ */
9265
+ var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9266
+ addonId: string(),
9267
+ bundleUrl: string()
9268
+ });
9269
+ method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9270
+ /**
9271
+ * Alerts capability — collection-based internal alert system.
9272
+ *
9273
+ * Multiple providers can register. Each provider filters by EventBus category
9274
+ * and creates/updates alerts. The built-in Alert Center addon persists alerts
9275
+ * in the DB and serves them to the admin UI.
9276
+ */
9277
+ var AlertSeveritySchema = _enum([
9278
+ "info",
9279
+ "success",
9280
+ "warning",
9281
+ "error"
9282
+ ]);
9283
+ var AlertStatusSchema = _enum([
9284
+ "active",
9285
+ "in-progress",
9286
+ "completed",
9287
+ "failed",
9288
+ "dismissed"
9289
+ ]);
9290
+ var AlertSourceSchema = object({
9291
+ type: string(),
9292
+ id: string()
9293
+ });
9294
+ var AlertSchema = object({
9295
+ id: string(),
9296
+ category: string(),
8514
9297
  severity: AlertSeveritySchema,
8515
9298
  title: string(),
8516
9299
  message: string(),
@@ -12177,7 +12960,7 @@ DeviceType.Camera, method(object({
12177
12960
  * Why: pub/sub routing over the system event-bus loses fidelity
12178
12961
  * (callback shape, QoS guarantees, will/retain semantics) and adds
12179
12962
  * refcount bookkeeping that addons would rather own themselves. The
12180
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
12963
+ * canonical consumer needs raw `mqtt.js`
12181
12964
  * features anyway — give it the connection config, get out of the way.
12182
12965
  *
12183
12966
  * Consumer flow:
@@ -15200,8 +15983,26 @@ var TrackSchema = object({
15200
15983
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
15201
15984
  * saveThumbnails policy). */
15202
15985
  snapshots: array(TrackSnapshotSchema).readonly(),
15203
- /** Deduplicated zones the track has entered at least once. */
15986
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
15204
15987
  zonesVisited: array(string()).readonly(),
15988
+ /**
15989
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
15990
+ * `zones` capability.
15991
+ *
15992
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
15993
+ * and no card can render — so every free-text search surface was structurally
15994
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
15995
+ * just returned nothing. Resolving here rather than in each client keeps ONE
15996
+ * derivation and costs the clients no extra call (the `zones` cap is
15997
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
15998
+ * surface built to avoid exactly that).
15999
+ *
16000
+ * Resolved, never invented: a zone deleted since the track was written has no
16001
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
16002
+ * two are not positionally aligned. Absent when the track visited no zone, or
16003
+ * when the zone catalogue could not be read.
16004
+ */
16005
+ zoneNames: array(string()).readonly().optional(),
15205
16006
  /** Deduplicated set of detector classes observed for this track over its
15206
16007
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
15207
16008
  * legacy rows written before class accumulation shipped. */
@@ -17695,6 +18496,23 @@ var CameraRecordingStatusSchema = object({
17695
18496
  active: boolean(),
17696
18497
  storageBytes: number()
17697
18498
  });
18499
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
18500
+ var CameraStatusDegradationSchema = object({
18501
+ stage: _enum([
18502
+ "source",
18503
+ "broker",
18504
+ "detection",
18505
+ "recording",
18506
+ "switches"
18507
+ ]),
18508
+ reason: _enum([
18509
+ "timeout",
18510
+ "error",
18511
+ "partial"
18512
+ ]),
18513
+ /** Wall-clock ms spent on the stage before it was abandoned. */
18514
+ elapsedMs: number()
18515
+ });
17698
18516
  /**
17699
18517
  * Aggregated per-camera pipeline status — server-composed, single call.
17700
18518
  *
@@ -17725,9 +18543,28 @@ var CameraStatusSchema = object({
17725
18543
  * differently — a quiet camera that looks identical to a dead one is the
17726
18544
  * silence-reads-as-never-happened trap this repo keeps paying for.
17727
18545
  *
17728
- * Empty when nothing is off. Never contains a switch no provider offers.
18546
+ * Empty when nothing is off, and never contains a switch no provider offers
18547
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
18548
+ * `'switches'`. When it does, the switch set could not be read and nothing
18549
+ * here may be rendered as "the operator turned nothing off": that is the
18550
+ * D62 failure (a camera we could not read painted as broken) in the very
18551
+ * field that exists to prevent it.
17729
18552
  */
17730
18553
  switchedOff: array(CameraSwitchIdSchema).readonly(),
18554
+ /**
18555
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
18556
+ * rejection — and whose block is therefore `null` because we could not
18557
+ * READ it, not because there is nothing there.
18558
+ *
18559
+ * Without this, three different facts arrive as the same `null`: "the stage
18560
+ * timed out", "the stage failed", and "this camera legitimately has no
18561
+ * decoder / no recording". Every surface that draws a conclusion from a null
18562
+ * block (or from an empty `switchedOff`) must consult this first; a stage
18563
+ * named here supports no conclusion at all, only "unknown".
18564
+ *
18565
+ * Empty on a clean read — the overwhelmingly common case.
18566
+ */
18567
+ degraded: array(CameraStatusDegradationSchema).readonly(),
17731
18568
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
17732
18569
  fetchedAt: number()
17733
18570
  });
@@ -18275,6 +19112,37 @@ DeviceType.Camera, method(object({
18275
19112
  lastCapturedAt: number().nullable(),
18276
19113
  cacheAgeMs: number().nullable(),
18277
19114
  etag: string().nullable()
19115
+ }))), systemMethod(object({
19116
+ /** The tiles a surface is actually rendering. One entry per (device,
19117
+ * width) the caller will paint — the width is snapped to the server's
19118
+ * ladder and becomes part of the link's SIGNED identity. */
19119
+ targets: array(object({
19120
+ deviceId: number(),
19121
+ /** Target width in px. Omit for the frame as captured — correct
19122
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
19123
+ width: number().int().positive().optional()
19124
+ })).min(1).max(200) }), array(object({
19125
+ deviceId: number(),
19126
+ /** Root-relative signed path, or null when the link plane is not
19127
+ * served (no data-plane facility). Present even for a device that has
19128
+ * never captured — the request is what triggers the first one (D94). */
19129
+ url: string().nullable(),
19130
+ /** Epoch ms of the frame this link serves. Null = never captured.
19131
+ * THE honest age: the tRPC path carried none before this. */
19132
+ capturedAt: number().nullable(),
19133
+ /** Age of that frame at the moment the answer was built. */
19134
+ ageMs: number().nullable(),
19135
+ /** Epoch ms after which `url` stops verifying. */
19136
+ expiresAt: number().nullable(),
19137
+ /** Ladder rung the bytes are at; null = the frame as captured. */
19138
+ width: number().nullable(),
19139
+ /** The device has never produced a frame. An empty state, not a
19140
+ * failure — and never a reason to withhold the link (D94). */
19141
+ neverCaptured: boolean(),
19142
+ /** A sleeping battery camera: the frame is deliberately stale and will
19143
+ * NOT refresh in the background. A surface should say so rather than
19144
+ * present it as current. */
19145
+ sleeping: boolean()
18278
19146
  })));
18279
19147
  /**
18280
19148
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -26990,864 +27858,237 @@ var BaseDevice = class {
26990
27858
  getAccessoryChildren() {
26991
27859
  return [];
26992
27860
  }
26993
- /**
26994
- * Read the current feature-probe flag bag with a typed cast. Helper
26995
- * for `getAccessoryChildren()` and `features` getters that derive
26996
- * outputs from the probe results.
26997
- */
26998
- getProbeFlags() {
26999
- return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
27000
- }
27001
- /**
27002
- * Returns true once `onProbe` has completed at least once
27003
- * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
27004
- * to avoid spawning stale accessories on a fresh device whose probe
27005
- * hasn't landed yet.
27006
- */
27007
- hasProbed() {
27008
- return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
27009
- }
27010
- };
27011
- /** Marker written to a declared integration's `info`. */
27012
- var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
27013
- /**
27014
- * Strip the `<node>/<addon>` suffix a forked child carries.
27015
- *
27016
- * Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
27017
- * that was supposed to act — because on the hub it reads `hub/<addon>`.
27018
- */
27019
- function declarationOwnerNodeId(localNodeId) {
27020
- const raw = localNodeId ?? "hub";
27021
- if (!raw.includes("/")) return raw;
27022
- return raw.split("/")[0] ?? "hub";
27023
- }
27024
- /**
27025
- * The one way an addon owns a device it declares.
27026
- *
27027
- * Construct once with the addon's ports, then call {@link reconcile} on boot and
27028
- * on every convergence tick. There is no second get-or-create helper — a guard
27029
- * in `scripts/` enforces that.
27030
- */
27031
- var DeclaredDevices = class {
27032
- ports;
27033
- constructor(ports) {
27034
- this.ports = ports;
27035
- }
27036
- /**
27037
- * Converge the declared set. Idempotent, and safe to call repeatedly.
27038
- *
27039
- * Throws only what the ports throw on the FIRST index read; every other
27040
- * failure is per-device and logged, so one bad declaration never takes the
27041
- * others down.
27042
- */
27043
- async reconcile(spec) {
27044
- if ((spec.placement ?? "hub") === "hub") {
27045
- const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
27046
- if (nodeId !== "hub") {
27047
- this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
27048
- nodeId,
27049
- rawNodeId: this.ports.localNodeId ?? null
27050
- } });
27051
- return {
27052
- integrationId: null,
27053
- devices: [],
27054
- removed: [],
27055
- owned: false
27056
- };
27057
- }
27058
- }
27059
- const integrationId = await this.ensureIntegration(spec.integrationName);
27060
- const index = await this.readIndex();
27061
- const outcomes = [];
27062
- for (const declaration of spec.devices) {
27063
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27064
- if (outcome !== null) outcomes.push(outcome);
27065
- }
27066
- return {
27067
- integrationId,
27068
- devices: outcomes,
27069
- removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
27070
- owned: true
27071
- };
27072
- }
27073
- /**
27074
- * Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
27075
- *
27076
- * The re-assertion is the fix for the defect the hand-rolled version shipped
27077
- * with: writing `info.fixed` only on the create path left every pre-existing
27078
- * install without it, and the kernel kept offering to delete an integration
27079
- * the addon owns.
27080
- */
27081
- async ensureIntegration(integrationName) {
27082
- const existing = await this.ports.getIntegration(this.ports.addonId);
27083
- if (existing === null) {
27084
- const created = await this.ports.createIntegration({
27085
- addonId: this.ports.addonId,
27086
- name: integrationName,
27087
- info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27088
- });
27089
- this.ports.logger.info("declared a fixed integration", { meta: {
27090
- integrationId: created.id,
27091
- name: integrationName
27092
- } });
27093
- return created.id;
27094
- }
27095
- if (existing.info?.["fixed"] !== true) {
27096
- await this.ports.updateIntegration({
27097
- id: existing.id,
27098
- info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27099
- });
27100
- this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
27101
- }
27102
- return existing.id;
27103
- }
27104
- async readIndex() {
27105
- const rows = await this.ports.listOwnDevices();
27106
- return new Map(rows.map((row) => [row.stableId, row]));
27107
- }
27108
- /**
27109
- * One declaration: adopt what exists, create what does not.
27110
- *
27111
- * The create branch is the destructive one — it seeds `initialMeta`, and
27112
- * `initialMeta.name` lands as an unconditional `setName`. A transiently empty
27113
- * index therefore looks exactly like a first boot and would silently re-stamp
27114
- * the declared name over the operator's rename. D49: that branch needs a
27115
- * second read to agree.
27116
- */
27117
- async applyDeclaration(declaration, integrationId, index) {
27118
- try {
27119
- let existing = index.get(declaration.stableId);
27120
- if (existing === void 0) {
27121
- existing = (await this.readIndex()).get(declaration.stableId);
27122
- if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
27123
- tags: { deviceId: existing.id },
27124
- meta: {
27125
- stableId: declaration.stableId,
27126
- addonId: this.ports.addonId
27127
- }
27128
- });
27129
- }
27130
- if (existing !== void 0) {
27131
- const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
27132
- this.ports.logger.info("declared device adopted", {
27133
- tags: { deviceId: device.id },
27134
- meta: {
27135
- stableId: declaration.stableId,
27136
- integrationId
27137
- }
27138
- });
27139
- return {
27140
- stableId: declaration.stableId,
27141
- deviceId: device.id,
27142
- device,
27143
- created: false
27144
- };
27145
- }
27146
- const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
27147
- type: declaration.type,
27148
- name: declaration.name,
27149
- integrationId,
27150
- ...declaration.role === void 0 ? {} : { role: declaration.role }
27151
- });
27152
- this.ports.logger.info("declared device created", {
27153
- tags: { deviceId: device.id },
27154
- meta: {
27155
- stableId: declaration.stableId,
27156
- integrationId
27157
- }
27158
- });
27159
- return {
27160
- stableId: declaration.stableId,
27161
- deviceId: device.id,
27162
- device,
27163
- created: true
27164
- };
27165
- } catch (err) {
27166
- this.ports.logger.warn("a declared device could not be brought up", { meta: {
27167
- stableId: declaration.stableId,
27168
- error: err instanceof Error ? err.message : String(err)
27169
- } });
27170
- return null;
27171
- }
27172
- }
27173
- /**
27174
- * Remove rows under the addon's FIXED integration whose declaration is gone.
27175
- *
27176
- * Bounded to that integration: a declared integration has no operator
27177
- * add-flow, so every row under it got there by declaration. Devices this
27178
- * addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
27179
- *
27180
- * Bounded in count, and every deletion is logged with its `deviceId` — a
27181
- * withdrawal that removes an operator-visible row silently is the failure
27182
- * mode, not the removal itself.
27183
- */
27184
- async sweepWithdrawn(declarations, integrationId, index) {
27185
- const declared = new Set(declarations.map((d) => d.stableId));
27186
- const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
27187
- if (candidates.length === 0) return [];
27188
- if (candidates.length > 32) {
27189
- this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
27190
- integrationId,
27191
- candidates: candidates.length,
27192
- bound: 32
27193
- } });
27194
- return [];
27195
- }
27196
- const removed = [];
27197
- for (const row of candidates) try {
27198
- await this.ports.devices.remove(row.id);
27199
- removed.push(row.id);
27200
- this.ports.logger.info("declared device removed — its declaration was withdrawn", {
27201
- tags: { deviceId: row.id },
27202
- meta: {
27203
- stableId: row.stableId,
27204
- integrationId
27205
- }
27206
- });
27207
- } catch (err) {
27208
- this.ports.logger.warn("a withdrawn declared device could not be removed", {
27209
- tags: { deviceId: row.id },
27210
- meta: {
27211
- stableId: row.stableId,
27212
- error: err instanceof Error ? err.message : String(err)
27213
- }
27214
- });
27215
- }
27216
- return removed;
27217
- }
27218
- };
27219
- 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;
27220
- new Set(Object.values(DeviceType));
27221
- DeviceFeature.BatteryOperated;
27222
- /**
27223
- * Error types for the safe expression engine. Two distinct classes so callers
27224
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
27225
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
27226
- */
27227
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
27228
- * the failure is anchored to a character (author-facing inline feedback). */
27229
- var ExpressionParseError = class extends Error {
27230
- position;
27231
- constructor(message, position) {
27232
- super(message);
27233
- this.name = "ExpressionParseError";
27234
- this.position = position;
27235
- }
27236
- };
27237
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
27238
- * result, unknown builtin, step-budget exceeded). */
27239
- var ExpressionEvalError = class extends Error {
27240
- constructor(message) {
27241
- super(message);
27242
- this.name = "ExpressionEvalError";
27243
- }
27244
- };
27245
- /**
27246
- * Frozen, null-prototype builtin function table for the expression engine
27247
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
27248
- * parser rejects any callee not in it, and the evaluator gates each call on an
27249
- * own-property check against it.
27250
- *
27251
- * Because the object has a NULL prototype AND is `Object.freeze`d:
27252
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
27253
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
27254
- * (there is no `Object.prototype` in the chain), so those names are not
27255
- * callable — they are simply "unknown function" at parse time.
27256
- *
27257
- * Every numeric argument is validated as a finite number and every numeric
27258
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
27259
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
27260
- * closed rather than emitting a garbage value.
27261
- */
27262
- function asFiniteNumber(value, name, index) {
27263
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
27264
- return value;
27265
- }
27266
- function asString$1(value, name, index) {
27267
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
27268
- return value;
27269
- }
27270
- function finiteResult(value, name) {
27271
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
27272
- return value;
27273
- }
27274
- function allFiniteNumbers(args, name) {
27275
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
27276
- }
27277
- var INF = Number.POSITIVE_INFINITY;
27278
- var table = {
27279
- min: {
27280
- minArgs: 1,
27281
- maxArgs: INF,
27282
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
27283
- },
27284
- max: {
27285
- minArgs: 1,
27286
- maxArgs: INF,
27287
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
27288
- },
27289
- abs: {
27290
- minArgs: 1,
27291
- maxArgs: 1,
27292
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
27293
- },
27294
- floor: {
27295
- minArgs: 1,
27296
- maxArgs: 1,
27297
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
27298
- },
27299
- ceil: {
27300
- minArgs: 1,
27301
- maxArgs: 1,
27302
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
27303
- },
27304
- sqrt: {
27305
- minArgs: 1,
27306
- maxArgs: 1,
27307
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
27308
- },
27309
- round: {
27310
- minArgs: 1,
27311
- maxArgs: 2,
27312
- apply: (args) => {
27313
- const x = asFiniteNumber(args[0], "round", 0);
27314
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
27315
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
27316
- const factor = 10 ** digits;
27317
- return finiteResult(Math.round(x * factor) / factor, "round");
27318
- }
27319
- },
27320
- pow: {
27321
- minArgs: 2,
27322
- maxArgs: 2,
27323
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
27324
- },
27325
- clamp: {
27326
- minArgs: 3,
27327
- maxArgs: 3,
27328
- apply: (args) => {
27329
- const x = asFiniteNumber(args[0], "clamp", 0);
27330
- const lo = asFiniteNumber(args[1], "clamp", 1);
27331
- const hi = asFiniteNumber(args[2], "clamp", 2);
27332
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
27333
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
27334
- }
27335
- },
27336
- avg: {
27337
- minArgs: 1,
27338
- maxArgs: INF,
27339
- apply: (args) => {
27340
- const nums = allFiniteNumbers(args, "avg");
27341
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
27342
- }
27343
- },
27344
- sum: {
27345
- minArgs: 1,
27346
- maxArgs: INF,
27347
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
27348
- },
27349
- coalesce: {
27350
- minArgs: 1,
27351
- maxArgs: INF,
27352
- apply: (args) => {
27353
- for (const a of args) if (a !== null) return a;
27354
- return null;
27355
- }
27356
- },
27357
- age: {
27358
- minArgs: 2,
27359
- maxArgs: 2,
27360
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
27361
- },
27362
- convert: {
27363
- minArgs: 3,
27364
- maxArgs: 3,
27365
- apply: (args, hooks) => {
27366
- const x = asFiniteNumber(args[0], "convert", 0);
27367
- const from = asString$1(args[1], "convert", 1).trim();
27368
- const to = asString$1(args[2], "convert", 2).trim();
27369
- if (hooks.convert) {
27370
- const out = hooks.convert(x, from, to);
27371
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
27372
- return finiteResult(out, "convert");
27373
- }
27374
- if (from === to) return x;
27375
- throw new ExpressionEvalError("convert: unit conversion table not installed");
27376
- }
27377
- }
27378
- };
27379
- Object.freeze(Object.assign(Object.create(null), table));
27380
- /** The set of valid builtin names — used by the parser to reject unknown
27381
- * callees at parse time (immediate author feedback). */
27382
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
27383
- /**
27384
- * Resource-bound constants for the safe expression engine.
27385
- *
27386
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
27387
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
27388
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
27389
- * work a single author-supplied expression can request, so a hostile or
27390
- * accidental pathological string can never spend unbounded CPU/memory.
27391
- */
27392
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
27393
- * rejected without allocation. */
27394
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
27395
- /** A legal binding / identifier name. */
27396
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
27397
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
27398
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
27399
- var RESERVED_BINDING_NAMES = new Set([
27400
- "now",
27401
- "true",
27402
- "false",
27403
- "null"
27404
- ]);
27405
- /**
27406
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
27407
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
27408
- * single/double-quoted strings with a tiny escape set, identifiers, the three
27409
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
27410
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
27411
- * is a parse error with a source position, so member access / assignment /
27412
- * template literals are lexically impossible.
27413
- */
27414
- var KEYWORDS = new Set([
27415
- "true",
27416
- "false",
27417
- "null"
27418
- ]);
27419
- function isDigit(ch) {
27420
- return ch >= "0" && ch <= "9";
27421
- }
27422
- function isIdentStart(ch) {
27423
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
27424
- }
27425
- function isIdentPart(ch) {
27426
- return isIdentStart(ch) || isDigit(ch);
27427
- }
27428
- function isWhitespace(ch) {
27429
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
27430
- }
27431
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
27432
- * Throws `ExpressionParseError` on any illegal character or unterminated
27433
- * string. */
27434
- function tokenize(source) {
27435
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
27436
- const tokens = [];
27437
- let i = 0;
27438
- const n = source.length;
27439
- while (i < n) {
27440
- const ch = source[i];
27441
- if (isWhitespace(ch)) {
27442
- i += 1;
27443
- continue;
27444
- }
27445
- if (isDigit(ch)) {
27446
- const start = i;
27447
- while (i < n && isDigit(source[i])) i += 1;
27448
- if (i < n && source[i] === ".") {
27449
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
27450
- i += 1;
27451
- while (i < n && isDigit(source[i])) i += 1;
27452
- }
27453
- const text = source.slice(start, i);
27454
- const value = Number(text);
27455
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
27456
- tokens.push({
27457
- type: "number",
27458
- value,
27459
- pos: start
27460
- });
27461
- continue;
27462
- }
27463
- if (ch === "'" || ch === "\"") {
27464
- const quote = ch;
27465
- const start = i;
27466
- i += 1;
27467
- let out = "";
27468
- let closed = false;
27469
- while (i < n) {
27470
- const c = source[i];
27471
- if (c === "\\") {
27472
- const next = i + 1 < n ? source[i + 1] : "";
27473
- if (next === "\\" || next === "'" || next === "\"") {
27474
- out += next;
27475
- i += 2;
27476
- continue;
27477
- }
27478
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
27479
- }
27480
- if (c === quote) {
27481
- closed = true;
27482
- i += 1;
27483
- break;
27484
- }
27485
- out += c;
27486
- i += 1;
27487
- }
27488
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
27489
- tokens.push({
27490
- type: "string",
27491
- value: out,
27492
- pos: start
27493
- });
27494
- continue;
27495
- }
27496
- if (isIdentStart(ch)) {
27497
- const start = i;
27498
- while (i < n && isIdentPart(source[i])) i += 1;
27499
- const text = source.slice(start, i);
27500
- if (KEYWORDS.has(text)) tokens.push({
27501
- type: "keyword",
27502
- keyword: keywordOf(text),
27503
- pos: start
27504
- });
27505
- else tokens.push({
27506
- type: "identifier",
27507
- name: text,
27508
- pos: start
27509
- });
27510
- continue;
27511
- }
27512
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
27513
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
27514
- tokens.push({
27515
- type: "punct",
27516
- punct: two,
27517
- pos: i
27518
- });
27519
- i += 2;
27520
- continue;
27521
- }
27522
- if (isSinglePunct(ch)) {
27523
- tokens.push({
27524
- type: "punct",
27525
- punct: ch,
27526
- pos: i
27527
- });
27528
- i += 1;
27529
- continue;
27530
- }
27531
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
27861
+ /**
27862
+ * Read the current feature-probe flag bag with a typed cast. Helper
27863
+ * for `getAccessoryChildren()` and `features` getters that derive
27864
+ * outputs from the probe results.
27865
+ */
27866
+ getProbeFlags() {
27867
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
27532
27868
  }
27533
- tokens.push({
27534
- type: "eof",
27535
- pos: n
27536
- });
27537
- return tokens;
27538
- }
27539
- function keywordOf(text) {
27540
- if (text === "true") return "true";
27541
- if (text === "false") return "false";
27542
- return "null";
27543
- }
27544
- function isSinglePunct(ch) {
27545
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
27546
- }
27869
+ /**
27870
+ * Returns true once `onProbe` has completed at least once
27871
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
27872
+ * to avoid spawning stale accessories on a fresh device whose probe
27873
+ * hasn't landed yet.
27874
+ */
27875
+ hasProbed() {
27876
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
27877
+ }
27878
+ };
27879
+ /** Marker written to a declared integration's `info`. */
27880
+ var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
27547
27881
  /**
27548
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
27549
- *
27550
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
27551
- * → relational → additive → multiplicative → unary `! -` → call / primary.
27552
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
27553
- * string validated against the builtin table at parse time, so an unknown
27554
- * function is rejected immediately (author feedback) and a persisted expression
27555
- * that references a since-removed builtin degrades at read.
27882
+ * Strip the `<node>/<addon>` suffix a forked child carries.
27556
27883
  *
27557
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
27558
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
27884
+ * Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
27885
+ * that was supposed to act — because on the hub it reads `hub/<addon>`.
27559
27886
  */
27560
- /** Binary/logical operator precedence (higher binds tighter). */
27561
- var BINARY_PRECEDENCE = {
27562
- "||": 1,
27563
- "&&": 2,
27564
- "==": 3,
27565
- "!=": 3,
27566
- "<": 4,
27567
- "<=": 4,
27568
- ">": 4,
27569
- ">=": 4,
27570
- "+": 5,
27571
- "-": 5,
27572
- "*": 6,
27573
- "/": 6,
27574
- "%": 6
27575
- };
27576
- function isLogicalOp(op) {
27577
- return op === "&&" || op === "||";
27578
- }
27579
- function isBinaryOp(op) {
27580
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
27887
+ function declarationOwnerNodeId(localNodeId) {
27888
+ const raw = localNodeId ?? "hub";
27889
+ if (!raw.includes("/")) return raw;
27890
+ return raw.split("/")[0] ?? "hub";
27581
27891
  }
27582
- var Parser = class {
27583
- tokens;
27584
- pos = 0;
27585
- nodeCount = 0;
27586
- identifiers = /* @__PURE__ */ new Set();
27587
- callees = /* @__PURE__ */ new Set();
27588
- constructor(tokens) {
27589
- this.tokens = tokens;
27892
+ /**
27893
+ * The one way an addon owns a device it declares.
27894
+ *
27895
+ * Construct once with the addon's ports, then call {@link reconcile} on boot and
27896
+ * on every convergence tick. There is no second get-or-create helper — a guard
27897
+ * in `scripts/` enforces that.
27898
+ */
27899
+ var DeclaredDevices = class {
27900
+ ports;
27901
+ constructor(ports) {
27902
+ const addonId = ports.addonId;
27903
+ if (typeof addonId !== "string" || addonId.length === 0) throw new Error(`DeclaredDevices: addonId must be the declaring addon's id, got ${JSON.stringify(addonId)}. On an addon context it is \`ctx.id\` — there is no \`ctx.addonId\`.`);
27904
+ this.ports = ports;
27590
27905
  }
27591
- parse() {
27592
- const ast = this.parseTernary();
27593
- const tok = this.peek();
27594
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
27906
+ /**
27907
+ * Converge the declared set. Idempotent, and safe to call repeatedly.
27908
+ *
27909
+ * Throws only what the ports throw on the FIRST index read; every other
27910
+ * failure is per-device and logged, so one bad declaration never takes the
27911
+ * others down.
27912
+ */
27913
+ async reconcile(spec) {
27914
+ if ((spec.placement ?? "hub") === "hub") {
27915
+ const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
27916
+ if (nodeId !== "hub") {
27917
+ this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
27918
+ nodeId,
27919
+ rawNodeId: this.ports.localNodeId ?? null
27920
+ } });
27921
+ return {
27922
+ integrationId: null,
27923
+ devices: [],
27924
+ removed: [],
27925
+ owned: false
27926
+ };
27927
+ }
27928
+ }
27929
+ const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27930
+ const index = await this.readIndex();
27931
+ const outcomes = [];
27932
+ for (const declaration of spec.devices) {
27933
+ const outcome = await this.applyDeclaration(declaration, integrationId, index);
27934
+ if (outcome !== null) outcomes.push(outcome);
27935
+ }
27595
27936
  return {
27596
- ast,
27597
- identifiers: this.identifiers,
27598
- callees: this.callees,
27599
- nodeCount: this.nodeCount
27937
+ integrationId,
27938
+ devices: outcomes,
27939
+ removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
27940
+ owned: true
27600
27941
  };
27601
27942
  }
27602
- peek() {
27603
- return this.tokens[this.pos];
27604
- }
27605
- next() {
27606
- return this.tokens[this.pos++];
27607
- }
27608
- /** Consume a punctuator token, erroring if the next token isn't it. */
27609
- expectPunct(punct) {
27610
- const tok = this.peek();
27611
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
27612
- this.pos += 1;
27613
- }
27614
- matchPunct(punct) {
27615
- const tok = this.peek();
27616
- if (tok.type === "punct" && tok.punct === punct) {
27617
- this.pos += 1;
27618
- return true;
27619
- }
27620
- return false;
27621
- }
27622
- countNode() {
27623
- this.nodeCount += 1;
27624
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
27625
- }
27626
- parseTernary() {
27627
- const test = this.parseBinary(1);
27628
- if (this.matchPunct("?")) {
27629
- const consequent = this.parseTernary();
27630
- this.expectPunct(":");
27631
- const alternate = this.parseTernary();
27632
- this.countNode();
27633
- return {
27634
- kind: "conditional",
27635
- test,
27636
- consequent,
27637
- alternate
27638
- };
27943
+ /**
27944
+ * Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
27945
+ *
27946
+ * The re-assertion is the fix for the defect the hand-rolled version shipped
27947
+ * with: writing `info.fixed` only on the create path left every pre-existing
27948
+ * install without it, and the kernel kept offering to delete an integration
27949
+ * the addon owns.
27950
+ */
27951
+ async ensureIntegration(integrationName) {
27952
+ const existing = await this.ports.getIntegration(this.ports.addonId);
27953
+ if (existing === null) {
27954
+ const created = await this.ports.createIntegration({
27955
+ addonId: this.ports.addonId,
27956
+ name: integrationName,
27957
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27958
+ });
27959
+ this.ports.logger.info("declared a fixed integration", { meta: {
27960
+ integrationId: created.id,
27961
+ name: integrationName
27962
+ } });
27963
+ return created.id;
27639
27964
  }
27640
- return test;
27641
- }
27642
- parseBinary(minPrec) {
27643
- let left = this.parseUnary();
27644
- for (;;) {
27645
- const tok = this.peek();
27646
- if (tok.type !== "punct") break;
27647
- const prec = BINARY_PRECEDENCE[tok.punct];
27648
- if (prec === void 0 || prec < minPrec) break;
27649
- const op = tok.punct;
27650
- this.pos += 1;
27651
- const right = this.parseBinary(prec + 1);
27652
- this.countNode();
27653
- if (isLogicalOp(op)) left = {
27654
- kind: "logical",
27655
- op,
27656
- left,
27657
- right
27658
- };
27659
- else if (isBinaryOp(op)) left = {
27660
- kind: "binary",
27661
- op,
27662
- left,
27663
- right
27664
- };
27665
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
27965
+ if (existing.info?.["fixed"] !== true) {
27966
+ await this.ports.updateIntegration({
27967
+ id: existing.id,
27968
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27969
+ });
27970
+ this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
27666
27971
  }
27667
- return left;
27972
+ return existing.id;
27668
27973
  }
27669
- parseUnary() {
27670
- const tok = this.peek();
27671
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
27672
- const op = tok.punct;
27673
- this.pos += 1;
27674
- const operand = this.parseUnary();
27675
- this.countNode();
27676
- return {
27677
- kind: "unary",
27678
- op,
27679
- operand
27680
- };
27681
- }
27682
- return this.parsePrimary();
27974
+ async readIndex() {
27975
+ const rows = await this.ports.listOwnDevices();
27976
+ return new Map(rows.map((row) => [row.stableId, row]));
27683
27977
  }
27684
- parsePrimary() {
27685
- const tok = this.next();
27686
- switch (tok.type) {
27687
- case "number":
27688
- this.countNode();
27689
- return {
27690
- kind: "literal",
27691
- value: tok.value
27692
- };
27693
- case "string":
27694
- this.countNode();
27695
- return {
27696
- kind: "literal",
27697
- value: tok.value
27698
- };
27699
- case "keyword":
27700
- this.countNode();
27701
- return {
27702
- kind: "literal",
27703
- value: tok.keyword === "null" ? null : tok.keyword === "true"
27704
- };
27705
- case "identifier": {
27706
- const nextTok = this.peek();
27707
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
27708
- this.identifiers.add(tok.name);
27709
- this.countNode();
27978
+ /**
27979
+ * One declaration: adopt what exists, create what does not.
27980
+ *
27981
+ * The create branch is the destructive one — it seeds `initialMeta`, and
27982
+ * `initialMeta.name` lands as an unconditional `setName`. A transiently empty
27983
+ * index therefore looks exactly like a first boot and would silently re-stamp
27984
+ * the declared name over the operator's rename. D49: that branch needs a
27985
+ * second read to agree.
27986
+ */
27987
+ async applyDeclaration(declaration, integrationId, index) {
27988
+ try {
27989
+ let existing = index.get(declaration.stableId);
27990
+ if (existing === void 0) {
27991
+ existing = (await this.readIndex()).get(declaration.stableId);
27992
+ if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
27993
+ tags: { deviceId: existing.id },
27994
+ meta: {
27995
+ stableId: declaration.stableId,
27996
+ addonId: this.ports.addonId
27997
+ }
27998
+ });
27999
+ }
28000
+ if (existing !== void 0) {
28001
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
28002
+ this.ports.logger.info("declared device adopted", {
28003
+ tags: { deviceId: device.id },
28004
+ meta: {
28005
+ stableId: declaration.stableId,
28006
+ integrationId
28007
+ }
28008
+ });
27710
28009
  return {
27711
- kind: "identifier",
27712
- name: tok.name
28010
+ stableId: declaration.stableId,
28011
+ deviceId: device.id,
28012
+ device,
28013
+ created: false
27713
28014
  };
27714
28015
  }
27715
- case "punct":
27716
- if (tok.punct === "(") {
27717
- const inner = this.parseTernary();
27718
- this.expectPunct(")");
27719
- return inner;
28016
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
28017
+ type: declaration.type,
28018
+ name: declaration.name,
28019
+ integrationId,
28020
+ ...declaration.role === void 0 ? {} : { role: declaration.role }
28021
+ });
28022
+ this.ports.logger.info("declared device created", {
28023
+ tags: { deviceId: device.id },
28024
+ meta: {
28025
+ stableId: declaration.stableId,
28026
+ integrationId
27720
28027
  }
27721
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
27722
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
28028
+ });
28029
+ return {
28030
+ stableId: declaration.stableId,
28031
+ deviceId: device.id,
28032
+ device,
28033
+ created: true
28034
+ };
28035
+ } catch (err) {
28036
+ this.ports.logger.warn("a declared device could not be brought up", { meta: {
28037
+ stableId: declaration.stableId,
28038
+ error: err instanceof Error ? err.message : String(err)
28039
+ } });
28040
+ return null;
27723
28041
  }
27724
28042
  }
27725
- parseCall(callee, pos) {
27726
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
27727
- this.expectPunct("(");
27728
- const args = [];
27729
- if (!this.matchPunct(")")) for (;;) {
27730
- args.push(this.parseTernary());
27731
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
27732
- if (this.matchPunct(",")) continue;
27733
- this.expectPunct(")");
27734
- break;
28043
+ /**
28044
+ * Remove rows under the addon's FIXED integration whose declaration is gone.
28045
+ *
28046
+ * Bounded to that integration: a declared integration has no operator
28047
+ * add-flow, so every row under it got there by declaration. Devices this
28048
+ * addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
28049
+ *
28050
+ * Bounded in count, and every deletion is logged with its `deviceId` — a
28051
+ * withdrawal that removes an operator-visible row silently is the failure
28052
+ * mode, not the removal itself.
28053
+ */
28054
+ async sweepWithdrawn(declarations, integrationId, index) {
28055
+ const declared = new Set(declarations.map((d) => d.stableId));
28056
+ const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
28057
+ if (candidates.length === 0) return [];
28058
+ if (candidates.length > 32) {
28059
+ this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
28060
+ integrationId,
28061
+ candidates: candidates.length,
28062
+ bound: 32
28063
+ } });
28064
+ return [];
27735
28065
  }
27736
- this.callees.add(callee);
27737
- this.countNode();
27738
- return {
27739
- kind: "call",
27740
- callee,
27741
- args
27742
- };
28066
+ const removed = [];
28067
+ for (const row of candidates) try {
28068
+ await this.ports.devices.remove(row.id);
28069
+ removed.push(row.id);
28070
+ this.ports.logger.info("declared device removed — its declaration was withdrawn", {
28071
+ tags: { deviceId: row.id },
28072
+ meta: {
28073
+ stableId: row.stableId,
28074
+ integrationId
28075
+ }
28076
+ });
28077
+ } catch (err) {
28078
+ this.ports.logger.warn("a withdrawn declared device could not be removed", {
28079
+ tags: { deviceId: row.id },
28080
+ meta: {
28081
+ stableId: row.stableId,
28082
+ error: err instanceof Error ? err.message : String(err)
28083
+ }
28084
+ });
28085
+ }
28086
+ return removed;
27743
28087
  }
27744
28088
  };
27745
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
27746
- * `ExpressionParseError` on any lexical or grammatical failure. */
27747
- function parseExpression(source) {
27748
- return new Parser(tokenize(source)).parse();
27749
- }
27750
- /**
27751
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
27752
- * by expr"). The cache stores BOTH successes and failures (negative caching),
27753
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
27754
- * one per read on a hot resolve path.
27755
- *
27756
- * The cache is a module-level singleton: entries are pure, content-addressed
27757
- * ASTs keyed by the raw source string, so sharing one instance across all
27758
- * callers is safe and maximises hit rate.
27759
- */
27760
- var cache = /* @__PURE__ */ new Map();
27761
- function getCached(source) {
27762
- const hit = cache.get(source);
27763
- if (hit !== void 0) {
27764
- cache.delete(source);
27765
- cache.set(source, hit);
27766
- return hit;
27767
- }
27768
- let result;
27769
- try {
27770
- result = {
27771
- ok: true,
27772
- parsed: parseExpression(source)
27773
- };
27774
- } catch (err) {
27775
- result = {
27776
- ok: false,
27777
- error: err instanceof ExpressionParseError ? err.message : String(err)
27778
- };
27779
- }
27780
- cache.set(source, result);
27781
- if (cache.size > 256) {
27782
- const oldest = cache.keys().next().value;
27783
- if (oldest !== void 0) cache.delete(oldest);
27784
- }
27785
- return result;
27786
- }
27787
- /** Compile `source`, returning a discriminated result instead of throwing.
27788
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
27789
- function compileExpressionSafe(source) {
27790
- return getCached(source);
27791
- }
27792
- Object.freeze({});
27793
- /**
27794
- * Author-time validation. Returns `null` when the source is valid, else a
27795
- * human-readable error message. Checks: the expression compiles; binding count
27796
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
27797
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
27798
- * FREE identifier of the AST is covered by a binding or the injected `now`.
27799
- */
27800
- function validateExpressionSource(src) {
27801
- const names = Object.keys(src.bindings);
27802
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
27803
- for (const name of names) {
27804
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
27805
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
27806
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
27807
- }
27808
- const compiled = compileExpressionSafe(src.expr);
27809
- if (!compiled.ok) return compiled.error;
27810
- const bound = new Set(names);
27811
- for (const id of compiled.parsed.identifiers) {
27812
- if (id === "now") continue;
27813
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
27814
- }
27815
- return null;
27816
- }
27817
- var ExpressionBindingSourceSchema = union([
27818
- object({
27819
- kind: literal("field").optional(),
27820
- sourceKey: string(),
27821
- cap: string(),
27822
- fieldPath: string()
27823
- }),
27824
- object({
27825
- kind: literal("literal"),
27826
- value: union([
27827
- string(),
27828
- number(),
27829
- boolean(),
27830
- _null()
27831
- ])
27832
- }),
27833
- object({
27834
- kind: literal("global"),
27835
- sourceStableId: string(),
27836
- cap: string(),
27837
- fieldPath: string()
27838
- })
27839
- ]);
27840
- object({
27841
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
27842
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
27843
- }).superRefine((src, ctx) => {
27844
- const err = validateExpressionSource(src);
27845
- if (err !== null) ctx.addIssue({
27846
- code: "custom",
27847
- message: err,
27848
- path: ["expr"]
27849
- });
27850
- });
28089
+ 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;
28090
+ new Set(Object.values(DeviceType));
28091
+ DeviceFeature.BatteryOperated;
27851
28092
  Object.freeze({
27852
28093
  "accessories.setChildHidden": {
27853
28094
  capName: "accessories",
@@ -32181,6 +32422,12 @@ Object.freeze({
32181
32422
  addonId: null,
32182
32423
  access: "view"
32183
32424
  },
32425
+ "snapshot.getSnapshotLinks": {
32426
+ capName: "snapshot",
32427
+ capScope: "device",
32428
+ addonId: null,
32429
+ access: "view"
32430
+ },
32184
32431
  "snapshot.getSnapshotOverview": {
32185
32432
  capName: "snapshot",
32186
32433
  capScope: "device",
@@ -33712,6 +33959,12 @@ Object.defineProperty(exports, "TrackSourceSchema", {
33712
33959
  return TrackSourceSchema;
33713
33960
  }
33714
33961
  });
33962
+ Object.defineProperty(exports, "__exportAll", {
33963
+ enumerable: true,
33964
+ get: function() {
33965
+ return __exportAll;
33966
+ }
33967
+ });
33715
33968
  Object.defineProperty(exports, "__toESM", {
33716
33969
  enumerable: true,
33717
33970
  get: function() {