@camstack/addon-notifiers 1.2.11 → 1.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +1226 -486
  2. package/dist/addon.mjs +1226 -486
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -2,7 +2,7 @@ import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- //#region ../types/dist/event-category-Bz24uP1U.mjs
5
+ //#region ../types/dist/event-category-41fKf-q9.mjs
6
6
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
7
7
  EventCategory["SystemBoot"] = "system.boot";
8
8
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -62,6 +62,26 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
62
62
  */
63
63
  EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
64
64
  /**
65
+ * A liveness invariant of this node is false — it has no devices, every
66
+ * camera is offline, or a camera that was recording has stopped producing
67
+ * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
68
+ * `findingId` is stable across ticks), only after its boot grace period.
69
+ * AlertCenter raises a persistent operator-visible alert.
70
+ *
71
+ * It exists because on 2026-08-03 the hub ran four hours with zero devices
72
+ * and zero recordings while every surface stayed quiet.
73
+ *
74
+ * Payload: `{ findingId, severity, title, message, deviceId? }`.
75
+ */
76
+ EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
77
+ /**
78
+ * A previously reported liveness fault is true again. AlertCenter dismisses
79
+ * the matching `SystemLivenessFailed` alert.
80
+ *
81
+ * Payload: `{ findingId }`.
82
+ */
83
+ EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
84
+ /**
65
85
  * Monitor is attempting to reload a failed addon NOW. UI uses this
66
86
  * to show a spinner during the retry attempt. Same transient nature
67
87
  * as AddonRetryScheduled.
@@ -7429,14 +7449,7 @@ var RecordingConfigSchema = object({
7429
7449
  * windows only — existing sheets are immutable, and each window's index
7430
7450
  * carries its own tile dims so mixed-preset history renders correctly.
7431
7451
  */
7432
- scrubThumbnails: ScrubThumbnailPresetSchema.optional(),
7433
- /**
7434
- * OPT-IN thumbnail-strip generation for this camera: every keyframe of the
7435
- * low recording saved as a JPEG (the fast-drag scrub depth), a derived
7436
- * cache that eviction reclaims with the footage. Absent/false = no strips
7437
- * are written and scrub reads exact keyframes at every velocity.
7438
- */
7439
- stripsEnabled: boolean().optional()
7452
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7440
7453
  }).strict();
7441
7454
  /**
7442
7455
  * Ops-log — the durable, append-only operations audit shared by the
@@ -7498,7 +7511,7 @@ var OpsLogQueryInputSchema = object({
7498
7511
  /**
7499
7512
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
7500
7513
  *
7501
- * One shape shared by the recorder's `relocateFootage` (segments + strips) and
7514
+ * One shape shared by the recorder's `relocateFootage` (segments) and
7502
7515
  * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7503
7516
  * page renders both movers with one component. Jobs are in-RAM (a restart
7504
7517
  * forgets them — re-running is safe by construction: copy-if-absent, delete
@@ -7520,7 +7533,7 @@ var RelocateJobSchema = object({
7520
7533
  toLocationId: string(),
7521
7534
  /** Scoped device, or null = every device. */
7522
7535
  deviceId: number().nullable(),
7523
- /** What the job moves (owner-addon specific: segments/strips or media). */
7536
+ /** What the job moves (owner-addon specific: segments or media). */
7524
7537
  entities: array(string()),
7525
7538
  filesMoved: number().int(),
7526
7539
  bytesMoved: number().int(),
@@ -7534,7 +7547,7 @@ var RelocateFootageInputSchema = object({
7534
7547
  deviceId: number().optional(),
7535
7548
  fromLocationId: string(),
7536
7549
  toLocationId: string(),
7537
- entities: array(_enum(["segments", "strips"])).optional(),
7550
+ entities: array(_enum(["segments"])).optional(),
7538
7551
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7539
7552
  * never allowed to starve live writers. */
7540
7553
  throttleMbps: number().min(1).max(1e3).optional()
@@ -9196,8 +9209,11 @@ function prepareNotification(caps, n) {
9196
9209
  });
9197
9210
  }
9198
9211
  const inActions = n.actions ?? [];
9199
- const actions = inActions.slice(0, Math.max(0, caps.actions));
9200
- if (inActions.length > actions.length) dropped.push("actions");
9212
+ const kept = inActions.slice(0, Math.max(0, caps.actions));
9213
+ if (inActions.length > kept.length) dropped.push("actions");
9214
+ const iconsSupported = caps.actionIcons === true;
9215
+ if (!iconsSupported && kept.some((a) => a.icon !== void 0)) dropped.push("actionIcons");
9216
+ const actions = iconsSupported ? kept : kept.map(({ icon: _icon, ...rest }) => rest);
9201
9217
  let clickUrl = null;
9202
9218
  if (n.clickUrl !== void 0) if (caps.clickUrl) clickUrl = n.clickUrl;
9203
9219
  else dropped.push("clickUrl");
@@ -9296,160 +9312,552 @@ var MaskGridDimsSchema = object({
9296
9312
  height: number()
9297
9313
  });
9298
9314
  /**
9299
- * notification-rulesthe Notification Center rule surface (P1 core).
9300
- *
9301
- * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
9302
- * (operator decisions D-1/D-2/D-3 are binding):
9315
+ * notification-outputcanonical, capability-gated notification delivery.
9303
9316
  *
9304
- * - D-2: rule EVALUATION lives in `addon-post-analysis` (the
9305
- * `notification-center` module), hooked on the durable persistence
9306
- * moments (object-event insert, TrackCloser.closeExpired) with a
9307
- * persisted outbox + retry never the lossy telemetry bus (D8).
9308
- * - D-3: urgency belongs to the RULE. `delivery: 'immediate'` fires on the
9309
- * FIRST persisted detection matching the conditions (per-track dedup,
9310
- * `maxPerTrack` fixed at 1 — see {@link NC_MAX_PER_TRACK_IMMEDIATE});
9311
- * `delivery: 'track-end'` evaluates the finalized track record at close.
9312
- * - DISPATCH stays behind `notification-output` (rules reference targets
9313
- * by id; per-backend params are a passthrough blob capped by the
9314
- * target kind's own caps/degrade engine).
9317
+ * Apprise-derived model (see
9318
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
9319
+ * callers emit ONE canonical `Notification`; each provider declares a
9320
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
9321
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
9322
+ * message to what the kind supports — callers never special-case a service.
9315
9323
  *
9316
- * P1 scope: admin-authored rules only (`createdBy` stamped from the
9317
- * server-injected caller identity the first `caller: 'required'`
9318
- * adopter). The P1 condition subset is: devices, classes(+exclude),
9319
- * minConfidence, admin zones (any/all + exclude), weekly schedule
9320
- * windows, and the optional label/identity/plate matchers. User rules,
9321
- * private zones, per-recipient fan-out and the wider condition table are
9322
- * P2+ (see spec §7).
9324
+ * DESIGN DECISIONS (locked):
9325
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
9326
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
9327
+ * cap. Rationale: the admin UI needs one uniform surface across the
9328
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
9329
+ * alternative would fork the UI per addon and cannot host the
9330
+ * discovery→adopt flow.
9331
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
9332
+ * the generated cap-mount auto-`concatCollection`-fans them across every
9333
+ * registered provider (notifiers addon + HA addon) so one catalog is
9334
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
9335
+ * `addonId` the generated collection router extracts from the call input.
9336
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
9337
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
9338
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
9339
+ * base64 fallback needed.
9323
9340
  *
9324
- * All schemas here are the single source of truth `NcRule` etc. are
9325
- * `z.infer` exports; no duplicate interfaces (the advanced-notifier
9326
- * schema/interface drift is explicitly not repeated).
9341
+ * TODO (deferred, closed-set changeseparate decision): add
9342
+ * `providerKind: 'notify'` so notification providers surface on the unified
9343
+ * admin "Integrations" page.
9327
9344
  */
9328
9345
  /**
9329
- * D-3: the trigger/urgency of a rule which persistence moment evaluates it.
9330
- * The value maps 1:1 onto the evaluated record kind:
9331
- * - `immediate` ↔ object-event persist (lowest-latency detection burst)
9332
- * - `track-end` ↔ TrackCloser.closeExpired (finalized track record)
9333
- * - `device-event` ↔ SensorEventStore insert (doorbell press / sensor state
9334
- * change of a LINKED device, one row per linked camera)
9335
- * - `package-event` ↔ PackageDropDetector object-event insert (a `package`
9336
- * delivery / pick-up)
9337
- *
9338
- * `immediate`/`track-end` carry the D-3 urgency semantics; `device-event`/
9339
- * `package-event` are pure trigger kinds (no urgency dimension). Extending
9340
- * this one field keeps the schema additive — a rule still declares exactly
9341
- * one trigger.
9346
+ * Zentik-derived typed-media enumthe superset across every kind. Each
9347
+ * adapter picks what it supports and the degrade engine filters the rest.
9342
9348
  */
9343
- var NcDeliverySchema = _enum([
9344
- "immediate",
9345
- "track-end",
9346
- "device-event",
9347
- "package-event"
9349
+ var AttachmentMediaTypeSchema = _enum([
9350
+ "image",
9351
+ "video",
9352
+ "gif",
9353
+ "audio",
9354
+ "icon"
9348
9355
  ]);
9349
- /** Weekly schedule — OR of windows; absence on the rule = always active. */
9350
- var NcScheduleSchema = object({
9351
- windows: array(object({
9352
- /** Days of week the window STARTS on (0 = Sunday … 6 = Saturday). */
9353
- days: array(number().int().min(0).max(6)).min(1),
9354
- startMinute: number().int().min(0).max(1439),
9355
- endMinute: number().int().min(0).max(1439)
9356
- })).min(1),
9357
- /** IANA timezone; default = hub host timezone. */
9358
- timezone: string().optional(),
9359
- /** Active OUTSIDE the windows (e.g. "only outside business hours"). */
9360
- invert: boolean().optional()
9361
- });
9362
- /** Fuzzy plate matcher — OCR noise makes exact match useless (spec row 12/13). */
9363
- var NcPlateMatcherSchema = object({
9364
- values: array(string().min(1)).min(1),
9365
- /** Max Levenshtein distance after normalization (uppercase alphanumeric). */
9366
- maxDistance: number().int().min(0).max(3).default(1)
9367
- });
9368
9356
  /**
9369
- * Occupancy condition (DEVICE-EVENT trigger). Fires on a ZoneAnalytics
9370
- * occupancy edge for a device optionally narrowed to a single admin
9371
- * `zoneId` and/or object `className`. `op` selects the edge/threshold:
9372
- * - `became-occupied` (default) count crossed 0 `count`
9373
- * - `became-free` — count crossed ≥ `count` → below it
9374
- * - `>=` / `<=` — count is at/over or at/under `count`
9375
- * `sustainSeconds` requires the condition hold continuously that long
9376
- * before firing (debounces flicker; 0 = fire on the first matching edge).
9377
- * Fail-closed: no ZoneAnalytics snapshot / missing zone / null snapshot ⇒
9378
- * the condition never matches. Confirmed edge-state survives addon restarts
9379
- * (declared SQLite collection, reseeded on boot).
9357
+ * A single attachment. Exactly one of `url` (remote source, most adapters
9358
+ * prefer this) or `bytes` (inline source; required for Pushover-style
9359
+ * bytes-only kinds) MUST be present the degrade engine expresses a
9360
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
9380
9361
  */
9381
- var NcOccupancyConditionSchema = object({
9382
- /** Admin zone id to scope the count to; absent = whole-frame occupancy. */
9383
- zoneId: string().optional(),
9384
- /** Object class to count; absent = any class. */
9385
- className: string().optional(),
9386
- op: _enum([
9387
- "became-occupied",
9388
- "became-free",
9389
- ">=",
9390
- "<="
9391
- ]).default("became-occupied"),
9392
- count: number().int().min(0).default(1),
9393
- sustainSeconds: number().int().min(0).max(3600).default(15)
9394
- });
9362
+ var AttachmentSchema = object({
9363
+ mediaType: AttachmentMediaTypeSchema,
9364
+ url: string().optional(),
9365
+ bytes: _instanceof(Uint8Array).optional(),
9366
+ mime: string().optional(),
9367
+ name: string().optional()
9368
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
9369
+ var NotificationFormatSchema = _enum([
9370
+ "text",
9371
+ "markdown",
9372
+ "html"
9373
+ ]);
9395
9374
  /**
9396
- * Which zone-crossing DIRECTION a rule accepts (`ObjectEvent.zoneCrossing`).
9375
+ * The CLOSED icon vocabulary an action button may use.
9397
9376
  *
9398
- * The values are not symmetric, and deliberately so the absent value has to
9399
- * mean exactly what every rule authored before this condition existed already
9400
- * does:
9401
- * - `enter` entries and every NON-crossing record (movement state,
9402
- * package, sensor). Exits are rejected. **This is the absent behaviour**:
9403
- * an operator who never asked for exits must not start receiving them.
9404
- * - `exit` — ONLY an exit crossing. A record that is not a crossing at all
9405
- * fails closed, because "the car left the drive" is a question about a
9406
- * boundary, not about a detection.
9407
- * - `any` — no direction filter; entries, exits and non-crossings alike.
9377
+ * A closed set, not a free string, and that is the whole point: an arbitrary
9378
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
9379
+ * notices — the same class of gap as a zone vocabulary nothing produced
9380
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
9381
+ * declares `actionIcons: false` and the degrade engine strips the field.
9408
9382
  *
9409
- * A rule asking for a direction should normally also scope `zones`, which the
9410
- * engine evaluates against the crossed zone as well as the current membership
9411
- * (an exit's membership no longer contains the zone it just left).
9383
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
9384
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
9385
+ * differently.
9412
9386
  */
9413
- var NcCrossingSchema = _enum([
9414
- "enter",
9415
- "exit",
9416
- "any"
9387
+ var NotificationActionIconSchema = _enum([
9388
+ "acknowledge",
9389
+ "dismiss",
9390
+ "silence",
9391
+ "view",
9392
+ "play",
9393
+ "open",
9394
+ "close",
9395
+ "lock",
9396
+ "unlock",
9397
+ "arm",
9398
+ "disarm",
9399
+ "light",
9400
+ "alert"
9417
9401
  ]);
9418
- /** Admin-zone membership condition (zone IDs as stamped by the ZoneEngine). */
9419
- var NcZoneConditionSchema = object({
9420
- ids: array(string().min(1)).min(1),
9421
- /** Quantifier over `ids` — at least one / every one visited. */
9422
- match: _enum(["any", "all"]).default("any")
9402
+ /** A single tap-through action button. */
9403
+ var NotificationActionSchema = object({
9404
+ id: string(),
9405
+ label: string(),
9406
+ url: string().optional(),
9407
+ /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
9408
+ icon: NotificationActionIconSchema.optional(),
9409
+ /**
9410
+ * Renders in a warning style where the notifier supports it.
9411
+ *
9412
+ * A HINT, never a gate. The callback's authority is its token and nothing
9413
+ * else — see `notification-center/action-token.ts` for what that does and
9414
+ * does not buy.
9415
+ */
9416
+ destructive: boolean().optional()
9423
9417
  });
9424
9418
  /**
9425
- * The P1 condition set a flat AND of groups; absent group = pass;
9426
- * membership lists are OR within the list (spec §2.3).
9419
+ * The canonical notification. `body` is the only hard field (Apprise model).
9420
+ * `priority` is a 5-level ORDINAL (1=lowest 3=normal(default) … 5=urgent),
9421
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
9422
+ * the adapter maps this ordinal onto its native level. `level?` is an
9423
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
9424
+ * `priority` for that one target.
9427
9425
  */
9428
- var NcConditionsSchema = object({
9429
- /** Device scope — absent = all devices. */
9430
- devices: array(number()).optional(),
9431
- /** Detector class names (any overlap with the record's class set). */
9432
- classes: array(string().min(1)).optional(),
9433
- /** Veto classes — any overlap fails the rule. */
9434
- classesExclude: array(string().min(1)).optional(),
9435
- /** Minimum detection confidence 0–1 (fails when the record has none). */
9436
- minConfidence: number().min(0).max(1).optional(),
9437
- /** Admin zone membership over event `zones` / track `zonesVisited`. */
9438
- zones: NcZoneConditionSchema.optional(),
9439
- /** Veto zones — any hit fails the rule. */
9440
- zonesExclude: array(string().min(1)).optional(),
9441
- /**
9442
- * Zone-crossing direction. IMMEDIATE only — a crossing is a per-event fact
9443
- * and a closed track carries none, so a `track-end` rule asking for one
9444
- * fails closed (use `zones`, which tests `zonesVisited`). ABSENT = `enter`,
9445
- * which is exactly today's behaviour. See {@link NcCrossingSchema}.
9446
- */
9447
- crossing: NcCrossingSchema.optional(),
9426
+ var NotificationSchema = object({
9427
+ body: string(),
9428
+ title: string().optional(),
9429
+ format: NotificationFormatSchema.default("text"),
9430
+ priority: number().int().min(1).max(5).default(3),
9431
+ level: string().optional(),
9432
+ attachments: array(AttachmentSchema).optional(),
9433
+ clickUrl: string().optional(),
9434
+ actions: array(NotificationActionSchema).optional(),
9435
+ sound: string().optional(),
9436
+ ttl: number().optional(),
9437
+ tag: string().optional(),
9438
+ deviceId: number().optional(),
9439
+ eventId: string().optional(),
9440
+ metadata: record(string(), unknown()).optional()
9441
+ });
9442
+ /** One declared native severity/priority level for a kind. */
9443
+ var TargetKindLevelSchema = object({
9444
+ id: string(),
9445
+ label: string(),
9446
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
9447
+ ordinal: number().int().min(1).max(5).nullable(),
9448
+ flags: object({
9449
+ critical: boolean().optional(),
9450
+ silent: boolean().optional(),
9451
+ noPush: boolean().optional()
9452
+ }).optional(),
9453
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
9454
+ requires: array(string()).optional(),
9455
+ description: string().optional()
9456
+ });
9457
+ /** The full capability block consulted before dispatch. */
9458
+ var TargetKindCapsSchema = object({
9459
+ attachments: object({
9460
+ mediaTypes: array(AttachmentMediaTypeSchema),
9461
+ mode: _enum([
9462
+ "url",
9463
+ "bytes",
9464
+ "both"
9465
+ ]),
9466
+ max: number().int().nonnegative(),
9467
+ maxBytes: number().int().positive().optional()
9468
+ }),
9469
+ /** Max action buttons (0 = none). */
9470
+ actions: number().int().nonnegative(),
9448
9471
  /**
9449
- * Exact (case-insensitive) match on the record's collapsed `label`
9450
- * (identity name / plate text / subclass).
9472
+ * Whether this kind renders a per-action ICON.
9473
+ *
9474
+ * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
9475
+ * run on the addon cap path — three production failures in one day taught
9476
+ * this repo that once. Absent is read as false by the degrade engine, which
9477
+ * is the safe direction: an icon that is not rendered costs nothing, an icon
9478
+ * assumed and dropped costs the operator's trust in the field.
9451
9479
  */
9452
- labelEquals: array(string().min(1)).optional(),
9480
+ actionIcons: boolean().optional(),
9481
+ levels: array(TargetKindLevelSchema),
9482
+ format: array(NotificationFormatSchema),
9483
+ clickUrl: boolean(),
9484
+ sound: boolean(),
9485
+ ttl: boolean(),
9486
+ bodyMaxLen: number().int().positive()
9487
+ });
9488
+ /**
9489
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
9490
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
9491
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
9492
+ * the union is large and not meant for runtime validation here; the exported
9493
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
9494
+ */
9495
+ var ConfigSchemaPassthrough$1 = unknown();
9496
+ var TargetKindSchema = object({
9497
+ kind: string(),
9498
+ label: string(),
9499
+ icon: string(),
9500
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
9501
+ addonId: string(),
9502
+ /**
9503
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
9504
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
9505
+ * when the addon bundles no icon for that kind — the client then falls back
9506
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
9507
+ *
9508
+ * Root-relative on purpose: it resolves against whatever origin serves a web
9509
+ * client, and a native client joins it onto its own hub base.
9510
+ *
9511
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
9512
+ * field that survived only because the runtime cap-router forwards provider
9513
+ * output verbatim — so every consumer had to re-declare it by hand to stop
9514
+ * its own Zod parse from stripping it, and the whole arrangement would have
9515
+ * broken silently the moment output validation was tightened anywhere.
9516
+ */
9517
+ iconUrl: string().optional(),
9518
+ /**
9519
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
9520
+ *
9521
+ * The server knows this and therefore says it, because the client cannot
9522
+ * safely guess: a React-Native client renders SVG and raster through two
9523
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
9524
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
9525
+ * placeholder glyph for every vector icon while the web build looked fine.
9526
+ *
9527
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
9528
+ * not been updated — a client that cannot determine the type should prefer
9529
+ * its raster path, which is the safe default for an unknown image.
9530
+ */
9531
+ iconMediaType: string().optional(),
9532
+ configSchema: ConfigSchemaPassthrough$1,
9533
+ supportsDiscovery: boolean(),
9534
+ caps: TargetKindCapsSchema
9535
+ });
9536
+ /**
9537
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
9538
+ * (return a presence marker only) when serving `listTargets` — never
9539
+ * round-trip a stored secret to the UI.
9540
+ */
9541
+ var TargetSchema = object({
9542
+ id: string(),
9543
+ name: string(),
9544
+ kind: string(),
9545
+ addonId: string(),
9546
+ enabled: boolean(),
9547
+ config: record(string(), unknown())
9548
+ });
9549
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
9550
+ var DiscoveredTargetSchema = object({
9551
+ kind: string(),
9552
+ suggestedName: string(),
9553
+ config: record(string(), unknown())
9554
+ });
9555
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
9556
+ var RenderedAsSchema = object({
9557
+ level: string(),
9558
+ format: NotificationFormatSchema,
9559
+ attachmentsSent: number().int().nonnegative(),
9560
+ actionsSent: number().int().nonnegative(),
9561
+ truncated: boolean(),
9562
+ dropped: array(string())
9563
+ });
9564
+ var SendResultSchema = object({
9565
+ success: boolean(),
9566
+ error: string().optional(),
9567
+ renderedAs: RenderedAsSchema.optional()
9568
+ });
9569
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
9570
+ var TestResultSchema = SendResultSchema;
9571
+ var notificationOutputCapability = {
9572
+ name: "notification-output",
9573
+ scope: "system",
9574
+ mode: "collection",
9575
+ methods: {
9576
+ listTargetKinds: method(object({}), array(TargetKindSchema)),
9577
+ listTargets: method(object({}), array(TargetSchema)),
9578
+ discoverTargets: method(object({
9579
+ kind: string(),
9580
+ config: record(string(), unknown()).optional()
9581
+ }), array(DiscoveredTargetSchema)),
9582
+ send: method(object({
9583
+ targetId: string(),
9584
+ notification: NotificationSchema
9585
+ }), SendResultSchema, { kind: "mutation" }),
9586
+ testTarget: method(object({
9587
+ targetId: string(),
9588
+ sample: NotificationSchema.optional()
9589
+ }), TestResultSchema, { kind: "mutation" }),
9590
+ upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
9591
+ deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
9592
+ setTargetEnabled: method(object({
9593
+ targetId: string(),
9594
+ enabled: boolean()
9595
+ }), _void(), { kind: "mutation" })
9596
+ }
9597
+ };
9598
+ /**
9599
+ * notification-rules — the Notification Center rule surface (P1 core).
9600
+ *
9601
+ * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
9602
+ * (operator decisions D-1/D-2/D-3 are binding):
9603
+ *
9604
+ * - D-2: rule EVALUATION lives in `addon-post-analysis` (the
9605
+ * `notification-center` module), hooked on the durable persistence
9606
+ * moments (object-event insert, TrackCloser.closeExpired) with a
9607
+ * persisted outbox + retry — never the lossy telemetry bus (D8).
9608
+ * - D-3: urgency belongs to the RULE. `delivery: 'immediate'` fires on the
9609
+ * FIRST persisted detection matching the conditions (per-track dedup,
9610
+ * `maxPerTrack` fixed at 1 — see {@link NC_MAX_PER_TRACK_IMMEDIATE});
9611
+ * `delivery: 'track-end'` evaluates the finalized track record at close.
9612
+ * - DISPATCH stays behind `notification-output` (rules reference targets
9613
+ * by id; per-backend params are a passthrough blob capped by the
9614
+ * target kind's own caps/degrade engine).
9615
+ *
9616
+ * P1 scope: admin-authored rules only (`createdBy` stamped from the
9617
+ * server-injected caller identity — the first `caller: 'required'`
9618
+ * adopter). The P1 condition subset is: devices, classes(+exclude),
9619
+ * minConfidence, admin zones (any/all + exclude), weekly schedule
9620
+ * windows, and the optional label/identity/plate matchers. User rules,
9621
+ * private zones, per-recipient fan-out and the wider condition table are
9622
+ * P2+ (see spec §7).
9623
+ *
9624
+ * All schemas here are the single source of truth — `NcRule` etc. are
9625
+ * `z.infer` exports; no duplicate interfaces (the advanced-notifier
9626
+ * schema/interface drift is explicitly not repeated).
9627
+ */
9628
+ /**
9629
+ * D-3: the trigger/urgency of a rule — which persistence moment evaluates it.
9630
+ * The value maps 1:1 onto the evaluated record kind:
9631
+ * - `immediate` ↔ object-event persist (lowest-latency detection burst)
9632
+ * - `track-end` ↔ TrackCloser.closeExpired (finalized track record)
9633
+ * - `device-event` ↔ SensorEventStore insert (doorbell press / sensor state
9634
+ * change of a LINKED device, one row per linked camera)
9635
+ * - `package-event` ↔ PackageDropDetector object-event insert (a `package`
9636
+ * delivery / pick-up)
9637
+ *
9638
+ * `immediate`/`track-end` carry the D-3 urgency semantics; `device-event`/
9639
+ * `package-event` are pure trigger kinds (no urgency dimension). Extending
9640
+ * this one field keeps the schema additive — a rule still declares exactly
9641
+ * one trigger.
9642
+ */
9643
+ var NcDeliverySchema = _enum([
9644
+ "immediate",
9645
+ "track-end",
9646
+ "device-event",
9647
+ "package-event"
9648
+ ]);
9649
+ /** Weekly schedule — OR of windows; absence on the rule = always active. */
9650
+ var NcScheduleSchema = object({
9651
+ windows: array(object({
9652
+ /** Days of week the window STARTS on (0 = Sunday … 6 = Saturday). */
9653
+ days: array(number().int().min(0).max(6)).min(1),
9654
+ startMinute: number().int().min(0).max(1439),
9655
+ endMinute: number().int().min(0).max(1439)
9656
+ })).min(1),
9657
+ /** IANA timezone; default = hub host timezone. */
9658
+ timezone: string().optional(),
9659
+ /** Active OUTSIDE the windows (e.g. "only outside business hours"). */
9660
+ invert: boolean().optional()
9661
+ });
9662
+ /** Fuzzy plate matcher — OCR noise makes exact match useless (spec row 12/13). */
9663
+ var NcPlateMatcherSchema = object({
9664
+ values: array(string().min(1)).min(1),
9665
+ /** Max Levenshtein distance after normalization (uppercase alphanumeric). */
9666
+ maxDistance: number().int().min(0).max(3).default(1)
9667
+ });
9668
+ /**
9669
+ * Occupancy condition (DEVICE-EVENT trigger). Fires on a ZoneAnalytics
9670
+ * occupancy edge for a device — optionally narrowed to a single admin
9671
+ * `zoneId` and/or object `className`. `op` selects the edge/threshold:
9672
+ * - `became-occupied` (default) — count crossed 0 → ≥ `count`
9673
+ * - `became-free` — count crossed ≥ `count` → below it
9674
+ * - `>=` / `<=` — count is at/over or at/under `count`
9675
+ * `sustainSeconds` requires the condition hold continuously that long
9676
+ * before firing (debounces flicker; 0 = fire on the first matching edge).
9677
+ * Fail-closed: no ZoneAnalytics snapshot / missing zone / null snapshot ⇒
9678
+ * the condition never matches. Confirmed edge-state survives addon restarts
9679
+ * (declared SQLite collection, reseeded on boot).
9680
+ */
9681
+ var NcOccupancyConditionSchema = object({
9682
+ /** Admin zone id to scope the count to; absent = whole-frame occupancy. */
9683
+ zoneId: string().optional(),
9684
+ /** Object class to count; absent = any class. */
9685
+ className: string().optional(),
9686
+ op: _enum([
9687
+ "became-occupied",
9688
+ "became-free",
9689
+ ">=",
9690
+ "<="
9691
+ ]).default("became-occupied"),
9692
+ count: number().int().min(0).default(1),
9693
+ sustainSeconds: number().int().min(0).max(3600).default(15)
9694
+ });
9695
+ /**
9696
+ * Which zone-crossing DIRECTION a rule accepts (`ObjectEvent.zoneCrossing`).
9697
+ *
9698
+ * The values are not symmetric, and deliberately so — the absent value has to
9699
+ * mean exactly what every rule authored before this condition existed already
9700
+ * does:
9701
+ * - `enter` — entries and every NON-crossing record (movement state,
9702
+ * package, sensor). Exits are rejected. **This is the absent behaviour**:
9703
+ * an operator who never asked for exits must not start receiving them.
9704
+ * - `exit` — ONLY an exit crossing. A record that is not a crossing at all
9705
+ * fails closed, because "the car left the drive" is a question about a
9706
+ * boundary, not about a detection.
9707
+ * - `any` — no direction filter; entries, exits and non-crossings alike.
9708
+ *
9709
+ * A rule asking for a direction should normally also scope `zones`, which the
9710
+ * engine evaluates against the crossed zone as well as the current membership
9711
+ * (an exit's membership no longer contains the zone it just left).
9712
+ */
9713
+ var NcCrossingSchema = _enum([
9714
+ "enter",
9715
+ "exit",
9716
+ "any"
9717
+ ]);
9718
+ /** Admin-zone membership condition (zone IDs as stamped by the ZoneEngine). */
9719
+ var NcZoneConditionSchema = object({
9720
+ ids: array(string().min(1)).min(1),
9721
+ /** Quantifier over `ids` — at least one / every one visited. */
9722
+ match: _enum(["any", "all"]).default("any")
9723
+ });
9724
+ /**
9725
+ * The P1 condition set — a flat AND of groups; absent group = pass;
9726
+ * membership lists are OR within the list (spec §2.3).
9727
+ */
9728
+ /**
9729
+ * What a rule may actuate.
9730
+ *
9731
+ * **No hand-maintained allowlist** (operator decision, and the right one — a
9732
+ * written list of methods is a third parallel map to keep aligned, and this
9733
+ * repo has paid for those). The boundary instead comes from a property the
9734
+ * capabilities already carry: an action may target only a **device-scoped**
9735
+ * capability method.
9736
+ *
9737
+ * That is not decoration. A rule can be authored by a NON-ADMIN — personal
9738
+ * rules are a supported flow — and the executor runs with the addon's
9739
+ * privileges, so an unbounded action is an arbitrary RPC channel with a
9740
+ * privilege escalation attached. Restricting to device scope excludes the
9741
+ * system caps (`device-manager.removeDevice` and friends) by construction,
9742
+ * costs nothing to maintain, and cannot rot: a cap that stops being
9743
+ * device-scoped stops being actuatable in the same change.
9744
+ *
9745
+ * The executor enforces it; {@link NcRuleActionSchema} carries the intent.
9746
+ */
9747
+ /**
9748
+ * One step of a sequence.
9749
+ *
9750
+ * `wait` is a first-class step rather than a property of the next action: it is
9751
+ * what makes a sequence a SEQUENCE and not a list — "unlock, wait 5s, open"
9752
+ * cannot be expressed otherwise.
9753
+ */
9754
+ var NcRuleActionSchema = discriminatedUnion("kind", [object({
9755
+ kind: literal("wait"),
9756
+ seconds: number().min(0).max(300)
9757
+ }), object({
9758
+ kind: literal("cap"),
9759
+ deviceId: number().int(),
9760
+ /** Capability name, e.g. `alarm-panel`. */
9761
+ cap: string().min(1),
9762
+ /** Method on it. The executor refuses a non-device-scoped cap. */
9763
+ method: string().min(1),
9764
+ /** Method arguments, minus `deviceId` (the executor injects it). */
9765
+ args: record(string(), unknown()).optional()
9766
+ })]);
9767
+ /**
9768
+ * A named, ordered run of steps with its own throttle.
9769
+ *
9770
+ * `minDelaySec` exists because a noisy rule otherwise hammers a physical
9771
+ * actuator — the rule's own cooldown governs NOTIFICATIONS, which is a
9772
+ * different budget from "how often may this gate actually open".
9773
+ */
9774
+ var NcRuleActionSequenceSchema = object({
9775
+ name: string().min(1).max(120),
9776
+ enabled: boolean(),
9777
+ minDelaySec: number().int().min(0).max(86400).optional(),
9778
+ actions: array(NcRuleActionSchema).min(1)
9779
+ });
9780
+ /**
9781
+ * One button carried by the notification, running a named sequence on tap.
9782
+ *
9783
+ * **Read this before adding a button that does something physical.** The tap
9784
+ * arrives over a link that travelled through third-party infrastructure — ntfy,
9785
+ * a push relay, whatever forwarded the message — and the callback's ONLY
9786
+ * authority is the token in that link: single-use, short-lived, bound to this
9787
+ * one action of this one notification. It does not identify who tapped.
9788
+ * Whoever holds the notification can run the button, once, inside the window.
9789
+ * That is the operator's explicit choice (2026-08-05), and `destructive` is a
9790
+ * rendering hint, not a second gate. [D47](decisions/adr-0047.md).
9791
+ */
9792
+ var NcRuleNotificationButtonSchema = object({
9793
+ /** Stable id — travels in the callback and identifies the button in logs. */
9794
+ id: string().min(1).max(64),
9795
+ label: string().min(1).max(40),
9796
+ /** Name of a sequence in `onTrigger`. The dispatcher drops a button whose
9797
+ * sequence does not exist rather than minting a token for nothing. */
9798
+ sequence: string().min(1).max(120),
9799
+ icon: NotificationActionIconSchema.optional(),
9800
+ destructive: boolean().optional()
9801
+ });
9802
+ /**
9803
+ * Sequences a rule runs, by hook point.
9804
+ *
9805
+ * ONLY `onTrigger` is here, deliberately. The reference also has activation /
9806
+ * deactivation / reset / post-generation hooks, and they are wanted — but this
9807
+ * repo's expensive failure mode is declaring a surface nothing produces, so a
9808
+ * hook appears here in the same change that produces its edge, never before.
9809
+ */
9810
+ var NcRuleActionsSchema = object({
9811
+ /** Runs when the rule MATCHES. */
9812
+ onTrigger: array(NcRuleActionSequenceSchema).optional(),
9813
+ /**
9814
+ * Buttons the NOTIFICATION carries, each running one of this rule's
9815
+ * sequences when tapped.
9816
+ *
9817
+ * Deliberately a REFERENCE to a sequence rather than a second place to
9818
+ * author steps. A button that could define its own actions would be a
9819
+ * parallel actuation vocabulary — the executor's device-scope check, the
9820
+ * stop-at-first-failure rule and the per-sequence throttle all live on
9821
+ * sequences, and a second authoring surface would drift from every one of
9822
+ * them.
9823
+ *
9824
+ * A sequence reachable ONLY by a button simply appears in `onTrigger` with
9825
+ * `enabled: false`: it is then authored, throttled and validated like the
9826
+ * rest, and nothing runs it automatically.
9827
+ */
9828
+ buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
9829
+ });
9830
+ var NcConditionsSchema = object({
9831
+ /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
9832
+ deviceState: object({
9833
+ deviceId: number().int(),
9834
+ /** Any of these matches. */
9835
+ states: array(string().min(1)).min(1)
9836
+ }).optional(),
9837
+ /** Device scope — absent = all devices. */
9838
+ devices: array(number()).optional(),
9839
+ /** Detector class names (any overlap with the record's class set). */
9840
+ classes: array(string().min(1)).optional(),
9841
+ /** Veto classes — any overlap fails the rule. */
9842
+ classesExclude: array(string().min(1)).optional(),
9843
+ /** Minimum detection confidence 0–1 (fails when the record has none). */
9844
+ minConfidence: number().min(0).max(1).optional(),
9845
+ /** Admin zone membership over event `zones` / track `zonesVisited`. */
9846
+ zones: NcZoneConditionSchema.optional(),
9847
+ /** Veto zones — any hit fails the rule. */
9848
+ zonesExclude: array(string().min(1)).optional(),
9849
+ /**
9850
+ * Zone-crossing direction. IMMEDIATE only — a crossing is a per-event fact
9851
+ * and a closed track carries none, so a `track-end` rule asking for one
9852
+ * fails closed (use `zones`, which tests `zonesVisited`). ABSENT = `enter`,
9853
+ * which is exactly today's behaviour. See {@link NcCrossingSchema}.
9854
+ */
9855
+ crossing: NcCrossingSchema.optional(),
9856
+ /**
9857
+ * Exact (case-insensitive) match on the record's collapsed `label`
9858
+ * (identity name / plate text / subclass).
9859
+ */
9860
+ labelEquals: array(string().min(1)).optional(),
9453
9861
  /**
9454
9862
  * Identity matcher. P1 boundary: matched against the record's collapsed
9455
9863
  * `label` (the identity display name propagated by the face pipeline) —
@@ -9626,6 +10034,14 @@ var NcMediaPolicySchema = object({
9626
10034
  clipPreRollSec: number().int().min(0).max(30).optional(),
9627
10035
  clipPostRollSec: number().int().min(0).max(30).optional(),
9628
10036
  /**
10037
+ * Playback rate of the attached gif / clip. Absent = 2x.
10038
+ *
10039
+ * A notification clip is GLANCED at on a lock screen, not watched: at real
10040
+ * time an eight-second passage is eight seconds of the recipient's attention
10041
+ * and twice the bytes. 1 is real time for the operator who wants it.
10042
+ */
10043
+ clipSpeed: number().min(1).max(8).optional(),
10044
+ /**
9629
10045
  * Which stream profile the footage is cut from. Absent = the CHEAPEST
9630
10046
  * assigned profile: a notification is watched on a phone, so the 4K
9631
10047
  * rendition would burn CPU to produce a file the client downscales anyway.
@@ -9697,7 +10113,30 @@ var NcRuleInputSchema = object({
9697
10113
  * behaviour, visible to all, read-only in the viewer). Present = personal
9698
10114
  * rule owned by this userId. Server-stamped; never trusted from a client.
9699
10115
  */
9700
- ownerUserId: string().optional()
10116
+ ownerUserId: string().optional(),
10117
+ /**
10118
+ * May a non-admin snooze this rule for EVERYONE, not just themselves?
10119
+ *
10120
+ * A snooze is personal by default — it silences the person who set it. This
10121
+ * opts THIS rule into the "the gardener is here all afternoon" case, where
10122
+ * silencing the camera for the whole household is legitimate. It silences
10123
+ * other people, so it is off unless a rule deliberately allows it.
10124
+ *
10125
+ * `.optional()`, deliberately NOT `.default()`: a Zod default does not run on
10126
+ * the addon cap path (three production failures in one day), so absent is
10127
+ * read as `false` by {@link canSetGlobal} in the engine. Admins are not bound
10128
+ * by this flag — see the scope rules on that function.
10129
+ */
10130
+ snoozeAllowGlobal: boolean().optional(),
10131
+ /**
10132
+ * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
10133
+ *
10134
+ * This is what makes the rule set the alarm's trigger set without the alarm
10135
+ * being a special case: arming is
10136
+ * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
10137
+ * shape as every other actuation.
10138
+ */
10139
+ actions: NcRuleActionsSchema.optional()
9701
10140
  });
9702
10141
  /**
9703
10142
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -9768,7 +10207,8 @@ var NcConditionDescriptorSchema = object({
9768
10207
  "packagePhase",
9769
10208
  "crossingSelect",
9770
10209
  "polygonDraw",
9771
- "occupancy"
10210
+ "occupancy",
10211
+ "deviceState"
9772
10212
  ]),
9773
10213
  operator: _enum([
9774
10214
  "in",
@@ -9782,7 +10222,28 @@ var NcConditionDescriptorSchema = object({
9782
10222
  /** Which delivery kinds the condition applies to. */
9783
10223
  appliesTo: array(NcDeliverySchema),
9784
10224
  phase: string(),
9785
- description: string().optional()
10225
+ description: string().optional(),
10226
+ /**
10227
+ * The CHOICES for a single-choice widget (`sourceSelect`, `crossingSelect`,
10228
+ * `packagePhase`, …), served with the descriptor.
10229
+ *
10230
+ * Before this the descriptor said which widget to render and not what to put
10231
+ * in it, so every option list lived in three places: this file's enums, the
10232
+ * admin's `NC_*_OPTIONS` and the viewer's `NC_*_VALUES`. That triple mirror
10233
+ * is the drift that emptied the viewer's rule editor on 2026-08-04 — the app
10234
+ * mirrors the cap by hand, so it is only ever as current as its last build.
10235
+ *
10236
+ * With the options on the wire, a condition of an EXISTING `valueType` costs
10237
+ * zero client changes. Clients keep a local fallback for an older hub that
10238
+ * does not send them; absent here is "use your own list", not "no choices".
10239
+ */
10240
+ options: array(object({
10241
+ /** Written to the rule verbatim. `''` means the ABSENT state. */
10242
+ value: string(),
10243
+ label: string(),
10244
+ /** What THIS choice matches — shown one at a time, under the control. */
10245
+ hint: string().optional()
10246
+ })).readonly().optional()
9786
10247
  });
9787
10248
  /**
9788
10249
  * The delivery lifecycle status of a history row — a straight read of the
@@ -9867,6 +10328,74 @@ var NcHistoryFilterSchema = object({
9867
10328
  until: number().optional(),
9868
10329
  limit: number().int().min(1).max(500).default(100)
9869
10330
  });
10331
+ /**
10332
+ * What a snooze covers. Broader scopes win when several overlap, so one window
10333
+ * leaves ONE digest rather than a rule snooze and a whole-feed snooze both
10334
+ * summarising the same silence.
10335
+ */
10336
+ var NcSnoozeScopeSchema = _enum([
10337
+ "rule",
10338
+ "device",
10339
+ "all"
10340
+ ]);
10341
+ /**
10342
+ * Client-authored snooze. The server stamps `userId`, `startedAt` and
10343
+ * `expiresAt` — a DURATION is sent rather than an instant so a client with a
10344
+ * skewed clock cannot author a window that is already over, or never ends.
10345
+ */
10346
+ var NcSnoozeInputSchema = object({
10347
+ scope: NcSnoozeScopeSchema,
10348
+ /** Required when `scope: 'rule'` — a scoped snooze with no id matches
10349
+ * NOTHING rather than degrading to "everything". */
10350
+ ruleId: string().optional(),
10351
+ /** Required when `scope: 'device'`. */
10352
+ deviceId: number().int().optional(),
10353
+ durationMinutes: number().int().min(1).max(1440),
10354
+ /**
10355
+ * Silence this for EVERY recipient, not just the caller. Permission is
10356
+ * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
10357
+ * broader scopes). Absent = personal.
10358
+ */
10359
+ global: boolean().optional(),
10360
+ /**
10361
+ * Deliver a summary of what was suppressed when the window ends. Absent =
10362
+ * ON: someone silencing a nuisance camera wants it off, someone silencing a
10363
+ * SECURITY camera wants to know what they missed, and choosing "off" for
10364
+ * everybody is how a snooze becomes an outage. Resolved to a concrete
10365
+ * boolean by the server at create time — never left to a Zod default, which
10366
+ * does not run on the addon cap path.
10367
+ */
10368
+ summary: boolean().optional()
10369
+ });
10370
+ /** A persisted snooze window. */
10371
+ var NcSnoozeSchema = object({
10372
+ id: string(),
10373
+ /** Who set it. Also who it silences, unless `global`. */
10374
+ userId: string(),
10375
+ scope: NcSnoozeScopeSchema,
10376
+ ruleId: string().optional(),
10377
+ deviceId: number().int().optional(),
10378
+ startedAt: number(),
10379
+ /** Exclusive: at exactly this instant the snooze is over. Expiry is a
10380
+ * COMPARISON, not a job — no sweeper can leave the operator silenced. */
10381
+ expiresAt: number(),
10382
+ global: boolean(),
10383
+ summary: boolean(),
10384
+ /** When the end-of-window digest went out. Absent = not sent (yet, or the
10385
+ * window has not closed, or `summary` is false). */
10386
+ digestSentAt: number().optional()
10387
+ });
10388
+ object({
10389
+ snoozeId: string(),
10390
+ targetId: string(),
10391
+ ruleId: string(),
10392
+ ruleName: string(),
10393
+ deviceId: number().int(),
10394
+ /** How many notifications this snooze hid for that pair. */
10395
+ count: number().int(),
10396
+ firstAt: number(),
10397
+ lastAt: number()
10398
+ });
9870
10399
  method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), method(object({ ruleId: string() }), object({ rule: NcRuleSchema.nullable() }), { auth: "admin" }), method(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
9871
10400
  kind: "mutation",
9872
10401
  auth: "admin",
@@ -9896,7 +10425,13 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
9896
10425
  }), method(object({}), object({
9897
10426
  catalog: array(NcConditionDescriptorSchema),
9898
10427
  taxonomy: NcTaxonomySchema.optional()
9899
- })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
10428
+ })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" }), method(object({}), object({ snoozes: array(NcSnoozeSchema) }), { caller: "required" }), method(object({ snooze: NcSnoozeInputSchema }), object({ snooze: NcSnoozeSchema }), {
10429
+ kind: "mutation",
10430
+ caller: "required"
10431
+ }), method(object({ snoozeId: string() }), object({ success: literal(true) }), {
10432
+ kind: "mutation",
10433
+ caller: "required"
10434
+ });
9900
10435
  /**
9901
10436
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
9902
10437
  *
@@ -10644,7 +11179,15 @@ method(object({
10644
11179
  format: _enum(["gif", "mp4"]).default("gif"),
10645
11180
  maxWidth: number().int().min(120).max(1920).default(480),
10646
11181
  /** GIF only — MP4 keeps the source cadence. */
10647
- fps: number().int().min(1).max(15).default(5)
11182
+ fps: number().int().min(1).max(15).default(5),
11183
+ /**
11184
+ * Playback rate. A notification clip is GLANCED at on a lock screen,
11185
+ * not watched, so 2x is the default: the recipient sees the whole
11186
+ * passage in half the time and the GIF is half the bytes. `1` is real
11187
+ * time. Applies to MP4 as well — the operator set a speed, not a GIF
11188
+ * speed.
11189
+ */
11190
+ speed: number().min(1).max(8).default(2)
10648
11191
  }), object({
10649
11192
  base64: string(),
10650
11193
  mime: string(),
@@ -15430,6 +15973,210 @@ method(object({ codec: string() }), boolean()), method(_void(), object({
15430
15973
  kind: "mutation",
15431
15974
  auth: "admin"
15432
15975
  });
15976
+ /**
15977
+ * Query filter for settings-store collections.
15978
+ */
15979
+ var QueryFilterSchema = object({
15980
+ where: record(string(), unknown()).optional(),
15981
+ whereIn: record(string(), array(unknown())).optional(),
15982
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
15983
+ orderBy: object({
15984
+ field: string(),
15985
+ direction: _enum(["asc", "desc"])
15986
+ }).optional(),
15987
+ limit: number().optional(),
15988
+ offset: number().optional()
15989
+ });
15990
+ /**
15991
+ * The predicate half of a filter, for BULK MUTATIONS.
15992
+ *
15993
+ * Deliberately not `QueryFilterSchema`: `orderBy` / `limit` / `offset` have no
15994
+ * meaning for a statement that rewrites a set, and accepting them would invite
15995
+ * a caller to believe `limit` bounds the damage. Every field is optional here
15996
+ * only so the shape stays composable — the implementation REJECTS a filter
15997
+ * that compiles to no predicate, because that is the whole collection.
15998
+ */
15999
+ var MutationFilterSchema = object({
16000
+ where: record(string(), unknown()).optional(),
16001
+ whereIn: record(string(), array(unknown())).optional(),
16002
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional()
16003
+ });
16004
+ /** A single stored record: `{ id, data }`. */
16005
+ var SettingsRecordSchema = object({
16006
+ id: string(),
16007
+ data: record(string(), unknown())
16008
+ });
16009
+ /**
16010
+ * Column declaration for a structured (SQL-backed) collection.
16011
+ *
16012
+ * Logical types — the backend translates each to the matching SQLite
16013
+ * storage class and handles per-type marshaling:
16014
+ * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
16015
+ * - `JSON` — TEXT under the hood; serialised on write, parsed on read
16016
+ * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
16017
+ */
16018
+ var CollectionColumnSchema = object({
16019
+ name: string(),
16020
+ type: _enum([
16021
+ "TEXT",
16022
+ "INTEGER",
16023
+ "REAL",
16024
+ "JSON",
16025
+ "BOOLEAN"
16026
+ ]),
16027
+ primaryKey: boolean().optional(),
16028
+ notNull: boolean().optional(),
16029
+ unique: boolean().optional(),
16030
+ /**
16031
+ * Column DEFAULT. Required for B2: the four `ensureTable` consumers declare
16032
+ * columns like `enabled INTEGER NOT NULL DEFAULT 1`, and without this the
16033
+ * collection surface simply cannot express their existing tables — which is
16034
+ * why they were still on the uncapped `table*` API.
16035
+ */
16036
+ defaultValue: union([
16037
+ string(),
16038
+ number(),
16039
+ boolean()
16040
+ ]).optional()
16041
+ });
16042
+ var CollectionIndexSchema = object({
16043
+ name: string(),
16044
+ columns: array(string()).readonly(),
16045
+ unique: boolean().optional()
16046
+ });
16047
+ method(object({
16048
+ namespace: string().optional(),
16049
+ collection: string(),
16050
+ key: string()
16051
+ }), unknown()), method(object({
16052
+ namespace: string().optional(),
16053
+ collection: string(),
16054
+ key: string(),
16055
+ value: unknown()
16056
+ }), _void(), { kind: "mutation" }), method(object({
16057
+ namespace: string().optional(),
16058
+ collection: string(),
16059
+ filter: QueryFilterSchema.optional()
16060
+ }), array(SettingsRecordSchema).readonly()), method(object({
16061
+ namespace: string().optional(),
16062
+ collection: string(),
16063
+ record: SettingsRecordSchema
16064
+ }), _void(), { kind: "mutation" }), method(object({
16065
+ namespace: string().optional(),
16066
+ collection: string(),
16067
+ id: string(),
16068
+ data: record(string(), unknown())
16069
+ }), _void(), { kind: "mutation" }), method(object({
16070
+ namespace: string().optional(),
16071
+ collection: string(),
16072
+ key: string()
16073
+ }), _void(), { kind: "mutation" }), method(object({
16074
+ namespace: string().optional(),
16075
+ collection: string(),
16076
+ filter: MutationFilterSchema
16077
+ }), object({ deleted: number().int() }), { kind: "mutation" }), method(object({
16078
+ namespace: string().optional(),
16079
+ collection: string(),
16080
+ filter: MutationFilterSchema,
16081
+ data: record(string(), unknown())
16082
+ }), object({ updated: number().int() }), { kind: "mutation" }), method(object({
16083
+ namespace: string().optional(),
16084
+ collection: string(),
16085
+ filter: QueryFilterSchema.optional()
16086
+ }), number()), method(object({
16087
+ namespace: string().optional(),
16088
+ collection: string(),
16089
+ field: string(),
16090
+ bucketSize: number().int().positive(),
16091
+ origin: number().int(),
16092
+ filter: QueryFilterSchema.optional()
16093
+ }), array(object({
16094
+ bucket: number().int(),
16095
+ count: number().int()
16096
+ })).readonly()), method(object({
16097
+ namespace: string().optional(),
16098
+ collection: string()
16099
+ }), boolean()), method(object({
16100
+ namespace: string().optional(),
16101
+ collection: string(),
16102
+ columns: array(CollectionColumnSchema).readonly(),
16103
+ indexes: array(CollectionIndexSchema).readonly().optional()
16104
+ }), _void(), { kind: "mutation" });
16105
+ /**
16106
+ * What one engine says about itself. The orchestrator uses `kind` to pick
16107
+ * a registrant for a collection; `engineId` is what a log line names when
16108
+ * a call is routed or refused.
16109
+ */
16110
+ var EngineInfoSchema = object({
16111
+ engineId: string(),
16112
+ /**
16113
+ * `relational` — rows, columns, indexes, the surface `settings-store`
16114
+ * has always described. `vector` — an embedding store answering
16115
+ * similarity queries. A registrant declares exactly one; an engine that
16116
+ * does both registers twice, because "both" would make the routing
16117
+ * decision ambiguous at exactly the point it must not be.
16118
+ */
16119
+ kind: _enum(["relational", "vector"]),
16120
+ displayName: string()
16121
+ });
16122
+ method(_void(), EngineInfoSchema), method(object({
16123
+ namespace: string().optional(),
16124
+ collection: string(),
16125
+ key: string()
16126
+ }), unknown()), method(object({
16127
+ namespace: string().optional(),
16128
+ collection: string(),
16129
+ key: string(),
16130
+ value: unknown()
16131
+ }), _void(), { kind: "mutation" }), method(object({
16132
+ namespace: string().optional(),
16133
+ collection: string(),
16134
+ filter: QueryFilterSchema.optional()
16135
+ }), array(SettingsRecordSchema).readonly()), method(object({
16136
+ namespace: string().optional(),
16137
+ collection: string(),
16138
+ record: SettingsRecordSchema
16139
+ }), _void(), { kind: "mutation" }), method(object({
16140
+ namespace: string().optional(),
16141
+ collection: string(),
16142
+ id: string(),
16143
+ data: record(string(), unknown())
16144
+ }), _void(), { kind: "mutation" }), method(object({
16145
+ namespace: string().optional(),
16146
+ collection: string(),
16147
+ key: string()
16148
+ }), _void(), { kind: "mutation" }), method(object({
16149
+ namespace: string().optional(),
16150
+ collection: string(),
16151
+ filter: MutationFilterSchema
16152
+ }), object({ deleted: number().int() }), { kind: "mutation" }), method(object({
16153
+ namespace: string().optional(),
16154
+ collection: string(),
16155
+ filter: MutationFilterSchema,
16156
+ data: record(string(), unknown())
16157
+ }), object({ updated: number().int() }), { kind: "mutation" }), method(object({
16158
+ namespace: string().optional(),
16159
+ collection: string(),
16160
+ filter: QueryFilterSchema.optional()
16161
+ }), number()), method(object({
16162
+ namespace: string().optional(),
16163
+ collection: string(),
16164
+ field: string(),
16165
+ bucketSize: number().int().positive(),
16166
+ origin: number().int(),
16167
+ filter: QueryFilterSchema.optional()
16168
+ }), array(object({
16169
+ bucket: number().int(),
16170
+ count: number().int()
16171
+ })).readonly()), method(object({
16172
+ namespace: string().optional(),
16173
+ collection: string()
16174
+ }), boolean()), method(object({
16175
+ namespace: string().optional(),
16176
+ collection: string(),
16177
+ columns: array(CollectionColumnSchema).readonly(),
16178
+ indexes: array(CollectionIndexSchema).readonly().optional()
16179
+ }), _void(), { kind: "mutation" });
15433
16180
  DeviceType.Camera;
15434
16181
  /**
15435
16182
  * `device-adoption` — generic discovery + adoption surface,
@@ -16108,7 +16855,20 @@ method(object({
16108
16855
  }), _void(), {
16109
16856
  kind: "mutation",
16110
16857
  auth: "admin"
16111
- }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({ addonId: string().optional() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
16858
+ }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({
16859
+ addonId: string().optional(),
16860
+ /**
16861
+ * `slim` omits `config` (returned `{}`), `metadata` (null) and the
16862
+ * `sourceInfo` derived from config — and skips the per-device settings
16863
+ * read that produces them. Everything identifying a device (id, name,
16864
+ * type, online, features, isCamera, parent/link ids) is unchanged.
16865
+ * Do not use it for dispatch routing, which needs `sourceInfo`.
16866
+ */
16867
+ projection: _enum(["full", "slim"]).optional(),
16868
+ /** Return only camera devices. Filtering server-side instead of
16869
+ * shipping 293 rows to find 12. */
16870
+ isCamera: boolean().optional()
16871
+ }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
16112
16872
  mode: DeviceLinkModeSchema,
16113
16873
  devices: array(LinkedDeviceSchema)
16114
16874
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
@@ -16547,14 +17307,14 @@ var LlmProfileSchema = object({
16547
17307
  /** ConfigUISchema tree passed through untyped on the wire (the
16548
17308
  * notification-output `ConfigSchemaPassthrough` precedent at
16549
17309
  * notification-output.cap.ts:151); the exported TS type re-tightens it. */
16550
- var ConfigSchemaPassthrough$1 = unknown();
17310
+ var ConfigSchemaPassthrough = unknown();
16551
17311
  var LlmProfileKindDescriptorSchema = object({
16552
17312
  kind: LlmProfileKindSchema,
16553
17313
  label: string(),
16554
17314
  icon: string(),
16555
17315
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
16556
17316
  addonId: string(),
16557
- configSchema: ConfigSchemaPassthrough$1
17317
+ configSchema: ConfigSchemaPassthrough
16558
17318
  });
16559
17319
  var LlmDefaultSelectorSchema = union([object({ consumer: string() }), object({ purpose: _enum(["text", "vision"]) })]);
16560
17320
  var LlmDefaultSchema = object({
@@ -17085,241 +17845,102 @@ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
17085
17845
  });
17086
17846
  method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
17087
17847
  /**
17088
- * notification-outputcanonical, capability-gated notification delivery.
17089
- *
17090
- * Apprise-derived model (see
17091
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
17092
- * callers emit ONE canonical `Notification`; each provider declares a
17093
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
17094
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
17095
- * message to what the kind supports callers never special-case a service.
17096
- *
17097
- * DESIGN DECISIONS (locked):
17098
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
17099
- * `setTargetEnabled`), each provider persisting via the `settings-store`
17100
- * cap. Rationale: the admin UI needs one uniform surface across the
17101
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
17102
- * alternative would fork the UI per addon and cannot host the
17103
- * discovery→adopt flow.
17104
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
17105
- * the generated cap-mount auto-`concatCollection`-fans them across every
17106
- * registered provider (notifiers addon + HA addon) so one catalog is
17107
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
17108
- * `addonId` the generated collection router extracts from the call input.
17109
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
17110
- * transformer) + UDS MsgPack both round-trip typed arrays already used by
17111
- * `storage` / `storage-provider` / `recording` caps over the same path. No
17112
- * base64 fallback needed.
17113
- *
17114
- * TODO (deferred, closed-set change — separate decision): add
17115
- * `providerKind: 'notify'` so notification providers surface on the unified
17116
- * admin "Integrations" page.
17117
- */
17118
- /**
17119
- * Zentik-derived typed-media enum — the superset across every kind. Each
17120
- * adapter picks what it supports and the degrade engine filters the rest.
17121
- */
17122
- var AttachmentMediaTypeSchema = _enum([
17123
- "image",
17124
- "video",
17125
- "gif",
17126
- "audio",
17127
- "icon"
17128
- ]);
17129
- /**
17130
- * A single attachment. Exactly one of `url` (remote source, most adapters
17131
- * prefer this) or `bytes` (inline source; required for Pushover-style
17132
- * bytes-only kinds) MUST be present — the degrade engine expresses a
17133
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
17134
- */
17135
- var AttachmentSchema = object({
17136
- mediaType: AttachmentMediaTypeSchema,
17137
- url: string().optional(),
17138
- bytes: _instanceof(Uint8Array).optional(),
17139
- mime: string().optional(),
17140
- name: string().optional()
17141
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
17142
- var NotificationFormatSchema = _enum([
17143
- "text",
17144
- "markdown",
17145
- "html"
17848
+ * core-blocksuser-authored TypeScript, stored in the kernel and executed in
17849
+ * its own process.
17850
+ *
17851
+ * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
17852
+ *
17853
+ * The first use is **owning devices without being a device provider**: a block
17854
+ * declares devices under a system or custom integration and drives their state,
17855
+ * with the same `ctx` an addon gets. Automations come later; nothing here
17856
+ * models a trigger.
17857
+ *
17858
+ * **Stated plainly, because it does not change by being true:** a block has an
17859
+ * addon's powers devices, storage, the event bus, `ctx.api`. It is a plugin
17860
+ * with no review step. What makes that survivable is not a sandbox, it is
17861
+ * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
17862
+ * so a block that throws or never returns is marked `failed` and visible
17863
+ * instead of taking the hub with it (D6). Every method here is admin-only, and
17864
+ * must stay so.
17865
+ */
17866
+ /** Where a block runs. The operator chooses a block driving a device on an
17867
+ * agent is the reason placement is not fixed to the hub. */
17868
+ var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
17869
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
17870
+ * a failing block reads the same way a failing addon does. */
17871
+ var CoreBlockStatusSchema = _enum([
17872
+ "stopped",
17873
+ "starting",
17874
+ "running",
17875
+ "failed"
17146
17876
  ]);
17147
- /** A single tap-through action button. */
17148
- var NotificationActionSchema = object({
17149
- id: string(),
17150
- label: string(),
17151
- url: string().optional()
17152
- });
17153
- /**
17154
- * The canonical notification. `body` is the only hard field (Apprise model).
17155
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
17156
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
17157
- * the adapter maps this ordinal onto its native level. `level?` is an
17158
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
17159
- * `priority` for that one target.
17160
- */
17161
- var NotificationSchema = object({
17162
- body: string(),
17163
- title: string().optional(),
17164
- format: NotificationFormatSchema.default("text"),
17165
- priority: number().int().min(1).max(5).default(3),
17166
- level: string().optional(),
17167
- attachments: array(AttachmentSchema).optional(),
17168
- clickUrl: string().optional(),
17169
- actions: array(NotificationActionSchema).optional(),
17170
- sound: string().optional(),
17171
- ttl: number().optional(),
17172
- tag: string().optional(),
17173
- deviceId: number().optional(),
17174
- eventId: string().optional(),
17175
- metadata: record(string(), unknown()).optional()
17176
- });
17177
- /** One declared native severity/priority level for a kind. */
17178
- var TargetKindLevelSchema = object({
17179
- id: string(),
17180
- label: string(),
17181
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
17182
- ordinal: number().int().min(1).max(5).nullable(),
17183
- flags: object({
17184
- critical: boolean().optional(),
17185
- silent: boolean().optional(),
17186
- noPush: boolean().optional()
17187
- }).optional(),
17188
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
17189
- requires: array(string()).optional(),
17190
- description: string().optional()
17191
- });
17192
- /** The full capability block consulted before dispatch. */
17193
- var TargetKindCapsSchema = object({
17194
- attachments: object({
17195
- mediaTypes: array(AttachmentMediaTypeSchema),
17196
- mode: _enum([
17197
- "url",
17198
- "bytes",
17199
- "both"
17200
- ]),
17201
- max: number().int().nonnegative(),
17202
- maxBytes: number().int().positive().optional()
17203
- }),
17204
- /** Max action buttons (0 = none). */
17205
- actions: number().int().nonnegative(),
17206
- levels: array(TargetKindLevelSchema),
17207
- format: array(NotificationFormatSchema),
17208
- clickUrl: boolean(),
17209
- sound: boolean(),
17210
- ttl: boolean(),
17211
- bodyMaxLen: number().int().positive()
17212
- });
17213
- /**
17214
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
17215
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
17216
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
17217
- * the union is large and not meant for runtime validation here; the exported
17218
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
17219
- */
17220
- var ConfigSchemaPassthrough = unknown();
17221
- var TargetKindSchema = object({
17222
- kind: string(),
17223
- label: string(),
17224
- icon: string(),
17225
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
17226
- addonId: string(),
17227
- /**
17228
- * URL of the kind's bundled BRAND icon, served by the providing addon over
17229
- * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
17230
- * when the addon bundles no icon for that kind — the client then falls back
17231
- * to a neutral glyph rather than rendering the raw `icon` NAME as text.
17232
- *
17233
- * Root-relative on purpose: it resolves against whatever origin serves a web
17234
- * client, and a native client joins it onto its own hub base.
17235
- *
17236
- * DECLARED here deliberately. It used to travel as an undeclared passthrough
17237
- * field that survived only because the runtime cap-router forwards provider
17238
- * output verbatim — so every consumer had to re-declare it by hand to stop
17239
- * its own Zod parse from stripping it, and the whole arrangement would have
17240
- * broken silently the moment output validation was tightened anywhere.
17241
- */
17242
- iconUrl: string().optional(),
17877
+ /** Client-authored fields. */
17878
+ var CoreBlockInputSchema = object({
17879
+ name: string().min(1).max(120),
17880
+ /** TypeScript source. Compiled server-side before it is ever stored — a
17881
+ * block that does not compile is a fork failure the operator would meet
17882
+ * minutes later, in a log, instead of in the editor. */
17883
+ code: string().max(2e5),
17884
+ enabled: boolean(),
17885
+ placement: CoreBlockPlacementSchema,
17243
17886
  /**
17244
- * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
17245
- *
17246
- * The server knows this and therefore says it, because the client cannot
17247
- * safely guess: a React-Native client renders SVG and raster through two
17248
- * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
17249
- * not decode SVG on iOS/Android), so without this it silently fell back to a
17250
- * placeholder glyph for every vector icon while the web build looked fine.
17251
- *
17252
- * Absent when {@link iconUrl} is absent, or for a legacy provider that has
17253
- * not been updated — a client that cannot determine the type should prefer
17254
- * its raster path, which is the safe default for an unknown image.
17887
+ * Integration the block's devices hang from. Absent = the system integration
17888
+ * blocks share. A block may declare its own instead.
17255
17889
  */
17256
- iconMediaType: string().optional(),
17257
- configSchema: ConfigSchemaPassthrough,
17258
- supportsDiscovery: boolean(),
17259
- caps: TargetKindCapsSchema
17890
+ integrationId: string().optional()
17260
17891
  });
17261
- /**
17262
- * A persisted target. `config` holds secrets; providers REDACT secret fields
17263
- * (return a presence marker only) when serving `listTargets` — never
17264
- * round-trip a stored secret to the UI.
17265
- */
17266
- var TargetSchema = object({
17892
+ /** A stored block. */
17893
+ var CoreBlockSchema = CoreBlockInputSchema.extend({
17267
17894
  id: string(),
17268
- name: string(),
17269
- kind: string(),
17270
- addonId: string(),
17271
- enabled: boolean(),
17272
- config: record(string(), unknown())
17273
- });
17274
- /** A discovery-surfaced candidate (config is partial + non-secret). */
17275
- var DiscoveredTargetSchema = object({
17276
- kind: string(),
17277
- suggestedName: string(),
17278
- config: record(string(), unknown())
17279
- });
17280
- /** The degrade engine's report — what was resolved / dropped / degraded. */
17281
- var RenderedAsSchema = object({
17282
- level: string(),
17283
- format: NotificationFormatSchema,
17284
- attachmentsSent: number().int().nonnegative(),
17285
- actionsSent: number().int().nonnegative(),
17286
- truncated: boolean(),
17287
- dropped: array(string())
17288
- });
17289
- var SendResultSchema = object({
17290
- success: boolean(),
17291
- error: string().optional(),
17292
- renderedAs: RenderedAsSchema.optional()
17293
- });
17294
- /** Same shape as SendResult — kept as a distinct name for the test panel. */
17295
- var TestResultSchema = SendResultSchema;
17296
- var notificationOutputCapability = {
17297
- name: "notification-output",
17298
- scope: "system",
17299
- mode: "collection",
17300
- methods: {
17301
- listTargetKinds: method(object({}), array(TargetKindSchema)),
17302
- listTargets: method(object({}), array(TargetSchema)),
17303
- discoverTargets: method(object({
17304
- kind: string(),
17305
- config: record(string(), unknown()).optional()
17306
- }), array(DiscoveredTargetSchema)),
17307
- send: method(object({
17308
- targetId: string(),
17309
- notification: NotificationSchema
17310
- }), SendResultSchema, { kind: "mutation" }),
17311
- testTarget: method(object({
17312
- targetId: string(),
17313
- sample: NotificationSchema.optional()
17314
- }), TestResultSchema, { kind: "mutation" }),
17315
- upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
17316
- deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
17317
- setTargetEnabled: method(object({
17318
- targetId: string(),
17319
- enabled: boolean()
17320
- }), _void(), { kind: "mutation" })
17321
- }
17322
- };
17895
+ createdAt: number(),
17896
+ updatedAt: number(),
17897
+ /** Server-stamped author. */
17898
+ createdBy: string(),
17899
+ status: CoreBlockStatusSchema,
17900
+ /**
17901
+ * Why the block is not running, when it is not. The operator's ONLY window
17902
+ * into a block that failed at load — a block that is silently absent is the
17903
+ * failure mode this whole feature has to avoid.
17904
+ */
17905
+ lastError: string().optional(),
17906
+ /** Ms epoch of the last state change. */
17907
+ lastChangedAt: number()
17908
+ });
17909
+ /** What a compile attempt produced. */
17910
+ var CoreBlockCompileResultSchema = object({
17911
+ ok: boolean(),
17912
+ /** Present when `ok` is false — the first error, in the author's words. */
17913
+ error: string().optional(),
17914
+ line: number().optional(),
17915
+ column: number().optional()
17916
+ });
17917
+ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }), method(object({ blockId: string() }), object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }), method(object({ block: CoreBlockInputSchema }), object({ block: CoreBlockSchema }), {
17918
+ kind: "mutation",
17919
+ auth: "admin",
17920
+ caller: "required"
17921
+ }), method(object({
17922
+ blockId: string(),
17923
+ block: CoreBlockInputSchema.partial()
17924
+ }), object({ block: CoreBlockSchema }), {
17925
+ kind: "mutation",
17926
+ auth: "admin",
17927
+ caller: "required"
17928
+ }), method(object({ blockId: string() }), object({ success: literal(true) }), {
17929
+ kind: "mutation",
17930
+ auth: "admin"
17931
+ }), method(object({
17932
+ blockId: string(),
17933
+ enabled: boolean()
17934
+ }), object({ block: CoreBlockSchema }), {
17935
+ kind: "mutation",
17936
+ auth: "admin"
17937
+ }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
17938
+ kind: "mutation",
17939
+ auth: "admin"
17940
+ }), method(object({}), object({ libs: array(object({
17941
+ filePath: string(),
17942
+ content: string()
17943
+ })) }), { auth: "admin" });
17323
17944
  /**
17324
17945
  * Zod schemas for persisted record types.
17325
17946
  *
@@ -17567,6 +18188,11 @@ var EventKindDescriptorSchema = object({
17567
18188
  deviceId: number()
17568
18189
  })
17569
18190
  });
18191
+ /** One camera's event vocabulary, as returned by `listEventKindsBatch`. */
18192
+ var EventKindsForDeviceSchema = object({
18193
+ deviceId: number(),
18194
+ kinds: array(EventKindDescriptorSchema).readonly()
18195
+ });
17570
18196
  var SensorEventSchema = object({
17571
18197
  id: string(),
17572
18198
  /** The CAMERA the event is attributed to (a sensor linked to N cameras
@@ -17823,6 +18449,19 @@ var MediaFileSchema = object({
17823
18449
  sizeBytes: number(),
17824
18450
  timestamp: number()
17825
18451
  });
18452
+ /**
18453
+ * One media row WITHOUT its bytes.
18454
+ *
18455
+ * A track's media is 5-8 MB of base64 (measured: 7.67 MB across 23 files for a
18456
+ * 140 s track), and a client that renders tiles from the media data plane needs
18457
+ * to know only WHAT EXISTS — the bytes then arrive per tile, lazily, over HTTP
18458
+ * with an immutable cache, instead of all at once inside a tRPC response that
18459
+ * blocks the whole view.
18460
+ *
18461
+ * `sizeBytes` is carried because it is what lets a client decide between the
18462
+ * stored blob and a `?variant=thumb` rendering without fetching either.
18463
+ */
18464
+ var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
17826
18465
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
17827
18466
  var MAX_EVENT_QUERY_LIMIT = 5e3;
17828
18467
  var DeviceEventQueryInput = object({
@@ -17972,7 +18611,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17972
18611
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
17973
18612
  kind: "mutation",
17974
18613
  auth: "admin"
17975
- }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({
18614
+ }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({ deviceIds: array(number()).min(1).max(200) }), array(EventKindsForDeviceSchema).readonly()), method(object({
17976
18615
  deviceId: number(),
17977
18616
  since: number().optional(),
17978
18617
  until: number().optional(),
@@ -18046,7 +18685,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
18046
18685
  }), array(MediaFileSchema).readonly()), method(object({
18047
18686
  trackId: string(),
18048
18687
  kinds: array(MediaFileKindEnum).optional()
18049
- }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
18688
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
18050
18689
  deviceId: number(),
18051
18690
  timestamp: number(),
18052
18691
  frameWidth: number(),
@@ -18676,6 +19315,28 @@ var ServerUpdateStateSchema = _enum([
18676
19315
  "pending-restart",
18677
19316
  "awaiting-confirmation"
18678
19317
  ]);
19318
+ var ImageContractSchema = object({
19319
+ state: _enum([
19320
+ "in-sync",
19321
+ "behind-patch",
19322
+ "behind-series",
19323
+ "ahead",
19324
+ "unknown"
19325
+ ]),
19326
+ /** The baked seed closure version — the image/app-bundle fingerprint. */
19327
+ seedVersion: string().nullable(),
19328
+ /** Best-known version the deployment contract delivers today. */
19329
+ contractVersion: string().nullable(),
19330
+ /**
19331
+ * Where `contractVersion` came from: a real registry check (`registry`), or
19332
+ * the node's own running version (`running` — a node can never run code
19333
+ * newer than the newest release, so `seed < running` proves image staleness
19334
+ * even before any registry check has run).
19335
+ */
19336
+ contractSource: _enum(["registry", "running"]).nullable(),
19337
+ /** One operator-grade sentence: this node runs image X; the contract says Y. */
19338
+ message: string()
19339
+ });
18679
19340
  var ServerRollbackInfoSchema = object({
18680
19341
  /** The version that failed (or was manually rolled back). */
18681
19342
  fromVersion: string(),
@@ -18712,7 +19373,12 @@ var ServerPackageStatusSchema = object({
18712
19373
  * versions are being IGNORED. Surfaced as a warning in the UI.
18713
19374
  */
18714
19375
  stateFileCorrupt: boolean(),
18715
- lastCheckedAtMs: number().nullable()
19376
+ lastCheckedAtMs: number().nullable(),
19377
+ /**
19378
+ * Seed-vs-contract verdict (see {@link ImageContractSchema}). Optional for
19379
+ * version skew: an older provider's payload simply omits it.
19380
+ */
19381
+ imageContract: ImageContractSchema.optional()
18716
19382
  });
18717
19383
  var ServerUpdateCheckResultSchema = object({
18718
19384
  packageName: string(),
@@ -18747,101 +19413,6 @@ version: string().optional() }), ServerUpdateActionResultSchema, {
18747
19413
  auth: "admin"
18748
19414
  });
18749
19415
  /**
18750
- * Query filter for settings-store collections.
18751
- */
18752
- var QueryFilterSchema = object({
18753
- where: record(string(), unknown()).optional(),
18754
- whereIn: record(string(), array(unknown())).optional(),
18755
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
18756
- orderBy: object({
18757
- field: string(),
18758
- direction: _enum(["asc", "desc"])
18759
- }).optional(),
18760
- limit: number().optional(),
18761
- offset: number().optional()
18762
- });
18763
- /** A single stored record: `{ id, data }`. */
18764
- var SettingsRecordSchema = object({
18765
- id: string(),
18766
- data: record(string(), unknown())
18767
- });
18768
- /**
18769
- * Column declaration for a structured (SQL-backed) collection.
18770
- *
18771
- * Logical types — the backend translates each to the matching SQLite
18772
- * storage class and handles per-type marshaling:
18773
- * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
18774
- * - `JSON` — TEXT under the hood; serialised on write, parsed on read
18775
- * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
18776
- */
18777
- var CollectionColumnSchema = object({
18778
- name: string(),
18779
- type: _enum([
18780
- "TEXT",
18781
- "INTEGER",
18782
- "REAL",
18783
- "JSON",
18784
- "BOOLEAN"
18785
- ]),
18786
- primaryKey: boolean().optional(),
18787
- notNull: boolean().optional(),
18788
- unique: boolean().optional()
18789
- });
18790
- var CollectionIndexSchema = object({
18791
- name: string(),
18792
- columns: array(string()).readonly(),
18793
- unique: boolean().optional()
18794
- });
18795
- method(object({
18796
- namespace: string().optional(),
18797
- collection: string(),
18798
- key: string()
18799
- }), unknown()), method(object({
18800
- namespace: string().optional(),
18801
- collection: string(),
18802
- key: string(),
18803
- value: unknown()
18804
- }), _void(), { kind: "mutation" }), method(object({
18805
- namespace: string().optional(),
18806
- collection: string(),
18807
- filter: QueryFilterSchema.optional()
18808
- }), array(SettingsRecordSchema).readonly()), method(object({
18809
- namespace: string().optional(),
18810
- collection: string(),
18811
- record: SettingsRecordSchema
18812
- }), _void(), { kind: "mutation" }), method(object({
18813
- namespace: string().optional(),
18814
- collection: string(),
18815
- id: string(),
18816
- data: record(string(), unknown())
18817
- }), _void(), { kind: "mutation" }), method(object({
18818
- namespace: string().optional(),
18819
- collection: string(),
18820
- key: string()
18821
- }), _void(), { kind: "mutation" }), method(object({
18822
- namespace: string().optional(),
18823
- collection: string(),
18824
- filter: QueryFilterSchema.optional()
18825
- }), number()), method(object({
18826
- namespace: string().optional(),
18827
- collection: string(),
18828
- field: string(),
18829
- bucketSize: number().int().positive(),
18830
- origin: number().int(),
18831
- filter: QueryFilterSchema.optional()
18832
- }), array(object({
18833
- bucket: number().int(),
18834
- count: number().int()
18835
- })).readonly()), method(object({
18836
- namespace: string().optional(),
18837
- collection: string()
18838
- }), boolean()), method(object({
18839
- namespace: string().optional(),
18840
- collection: string(),
18841
- columns: array(CollectionColumnSchema).readonly(),
18842
- indexes: array(CollectionIndexSchema).readonly().optional()
18843
- }), _void(), { kind: "mutation" });
18844
- /**
18845
19416
  * `smtp-provider` — pluggable email delivery surface.
18846
19417
  *
18847
19418
  * Collection cap: a deployment may install multiple SMTP relays (e.g.
@@ -22704,6 +23275,54 @@ Object.freeze({
22704
23275
  addonId: null,
22705
23276
  access: "create"
22706
23277
  },
23278
+ "coreBlocks.compile": {
23279
+ capName: "core-blocks",
23280
+ capScope: "system",
23281
+ addonId: null,
23282
+ access: "create"
23283
+ },
23284
+ "coreBlocks.create": {
23285
+ capName: "core-blocks",
23286
+ capScope: "system",
23287
+ addonId: null,
23288
+ access: "create"
23289
+ },
23290
+ "coreBlocks.delete": {
23291
+ capName: "core-blocks",
23292
+ capScope: "system",
23293
+ addonId: null,
23294
+ access: "delete"
23295
+ },
23296
+ "coreBlocks.get": {
23297
+ capName: "core-blocks",
23298
+ capScope: "system",
23299
+ addonId: null,
23300
+ access: "view"
23301
+ },
23302
+ "coreBlocks.getTypeDefs": {
23303
+ capName: "core-blocks",
23304
+ capScope: "system",
23305
+ addonId: null,
23306
+ access: "view"
23307
+ },
23308
+ "coreBlocks.list": {
23309
+ capName: "core-blocks",
23310
+ capScope: "system",
23311
+ addonId: null,
23312
+ access: "view"
23313
+ },
23314
+ "coreBlocks.setEnabled": {
23315
+ capName: "core-blocks",
23316
+ capScope: "system",
23317
+ addonId: null,
23318
+ access: "create"
23319
+ },
23320
+ "coreBlocks.update": {
23321
+ capName: "core-blocks",
23322
+ capScope: "system",
23323
+ addonId: null,
23324
+ access: "create"
23325
+ },
22707
23326
  "cover.close": {
22708
23327
  capName: "cover",
22709
23328
  capScope: "device",
@@ -22740,6 +23359,84 @@ Object.freeze({
22740
23359
  addonId: null,
22741
23360
  access: "view"
22742
23361
  },
23362
+ "dataStoreProvider.count": {
23363
+ capName: "data-store-provider",
23364
+ capScope: "system",
23365
+ addonId: null,
23366
+ access: "view"
23367
+ },
23368
+ "dataStoreProvider.declareCollection": {
23369
+ capName: "data-store-provider",
23370
+ capScope: "system",
23371
+ addonId: null,
23372
+ access: "create"
23373
+ },
23374
+ "dataStoreProvider.delete": {
23375
+ capName: "data-store-provider",
23376
+ capScope: "system",
23377
+ addonId: null,
23378
+ access: "delete"
23379
+ },
23380
+ "dataStoreProvider.deleteWhere": {
23381
+ capName: "data-store-provider",
23382
+ capScope: "system",
23383
+ addonId: null,
23384
+ access: "delete"
23385
+ },
23386
+ "dataStoreProvider.get": {
23387
+ capName: "data-store-provider",
23388
+ capScope: "system",
23389
+ addonId: null,
23390
+ access: "view"
23391
+ },
23392
+ "dataStoreProvider.getEngineInfo": {
23393
+ capName: "data-store-provider",
23394
+ capScope: "system",
23395
+ addonId: null,
23396
+ access: "view"
23397
+ },
23398
+ "dataStoreProvider.histogram": {
23399
+ capName: "data-store-provider",
23400
+ capScope: "system",
23401
+ addonId: null,
23402
+ access: "view"
23403
+ },
23404
+ "dataStoreProvider.insert": {
23405
+ capName: "data-store-provider",
23406
+ capScope: "system",
23407
+ addonId: null,
23408
+ access: "create"
23409
+ },
23410
+ "dataStoreProvider.isEmpty": {
23411
+ capName: "data-store-provider",
23412
+ capScope: "system",
23413
+ addonId: null,
23414
+ access: "view"
23415
+ },
23416
+ "dataStoreProvider.query": {
23417
+ capName: "data-store-provider",
23418
+ capScope: "system",
23419
+ addonId: null,
23420
+ access: "view"
23421
+ },
23422
+ "dataStoreProvider.set": {
23423
+ capName: "data-store-provider",
23424
+ capScope: "system",
23425
+ addonId: null,
23426
+ access: "create"
23427
+ },
23428
+ "dataStoreProvider.update": {
23429
+ capName: "data-store-provider",
23430
+ capScope: "system",
23431
+ addonId: null,
23432
+ access: "create"
23433
+ },
23434
+ "dataStoreProvider.updateWhere": {
23435
+ capName: "data-store-provider",
23436
+ capScope: "system",
23437
+ addonId: null,
23438
+ access: "create"
23439
+ },
22743
23440
  "dayNight.getOptions": {
22744
23441
  capName: "day-night",
22745
23442
  capScope: "device",
@@ -24534,12 +25231,24 @@ Object.freeze({
24534
25231
  addonId: null,
24535
25232
  access: "create"
24536
25233
  },
25234
+ "notificationRules.cancelSnooze": {
25235
+ capName: "notification-rules",
25236
+ capScope: "system",
25237
+ addonId: null,
25238
+ access: "create"
25239
+ },
24537
25240
  "notificationRules.createRule": {
24538
25241
  capName: "notification-rules",
24539
25242
  capScope: "system",
24540
25243
  addonId: null,
24541
25244
  access: "create"
24542
25245
  },
25246
+ "notificationRules.createSnooze": {
25247
+ capName: "notification-rules",
25248
+ capScope: "system",
25249
+ addonId: null,
25250
+ access: "create"
25251
+ },
24543
25252
  "notificationRules.deleteRule": {
24544
25253
  capName: "notification-rules",
24545
25254
  capScope: "system",
@@ -24570,6 +25279,12 @@ Object.freeze({
24570
25279
  addonId: null,
24571
25280
  access: "view"
24572
25281
  },
25282
+ "notificationRules.listSnoozes": {
25283
+ capName: "notification-rules",
25284
+ capScope: "system",
25285
+ addonId: null,
25286
+ access: "view"
25287
+ },
24573
25288
  "notificationRules.setRuleEnabled": {
24574
25289
  capName: "notification-rules",
24575
25290
  capScope: "system",
@@ -24774,6 +25489,12 @@ Object.freeze({
24774
25489
  addonId: null,
24775
25490
  access: "view"
24776
25491
  },
25492
+ "pipelineAnalytics.listEventKindsBatch": {
25493
+ capName: "pipeline-analytics",
25494
+ capScope: "device",
25495
+ addonId: null,
25496
+ access: "view"
25497
+ },
24777
25498
  "pipelineAnalytics.listOpsLog": {
24778
25499
  capName: "pipeline-analytics",
24779
25500
  capScope: "device",
@@ -24786,6 +25507,12 @@ Object.freeze({
24786
25507
  addonId: null,
24787
25508
  access: "view"
24788
25509
  },
25510
+ "pipelineAnalytics.listTrackMedia": {
25511
+ capName: "pipeline-analytics",
25512
+ capScope: "device",
25513
+ addonId: null,
25514
+ access: "view"
25515
+ },
24789
25516
  "pipelineAnalytics.listTracks": {
24790
25517
  capName: "pipeline-analytics",
24791
25518
  capScope: "device",
@@ -25818,6 +26545,12 @@ Object.freeze({
25818
26545
  addonId: null,
25819
26546
  access: "delete"
25820
26547
  },
26548
+ "settingsStore.deleteWhere": {
26549
+ capName: "settings-store",
26550
+ capScope: "system",
26551
+ addonId: null,
26552
+ access: "delete"
26553
+ },
25821
26554
  "settingsStore.get": {
25822
26555
  capName: "settings-store",
25823
26556
  capScope: "system",
@@ -25860,6 +26593,12 @@ Object.freeze({
25860
26593
  addonId: null,
25861
26594
  access: "create"
25862
26595
  },
26596
+ "settingsStore.updateWhere": {
26597
+ capName: "settings-store",
26598
+ capScope: "system",
26599
+ addonId: null,
26600
+ access: "create"
26601
+ },
25863
26602
  "smtpProvider.getStatus": {
25864
26603
  capName: "smtp-provider",
25865
26604
  capScope: "system",
@@ -28586,6 +29325,7 @@ var WEBHOOK_CAPS = {
28586
29325
  max: 99
28587
29326
  },
28588
29327
  actions: 99,
29328
+ actionIcons: true,
28589
29329
  levels: [
28590
29330
  {
28591
29331
  id: "p1",
@@ -29918,7 +30658,7 @@ function createMemorySettingsStorePort() {
29918
30658
  * the store persists whatever it is handed verbatim.
29919
30659
  */
29920
30660
  /** The default collection name (see `settings-store` addonId-prefix note). */
29921
- var TARGETS_COLLECTION = "notification-targets";
30661
+ var TARGETS_COLLECTION = "notifiers:notification-targets";
29922
30662
  /** KV JSON-blob shape — a single `data` column routes the row through the
29923
30663
  * settings backend's canonical key/value path (id TEXT PK, data TEXT). */
29924
30664
  var KV_BLOB_COLUMNS = [{