@camstack/addon-import-alexa 0.2.192 → 0.2.193

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 (4) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/addon.js +1748 -1414
  3. package/dist/addon.mjs +1749 -1415
  4. package/package.json +2 -2
package/dist/addon.js CHANGED
@@ -6044,7 +6044,7 @@ function number(params) {
6044
6044
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6045
6045
  }
6046
6046
  //#endregion
6047
- //#region ../types/dist/sleep-CmjWurK5.mjs
6047
+ //#region ../types/dist/sleep-CdKka2ri.mjs
6048
6048
  /**
6049
6049
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6050
6050
  * window to float samples (D455).
@@ -6800,6 +6800,13 @@ var DeclarationRefusedError = class extends RecordsError {
6800
6800
  function linkField(target) {
6801
6801
  return string().meta({ link: target });
6802
6802
  }
6803
+ /**
6804
+ * A link that may stay empty (absent): for a link the addon fills itself, or one an instance does not
6805
+ * always need. The meta rides on the optional wrapper, where {@link linksOfKind} reads it.
6806
+ */
6807
+ function optionalLinkField(target) {
6808
+ return string().optional().meta({ link: target });
6809
+ }
6803
6810
  function linkTargetOf(meta) {
6804
6811
  if (typeof meta !== "object" || meta === null) return void 0;
6805
6812
  const link = Reflect.get(meta, "link");
@@ -9591,286 +9598,6 @@ function normalizeAddonInitResult(result) {
9591
9598
  if (Array.isArray(result)) return { providers: result };
9592
9599
  return result;
9593
9600
  }
9594
- /**
9595
- * The share-view scope kinds a share-link token can carry. The hub mints
9596
- * and validates them (`ShareViewGrantSchema` builds its
9597
- * enum from this list); a data plane names the kinds it ACCEPTS in `share`.
9598
- */
9599
- var SHARE_VIEW_KINDS = ["grid-view", "events-view"];
9600
- /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
9601
- var PeerBytesTicketSchema = object({
9602
- /** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
9603
- url: string().min(1),
9604
- /**
9605
- * The HOST node this URL means something on — the hub or a named agent,
9606
- * never a runner. {@link AddonPeerBytes.open} compares it to its own and
9607
- * refuses `cross-node` by name when they differ, without dialling.
9608
- */
9609
- hostNodeId: string().min(1),
9610
- expiresAtMs: number$1().int().nonnegative(),
9611
- /**
9612
- * What the producer DECLARED the body to be, when it knows — `null` when it
9613
- * does not. Never `0` for unknown (D393): a consumer sizing a bound off this
9614
- * must be able to tell "the producer did not say" from "the body is empty".
9615
- */
9616
- declaredBytes: number$1().int().nonnegative().nullable()
9617
- });
9618
- function defOf(schema) {
9619
- const zodBag = Reflect.get(schema, "_zod");
9620
- if (typeof zodBag !== "object" || zodBag === null) return {};
9621
- const def = Reflect.get(zodBag, "def");
9622
- if (typeof def !== "object" || def === null) return {};
9623
- return def;
9624
- }
9625
- function declaredHere(schema) {
9626
- return globalRegistry.get(schema)?.secret === true;
9627
- }
9628
- /** Wrapper kinds whose `innerType` is the field itself. */
9629
- var WRAPPERS = new Set([
9630
- "optional",
9631
- "nullable",
9632
- "default",
9633
- "prefault",
9634
- "catch",
9635
- "readonly",
9636
- "nonoptional"
9637
- ]);
9638
- /**
9639
- * The schema layers that make up ONE field: the schema itself and every
9640
- * wrapper below it (optional / default / nullable / readonly / catch), plus
9641
- * both sides of a pipe (`z.preprocess` puts the real schema on `out`,
9642
- * `.transform` on `in`).
9643
- */
9644
- function layersOf(schema) {
9645
- const out = [];
9646
- const queue = [schema];
9647
- while (queue.length > 0) {
9648
- const s = queue.shift();
9649
- if (!s || out.includes(s)) continue;
9650
- out.push(s);
9651
- const def = defOf(s);
9652
- if (def.type !== void 0 && WRAPPERS.has(def.type) && def.innerType) queue.push(def.innerType);
9653
- if (def.type === "pipe") {
9654
- if (def.in) queue.push(def.in);
9655
- if (def.out) queue.push(def.out);
9656
- }
9657
- }
9658
- return out;
9659
- }
9660
- /** True when any layer of this field declares `.meta({ secret: true })`. */
9661
- function isZodSecret(schema) {
9662
- return layersOf(schema).some(declaredHere);
9663
- }
9664
- /**
9665
- * Every declared secret path in a config schema: dotted keys, `[]` for "each
9666
- * element of this array" (`connection.password`, `rows[].token`), `{}` for "each value of this
9667
- * map" (`applianceSecrets{}`). A declared
9668
- * object or array is reported as one path and not descended into — everything
9669
- * under it is secret.
9670
- */
9671
- function collectZodSecretPaths(schema, prefix = "") {
9672
- if (isZodSecret(schema)) return prefix.length > 0 ? [prefix] : [];
9673
- const paths = [];
9674
- for (const layer of layersOf(schema)) {
9675
- const def = defOf(layer);
9676
- if (def.type === "object" && def.shape) for (const [key, child] of Object.entries(def.shape)) paths.push(...collectZodSecretPaths(child, prefix ? `${prefix}.${key}` : key));
9677
- else if (def.type === "array" && def.element) paths.push(...collectZodSecretPaths(def.element, `${prefix}[]`));
9678
- else if (def.type === "record" && def.valueType) paths.push(...collectZodSecretPaths(def.valueType, `${prefix}{}`));
9679
- else if (def.type === "union" && def.options) for (const option of def.options) paths.push(...collectZodSecretPaths(option, prefix));
9680
- else if (def.type === "intersection") {
9681
- if (def.left) paths.push(...collectZodSecretPaths(def.left, prefix));
9682
- if (def.right) paths.push(...collectZodSecretPaths(def.right, prefix));
9683
- }
9684
- }
9685
- return [...new Set(paths)];
9686
- }
9687
- /**
9688
- * Record kinds (D881): the declaration an addon makes of one kind of fact, the
9689
- * Zod row schema that describes it, and the helper that turns the two into the
9690
- * JSON the engine stores.
9691
- *
9692
- * A kind is declared ONCE, next to the code that owns it, and the engine's
9693
- * reconciler creates its indexes - nothing creates DDL at runtime from a write.
9694
- * Everything the generic machinery needs (secret fields, the entry descriptor)
9695
- * is DERIVED from the Zod schema: `.meta({ secret: true })` marks a credential,
9696
- * never a field name (D879).
9697
- */
9698
- /** A JSON field path: `trackId`, `auth.accessToken`. Charset-restricted: it is rendered into DDL. */
9699
- var RecordFieldPathSchema = string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,63}(\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,3}$/);
9700
- var RecordIndexSchema = object({
9701
- name: string().regex(/^[a-z][a-z0-9_]{0,31}$/),
9702
- fields: array(RecordFieldPathSchema).min(1).max(4),
9703
- unique: boolean().optional()
9704
- }).strict();
9705
- var RecordRetentionSchema = union([
9706
- object({
9707
- ttlMs: number$1().int().positive(),
9708
- from: _enum(["at", "write"])
9709
- }).strict(),
9710
- object({
9711
- maxRows: number$1().int().positive().optional(),
9712
- maxRowsPerDevice: number$1().int().positive().optional()
9713
- }).strict().refine((r) => r.maxRows !== void 0 || r.maxRowsPerDevice !== void 0, { message: "name maxRows, maxRowsPerDevice or both" }),
9714
- object({
9715
- owner: string().min(1),
9716
- reason: string().min(1)
9717
- }).strict(),
9718
- object({ none: string().min(1) }).strict()
9719
- ]);
9720
- /** The descriptor a generic entries UI reads (D881 "generic entries"). */
9721
- var EntryDescriptorSchema = object({
9722
- title: string().min(1),
9723
- /** The record body field shown as the entry's name. */
9724
- nameField: string().min(1),
9725
- /** JSON Schema of the entry body; `secret: true` marks a password field. */
9726
- fields: record(string(), unknown())
9727
- }).strict();
9728
- /** What crosses the wire from an addon: JSON only, no Zod. An unknown key is refused. */
9729
- var RecordKindDeclarationSchema = object({
9730
- database: _enum(RECORD_DATABASES).default("settings"),
9731
- class: _enum(RECORD_CLASSES),
9732
- writeTrigger: string().min(1),
9733
- device: _enum([
9734
- "required",
9735
- "optional",
9736
- "none"
9737
- ]).default("none"),
9738
- at: _enum(["required", "none"]).default("none"),
9739
- indexes: array(RecordIndexSchema).max(8).default([]),
9740
- retention: RecordRetentionSchema,
9741
- deviceRemoval: _enum(["delete", "none"]).default("none"),
9742
- maxDataBytes: number$1().int().positive().max(RECORD_MAX_DATA_BYTES).default(RECORD_DEFAULT_MAX_DATA_BYTES),
9743
- /** Derived from the Zod schema; never written by hand. */
9744
- secretPaths: array(string().min(1)).default([]),
9745
- /** The id key of each array that carries a secret (`{ headers: 'name' }`). */
9746
- secretArrayKeys: record(string(), string()).default({}),
9747
- /** Kinds that must agree through `transact` sit in one file under one family name. */
9748
- transactFamily: string().min(1).optional(),
9749
- entry: EntryDescriptorSchema.optional()
9750
- }).strict();
9751
- /** The kind names: lower-case, dash separated, short enough for an index name. */
9752
- var RecordKindNameSchema = string().regex(/^[a-z][a-z0-9-]{0,47}$/);
9753
- /** Cross-field rules a declaration must satisfy. Throws {@link DeclarationRefusedError}. */
9754
- function assertValidDeclaration(name, decl) {
9755
- const refuse = (why) => {
9756
- throw new DeclarationRefusedError(`kind "${name}": ${why}`);
9757
- };
9758
- if (!RecordKindNameSchema.safeParse(name).success) refuse("the name must be lower-case-dashed");
9759
- const seen = /* @__PURE__ */ new Set();
9760
- for (const index of decl.indexes) {
9761
- if (seen.has(index.name)) refuse(`index name "${index.name}" is declared twice`);
9762
- seen.add(index.name);
9763
- }
9764
- const { retention } = decl;
9765
- const hasSecrets = decl.secretPaths.length > 0;
9766
- if ("ttlMs" in retention) {
9767
- if (retention.from === "at" && decl.at !== "required") refuse("a ttl from \"at\" needs at: \"required\"");
9768
- if (hasSecrets) refuse("a kind with secret fields cannot use engine TTL (its keychain values would be orphaned)");
9769
- }
9770
- if ("maxRows" in retention || "maxRowsPerDevice" in retention) {
9771
- if (retention.maxRowsPerDevice !== void 0 && decl.device !== "required") refuse("maxRowsPerDevice needs device: \"required\"");
9772
- if (decl.at !== "required") refuse("a row bound trims the oldest \"at\": it needs at: \"required\"");
9773
- if (hasSecrets) refuse("a kind with secret fields cannot use an engine row bound");
9774
- const bound = Math.max(retention.maxRows ?? 0, retention.maxRowsPerDevice ?? 0);
9775
- if (decl.database === "settings" && bound > 5e4) refuse(`a row bound above ${RECORD_SETTINGS_MAX_ROWS} belongs in database: "events" (a big delete on the settings thread stalls every configuration read)`);
9776
- }
9777
- if (decl.deviceRemoval === "delete" && decl.device === "none") refuse("deviceRemoval: \"delete\" needs a device: \"required\" or \"optional\"");
9778
- for (const path of decl.secretPaths) for (const prefix of secretArrayPrefixes(path)) if (decl.secretArrayKeys[prefix] === void 0) refuse(`secret path "${path}" crosses the array "${prefix}": declare its id key in secretArrayKeys`);
9779
- }
9780
- function bodyFieldsOf(schema) {
9781
- return { ...toJSONSchema(schema, {
9782
- unrepresentable: "any",
9783
- io: "input"
9784
- }) };
9785
- }
9786
- function isRecordLike(value) {
9787
- return typeof value === "object" && value !== null && !Array.isArray(value);
9788
- }
9789
- /** Does every branch of the body schema carry `field`? (A discriminated union has several.) */
9790
- function hasNameField(json, field) {
9791
- const branches = json["oneOf"] ?? json["anyOf"];
9792
- if (Array.isArray(branches)) return branches.length > 0 && branches.every((b) => isRecordLike(b) && hasNameField(b, field));
9793
- const properties = json["properties"];
9794
- return isRecordLike(properties) && Object.prototype.hasOwnProperty.call(properties, field);
9795
- }
9796
- /**
9797
- * Declare the kinds of a package. The keys are the kind names. The result is
9798
- * what `ctx.records.declare` sends to the engine and what `ctx.records.kind`
9799
- * types its handle from.
9800
- */
9801
- function defineRecordKinds(specs) {
9802
- const out = {};
9803
- for (const [name, spec] of Object.entries(specs)) {
9804
- const secretPaths = collectZodSecretPaths(spec.schema);
9805
- const nameField = spec.entry?.nameField ?? "name";
9806
- const fields = bodyFieldsOf(spec.schema);
9807
- if (spec.entry !== void 0 && !hasNameField(fields, nameField)) throw new DeclarationRefusedError(`kind "${name}": an entry kind's schema needs a "${nameField}" field`);
9808
- const parsed = RecordKindDeclarationSchema.parse({
9809
- database: spec.database,
9810
- class: spec.class,
9811
- writeTrigger: spec.writeTrigger,
9812
- device: spec.device,
9813
- at: spec.at,
9814
- indexes: spec.indexes === void 0 ? void 0 : [...spec.indexes],
9815
- retention: spec.retention,
9816
- deviceRemoval: spec.deviceRemoval,
9817
- maxDataBytes: spec.maxDataBytes,
9818
- secretPaths: [...new Set([...secretPaths, ...spec.retiredSecretPaths ?? []])],
9819
- secretArrayKeys: spec.secretArrayKeys === void 0 ? void 0 : { ...spec.secretArrayKeys },
9820
- transactFamily: spec.transactFamily,
9821
- entry: spec.entry === void 0 ? void 0 : {
9822
- title: spec.entry.title,
9823
- nameField,
9824
- fields
9825
- }
9826
- });
9827
- assertValidDeclaration(name, parsed);
9828
- out[name] = {
9829
- name,
9830
- schema: spec.schema,
9831
- declaration: parsed
9832
- };
9833
- }
9834
- return out;
9835
- }
9836
- /**
9837
- * Entry kinds (D881): every list of user-created entries (notification
9838
- * targets, brokers, LLM profiles) is ONE record kind with an id, a name and a
9839
- * kind-specific body, and the admin UI lists/adds/edits/deletes all of them
9840
- * through the `record-entries` capability with password fields derived from
9841
- * the schema (`.meta({ secret: true })`).
9842
- */
9843
- /** The body of a plain entry: a name and the fields the kind adds. */
9844
- function entryBody(shape) {
9845
- return object({
9846
- name: string().min(1),
9847
- enabled: boolean().optional(),
9848
- ...shape
9849
- });
9850
- }
9851
- /**
9852
- * Declare one entry kind. An entry is configuration: never trimmed by the
9853
- * engine, never aged, belonging to no device.
9854
- */
9855
- function defineEntryKind(name, spec) {
9856
- const defined = defineRecordKinds({ [name]: {
9857
- schema: spec.schema,
9858
- class: "config",
9859
- writeTrigger: spec.writeTrigger ?? "an operator adds or edits an entry in the entries UI",
9860
- retention: { none: "an entry goes only when the operator deletes it" },
9861
- ...spec.maxDataBytes === void 0 ? {} : { maxDataBytes: spec.maxDataBytes },
9862
- ...spec.database === void 0 ? {} : { database: spec.database },
9863
- ...spec.indexes === void 0 ? {} : { indexes: spec.indexes },
9864
- ...spec.secretArrayKeys === void 0 ? {} : { secretArrayKeys: spec.secretArrayKeys },
9865
- ...spec.retiredSecretPaths === void 0 ? {} : { retiredSecretPaths: spec.retiredSecretPaths },
9866
- entry: {
9867
- title: spec.title,
9868
- ...spec.nameField === void 0 ? {} : { nameField: spec.nameField }
9869
- }
9870
- } })[name];
9871
- if (defined === void 0) throw new DeclarationRefusedError(`entry kind "${name}" was not built`);
9872
- return defined;
9873
- }
9874
9601
  object({
9875
9602
  field: string().min(1),
9876
9603
  on: literal("advance")
@@ -9963,6 +9690,286 @@ function optionalMethod(input, output, options) {
9963
9690
  function event(data) {
9964
9691
  return { data };
9965
9692
  }
9693
+ function defOf(schema) {
9694
+ const zodBag = Reflect.get(schema, "_zod");
9695
+ if (typeof zodBag !== "object" || zodBag === null) return {};
9696
+ const def = Reflect.get(zodBag, "def");
9697
+ if (typeof def !== "object" || def === null) return {};
9698
+ return def;
9699
+ }
9700
+ function declaredHere(schema) {
9701
+ return globalRegistry.get(schema)?.secret === true;
9702
+ }
9703
+ /** Wrapper kinds whose `innerType` is the field itself. */
9704
+ var WRAPPERS = new Set([
9705
+ "optional",
9706
+ "nullable",
9707
+ "default",
9708
+ "prefault",
9709
+ "catch",
9710
+ "readonly",
9711
+ "nonoptional"
9712
+ ]);
9713
+ /**
9714
+ * The schema layers that make up ONE field: the schema itself and every
9715
+ * wrapper below it (optional / default / nullable / readonly / catch), plus
9716
+ * both sides of a pipe (`z.preprocess` puts the real schema on `out`,
9717
+ * `.transform` on `in`).
9718
+ */
9719
+ function layersOf(schema) {
9720
+ const out = [];
9721
+ const queue = [schema];
9722
+ while (queue.length > 0) {
9723
+ const s = queue.shift();
9724
+ if (!s || out.includes(s)) continue;
9725
+ out.push(s);
9726
+ const def = defOf(s);
9727
+ if (def.type !== void 0 && WRAPPERS.has(def.type) && def.innerType) queue.push(def.innerType);
9728
+ if (def.type === "pipe") {
9729
+ if (def.in) queue.push(def.in);
9730
+ if (def.out) queue.push(def.out);
9731
+ }
9732
+ }
9733
+ return out;
9734
+ }
9735
+ /** True when any layer of this field declares `.meta({ secret: true })`. */
9736
+ function isZodSecret(schema) {
9737
+ return layersOf(schema).some(declaredHere);
9738
+ }
9739
+ /**
9740
+ * Every declared secret path in a config schema: dotted keys, `[]` for "each
9741
+ * element of this array" (`connection.password`, `rows[].token`), `{}` for "each value of this
9742
+ * map" (`applianceSecrets{}`). A declared
9743
+ * object or array is reported as one path and not descended into — everything
9744
+ * under it is secret.
9745
+ */
9746
+ function collectZodSecretPaths(schema, prefix = "") {
9747
+ if (isZodSecret(schema)) return prefix.length > 0 ? [prefix] : [];
9748
+ const paths = [];
9749
+ for (const layer of layersOf(schema)) {
9750
+ const def = defOf(layer);
9751
+ if (def.type === "object" && def.shape) for (const [key, child] of Object.entries(def.shape)) paths.push(...collectZodSecretPaths(child, prefix ? `${prefix}.${key}` : key));
9752
+ else if (def.type === "array" && def.element) paths.push(...collectZodSecretPaths(def.element, `${prefix}[]`));
9753
+ else if (def.type === "record" && def.valueType) paths.push(...collectZodSecretPaths(def.valueType, `${prefix}{}`));
9754
+ else if (def.type === "union" && def.options) for (const option of def.options) paths.push(...collectZodSecretPaths(option, prefix));
9755
+ else if (def.type === "intersection") {
9756
+ if (def.left) paths.push(...collectZodSecretPaths(def.left, prefix));
9757
+ if (def.right) paths.push(...collectZodSecretPaths(def.right, prefix));
9758
+ }
9759
+ }
9760
+ return [...new Set(paths)];
9761
+ }
9762
+ /**
9763
+ * Record kinds (D881): the declaration an addon makes of one kind of fact, the
9764
+ * Zod row schema that describes it, and the helper that turns the two into the
9765
+ * JSON the engine stores.
9766
+ *
9767
+ * A kind is declared ONCE, next to the code that owns it, and the engine's
9768
+ * reconciler creates its indexes - nothing creates DDL at runtime from a write.
9769
+ * Everything the generic machinery needs (secret fields, the entry descriptor)
9770
+ * is DERIVED from the Zod schema: `.meta({ secret: true })` marks a credential,
9771
+ * never a field name (D879).
9772
+ */
9773
+ /** A JSON field path: `trackId`, `auth.accessToken`. Charset-restricted: it is rendered into DDL. */
9774
+ var RecordFieldPathSchema = string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,63}(\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,3}$/);
9775
+ var RecordIndexSchema = object({
9776
+ name: string().regex(/^[a-z][a-z0-9_]{0,31}$/),
9777
+ fields: array(RecordFieldPathSchema).min(1).max(4),
9778
+ unique: boolean().optional()
9779
+ }).strict();
9780
+ var RecordRetentionSchema = union([
9781
+ object({
9782
+ ttlMs: number$1().int().positive(),
9783
+ from: _enum(["at", "write"])
9784
+ }).strict(),
9785
+ object({
9786
+ maxRows: number$1().int().positive().optional(),
9787
+ maxRowsPerDevice: number$1().int().positive().optional()
9788
+ }).strict().refine((r) => r.maxRows !== void 0 || r.maxRowsPerDevice !== void 0, { message: "name maxRows, maxRowsPerDevice or both" }),
9789
+ object({
9790
+ owner: string().min(1),
9791
+ reason: string().min(1)
9792
+ }).strict(),
9793
+ object({ none: string().min(1) }).strict()
9794
+ ]);
9795
+ /** The descriptor a generic entries UI reads (D881 "generic entries"). */
9796
+ var EntryDescriptorSchema = object({
9797
+ title: string().min(1),
9798
+ /** The record body field shown as the entry's name. */
9799
+ nameField: string().min(1),
9800
+ /** JSON Schema of the entry body; `secret: true` marks a password field. */
9801
+ fields: record(string(), unknown())
9802
+ }).strict();
9803
+ /** What crosses the wire from an addon: JSON only, no Zod. An unknown key is refused. */
9804
+ var RecordKindDeclarationSchema = object({
9805
+ database: _enum(RECORD_DATABASES).default("settings"),
9806
+ class: _enum(RECORD_CLASSES),
9807
+ writeTrigger: string().min(1),
9808
+ device: _enum([
9809
+ "required",
9810
+ "optional",
9811
+ "none"
9812
+ ]).default("none"),
9813
+ at: _enum(["required", "none"]).default("none"),
9814
+ indexes: array(RecordIndexSchema).max(8).default([]),
9815
+ retention: RecordRetentionSchema,
9816
+ deviceRemoval: _enum(["delete", "none"]).default("none"),
9817
+ maxDataBytes: number$1().int().positive().max(RECORD_MAX_DATA_BYTES).default(RECORD_DEFAULT_MAX_DATA_BYTES),
9818
+ /** Derived from the Zod schema; never written by hand. */
9819
+ secretPaths: array(string().min(1)).default([]),
9820
+ /** The id key of each array that carries a secret (`{ headers: 'name' }`). */
9821
+ secretArrayKeys: record(string(), string()).default({}),
9822
+ /** Kinds that must agree through `transact` sit in one file under one family name. */
9823
+ transactFamily: string().min(1).optional(),
9824
+ entry: EntryDescriptorSchema.optional()
9825
+ }).strict();
9826
+ /** The kind names: lower-case, dash separated, short enough for an index name. */
9827
+ var RecordKindNameSchema = string().regex(/^[a-z][a-z0-9-]{0,47}$/);
9828
+ /** Cross-field rules a declaration must satisfy. Throws {@link DeclarationRefusedError}. */
9829
+ function assertValidDeclaration(name, decl) {
9830
+ const refuse = (why) => {
9831
+ throw new DeclarationRefusedError(`kind "${name}": ${why}`);
9832
+ };
9833
+ if (!RecordKindNameSchema.safeParse(name).success) refuse("the name must be lower-case-dashed");
9834
+ const seen = /* @__PURE__ */ new Set();
9835
+ for (const index of decl.indexes) {
9836
+ if (seen.has(index.name)) refuse(`index name "${index.name}" is declared twice`);
9837
+ seen.add(index.name);
9838
+ }
9839
+ const { retention } = decl;
9840
+ const hasSecrets = decl.secretPaths.length > 0;
9841
+ if ("ttlMs" in retention) {
9842
+ if (retention.from === "at" && decl.at !== "required") refuse("a ttl from \"at\" needs at: \"required\"");
9843
+ if (hasSecrets) refuse("a kind with secret fields cannot use engine TTL (its keychain values would be orphaned)");
9844
+ }
9845
+ if ("maxRows" in retention || "maxRowsPerDevice" in retention) {
9846
+ if (retention.maxRowsPerDevice !== void 0 && decl.device !== "required") refuse("maxRowsPerDevice needs device: \"required\"");
9847
+ if (decl.at !== "required") refuse("a row bound trims the oldest \"at\": it needs at: \"required\"");
9848
+ if (hasSecrets) refuse("a kind with secret fields cannot use an engine row bound");
9849
+ const bound = Math.max(retention.maxRows ?? 0, retention.maxRowsPerDevice ?? 0);
9850
+ if (decl.database === "settings" && bound > 5e4) refuse(`a row bound above ${RECORD_SETTINGS_MAX_ROWS} belongs in database: "events" (a big delete on the settings thread stalls every configuration read)`);
9851
+ }
9852
+ if (decl.deviceRemoval === "delete" && decl.device === "none") refuse("deviceRemoval: \"delete\" needs a device: \"required\" or \"optional\"");
9853
+ for (const path of decl.secretPaths) for (const prefix of secretArrayPrefixes(path)) if (decl.secretArrayKeys[prefix] === void 0) refuse(`secret path "${path}" crosses the array "${prefix}": declare its id key in secretArrayKeys`);
9854
+ }
9855
+ function bodyFieldsOf(schema) {
9856
+ return { ...toJSONSchema(schema, {
9857
+ unrepresentable: "any",
9858
+ io: "input"
9859
+ }) };
9860
+ }
9861
+ function isRecordLike(value) {
9862
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9863
+ }
9864
+ /** Does every branch of the body schema carry `field`? (A discriminated union has several.) */
9865
+ function hasNameField(json, field) {
9866
+ const branches = json["oneOf"] ?? json["anyOf"];
9867
+ if (Array.isArray(branches)) return branches.length > 0 && branches.every((b) => isRecordLike(b) && hasNameField(b, field));
9868
+ const properties = json["properties"];
9869
+ return isRecordLike(properties) && Object.prototype.hasOwnProperty.call(properties, field);
9870
+ }
9871
+ /**
9872
+ * Declare the kinds of a package. The keys are the kind names. The result is
9873
+ * what `ctx.records.declare` sends to the engine and what `ctx.records.kind`
9874
+ * types its handle from.
9875
+ */
9876
+ function defineRecordKinds(specs) {
9877
+ const out = {};
9878
+ for (const [name, spec] of Object.entries(specs)) {
9879
+ const secretPaths = collectZodSecretPaths(spec.schema);
9880
+ const nameField = spec.entry?.nameField ?? "name";
9881
+ const fields = bodyFieldsOf(spec.schema);
9882
+ if (spec.entry !== void 0 && !hasNameField(fields, nameField)) throw new DeclarationRefusedError(`kind "${name}": an entry kind's schema needs a "${nameField}" field`);
9883
+ const parsed = RecordKindDeclarationSchema.parse({
9884
+ database: spec.database,
9885
+ class: spec.class,
9886
+ writeTrigger: spec.writeTrigger,
9887
+ device: spec.device,
9888
+ at: spec.at,
9889
+ indexes: spec.indexes === void 0 ? void 0 : [...spec.indexes],
9890
+ retention: spec.retention,
9891
+ deviceRemoval: spec.deviceRemoval,
9892
+ maxDataBytes: spec.maxDataBytes,
9893
+ secretPaths: [...new Set([...secretPaths, ...spec.retiredSecretPaths ?? []])],
9894
+ secretArrayKeys: spec.secretArrayKeys === void 0 ? void 0 : { ...spec.secretArrayKeys },
9895
+ transactFamily: spec.transactFamily,
9896
+ entry: spec.entry === void 0 ? void 0 : {
9897
+ title: spec.entry.title,
9898
+ nameField,
9899
+ fields
9900
+ }
9901
+ });
9902
+ assertValidDeclaration(name, parsed);
9903
+ out[name] = {
9904
+ name,
9905
+ schema: spec.schema,
9906
+ declaration: parsed
9907
+ };
9908
+ }
9909
+ return out;
9910
+ }
9911
+ /**
9912
+ * Entry kinds (D881): every list of user-created entries (notification
9913
+ * targets, brokers, LLM profiles) is ONE record kind with an id, a name and a
9914
+ * kind-specific body, and the admin UI lists/adds/edits/deletes all of them
9915
+ * through the `record-entries` capability with password fields derived from
9916
+ * the schema (`.meta({ secret: true })`).
9917
+ */
9918
+ /** The body of a plain entry: a name and the fields the kind adds. */
9919
+ function entryBody(shape) {
9920
+ return object({
9921
+ name: string().min(1),
9922
+ enabled: boolean().optional(),
9923
+ ...shape
9924
+ });
9925
+ }
9926
+ /**
9927
+ * Declare one entry kind. An entry is configuration: never trimmed by the
9928
+ * engine, never aged, belonging to no device.
9929
+ */
9930
+ function defineEntryKind(name, spec) {
9931
+ const defined = defineRecordKinds({ [name]: {
9932
+ schema: spec.schema,
9933
+ class: "config",
9934
+ writeTrigger: spec.writeTrigger ?? "an operator adds or edits an entry in the entries UI",
9935
+ retention: { none: "an entry goes only when the operator deletes it" },
9936
+ ...spec.maxDataBytes === void 0 ? {} : { maxDataBytes: spec.maxDataBytes },
9937
+ ...spec.database === void 0 ? {} : { database: spec.database },
9938
+ ...spec.indexes === void 0 ? {} : { indexes: spec.indexes },
9939
+ ...spec.secretArrayKeys === void 0 ? {} : { secretArrayKeys: spec.secretArrayKeys },
9940
+ ...spec.retiredSecretPaths === void 0 ? {} : { retiredSecretPaths: spec.retiredSecretPaths },
9941
+ entry: {
9942
+ title: spec.title,
9943
+ ...spec.nameField === void 0 ? {} : { nameField: spec.nameField }
9944
+ }
9945
+ } })[name];
9946
+ if (defined === void 0) throw new DeclarationRefusedError(`entry kind "${name}" was not built`);
9947
+ return defined;
9948
+ }
9949
+ /**
9950
+ * The share-view scope kinds a share-link token can carry. The hub mints
9951
+ * and validates them (`ShareViewGrantSchema` builds its
9952
+ * enum from this list); a data plane names the kinds it ACCEPTS in `share`.
9953
+ */
9954
+ var SHARE_VIEW_KINDS = ["grid-view", "events-view"];
9955
+ /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
9956
+ var PeerBytesTicketSchema = object({
9957
+ /** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
9958
+ url: string().min(1),
9959
+ /**
9960
+ * The HOST node this URL means something on — the hub or a named agent,
9961
+ * never a runner. {@link AddonPeerBytes.open} compares it to its own and
9962
+ * refuses `cross-node` by name when they differ, without dialling.
9963
+ */
9964
+ hostNodeId: string().min(1),
9965
+ expiresAtMs: number$1().int().nonnegative(),
9966
+ /**
9967
+ * What the producer DECLARED the body to be, when it knows — `null` when it
9968
+ * does not. Never `0` for unknown (D393): a consumer sizing a bound off this
9969
+ * must be able to tell "the producer did not say" from "the body is empty".
9970
+ */
9971
+ declaredBytes: number$1().int().nonnegative().nullable()
9972
+ });
9966
9973
  var READING_QUANTITIES = [
9967
9974
  "temperature",
9968
9975
  "pressure",
@@ -10541,7 +10548,6 @@ var CAP_NAMES = Object.freeze([
10541
10548
  "climate-control",
10542
10549
  "color",
10543
10550
  "composer",
10544
- "connection-test",
10545
10551
  "connectivity",
10546
10552
  "consumables",
10547
10553
  "contact",
@@ -12561,8 +12567,7 @@ Object.freeze({
12561
12567
  "offerArtifact": 3e5,
12562
12568
  "readResult": 3e5,
12563
12569
  "submitJob": 18e5,
12564
- "uploadDataset": 72e5,
12565
- "validateCredentials": 3e4
12570
+ "uploadDataset": 72e5
12566
12571
  }
12567
12572
  });
12568
12573
  object({
@@ -14063,6 +14068,17 @@ function composeInstanceKinds(parts, base) {
14063
14068
  if (part === void 0) throw new UnknownInstanceKindError(kindId);
14064
14069
  return part;
14065
14070
  };
14071
+ /**
14072
+ * The part a DRAFT test runs on. A named kind is that kind; an unnamed one is the provider's only
14073
+ * testable kind. Several testable kinds and none named is a typed refusal that lists them, never a throw.
14074
+ */
14075
+ const draftPart = (kindId) => {
14076
+ if (kindId !== void 0) return parts.find((candidate) => candidate.kind.id === kindId) ?? { error: `Unknown instance kind '${kindId}'` };
14077
+ const testable = parts.filter((part) => part.kind.canTest !== false);
14078
+ const only = testable.length === 1 ? testable[0] : parts.length === 1 ? parts[0] : void 0;
14079
+ if (only !== void 0) return only;
14080
+ return { error: `This provider has several kinds: name one of ${(testable.length > 0 ? testable : parts).map((part) => part.kind.id).join(", ")}` };
14081
+ };
14066
14082
  const holderOf = async (id) => {
14067
14083
  for (const part of parts) if (await part.provider.getInstance({ id }) !== null) return part;
14068
14084
  throw new UnknownInstanceError(id);
@@ -14098,7 +14114,13 @@ function composeInstanceKinds(parts, base) {
14098
14114
  setInstanceEnabled: async (input) => (await holderOf(input.id)).provider.setInstanceEnabled(input),
14099
14115
  deleteInstance: async (input) => (await holderOf(input.id)).provider.deleteInstance(input),
14100
14116
  testInstance: async (input) => {
14101
- const test = (input.id === null ? byKind(input.kind) : await holderOf(input.id)).provider.testInstance;
14117
+ const resolved = input.id === null ? draftPart(input.kind) : await holderOf(input.id);
14118
+ if ("error" in resolved) return {
14119
+ success: false,
14120
+ status: "inconclusive",
14121
+ error: resolved.error
14122
+ };
14123
+ const test = resolved.provider.testInstance;
14102
14124
  if (test === void 0) return {
14103
14125
  success: false,
14104
14126
  status: "inconclusive",
@@ -14108,6 +14130,745 @@ function composeInstanceKinds(parts, base) {
14108
14130
  }
14109
14131
  };
14110
14132
  }
14133
+ /**
14134
+ * notification-output — canonical, capability-gated notification delivery.
14135
+ *
14136
+ * Apprise-derived model (see
14137
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
14138
+ * callers emit ONE canonical `Notification`; each provider declares a
14139
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
14140
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
14141
+ * message to what the kind supports — callers never special-case a service.
14142
+ *
14143
+ * DESIGN DECISIONS (locked):
14144
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
14145
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
14146
+ * cap. Rationale: the admin UI needs one uniform surface across the
14147
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
14148
+ * alternative would fork the UI per addon and cannot host the
14149
+ * discovery→adopt flow.
14150
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
14151
+ * the generated cap-mount auto-`concatCollection`-fans them across every
14152
+ * registered provider (notifiers addon + HA addon) so one catalog is
14153
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
14154
+ * `addonId` the generated collection router extracts from the call input.
14155
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
14156
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
14157
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
14158
+ * base64 fallback needed.
14159
+ *
14160
+ * TODO (deferred, closed-set change — separate decision): add
14161
+ * `providerKind: 'notify'` so notification providers surface on the unified
14162
+ * admin "Integrations" page.
14163
+ */
14164
+ /**
14165
+ * Zentik-derived typed-media enum — the superset across every kind. Each
14166
+ * adapter picks what it supports and the degrade engine filters the rest.
14167
+ */
14168
+ var AttachmentMediaTypeSchema = _enum([
14169
+ "image",
14170
+ "video",
14171
+ "gif",
14172
+ "audio",
14173
+ "icon"
14174
+ ]);
14175
+ /**
14176
+ * A single attachment. Exactly one of `url` (remote source, most adapters
14177
+ * prefer this) or `bytes` (inline source; required for Pushover-style
14178
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
14179
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
14180
+ */
14181
+ var AttachmentSchema = object({
14182
+ mediaType: AttachmentMediaTypeSchema,
14183
+ url: string().optional(),
14184
+ bytes: _instanceof(Uint8Array).optional(),
14185
+ mime: string().optional(),
14186
+ name: string().optional()
14187
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
14188
+ var NotificationFormatSchema = _enum([
14189
+ "text",
14190
+ "markdown",
14191
+ "html"
14192
+ ]);
14193
+ /**
14194
+ * The CLOSED icon vocabulary an action button may use.
14195
+ *
14196
+ * A closed set, not a free string, and that is the whole point: an arbitrary
14197
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
14198
+ * notices — the same class of gap as a zone vocabulary nothing produced
14199
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
14200
+ * declares `actionIcons: false` and the degrade engine strips the field.
14201
+ *
14202
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
14203
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
14204
+ * differently.
14205
+ *
14206
+ * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
14207
+ *
14208
+ * These names are for US. **No adapter may forward one verbatim.** Each maps
14209
+ * the whole set onto its own renderer's vocabulary through a
14210
+ * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
14211
+ * fallback, so adding a member here fails every adapter's build until someone
14212
+ * decides its glyph, which is the only place that decision can be made
14213
+ * honestly.
14214
+ *
14215
+ * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
14216
+ * `disarm` straight through; iOS feeds that string to
14217
+ * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
14218
+ * and every snooze and alarm button arrived BLANK. A pass-through is not a
14219
+ * mapping, and "the field is documented" is not "the value renders".
14220
+ *
14221
+ * Adding a member is TRAIN-BOUND. The enum lives in the published
14222
+ * `@camstack/server` closure and the cap seam validates against the HUB's copy,
14223
+ * so an addon that emits a token the running hub does not know does not lose an
14224
+ * icon — its whole `send` fails Zod validation and the notification never
14225
+ * arrives. Never emit a new token from an addon before the train carrying it.
14226
+ */
14227
+ var NotificationActionIconSchema = _enum([
14228
+ "acknowledge",
14229
+ "dismiss",
14230
+ "silence",
14231
+ "snooze",
14232
+ "view",
14233
+ "play",
14234
+ "open",
14235
+ "close",
14236
+ "lock",
14237
+ "unlock",
14238
+ "arm",
14239
+ "arm-home",
14240
+ "arm-away",
14241
+ "arm-night",
14242
+ "disarm",
14243
+ "light",
14244
+ "alert",
14245
+ "camera"
14246
+ ]);
14247
+ /** A single tap-through action button. */
14248
+ var NotificationActionSchema = object({
14249
+ id: string(),
14250
+ label: string(),
14251
+ url: string().optional(),
14252
+ /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
14253
+ icon: NotificationActionIconSchema.optional(),
14254
+ /**
14255
+ * Renders in a warning style where the notifier supports it.
14256
+ *
14257
+ * A HINT, never a gate. The callback's authority is its token and nothing
14258
+ * else — see `notification-center/action-token.ts` for what that does and
14259
+ * does not buy.
14260
+ */
14261
+ destructive: boolean().optional(),
14262
+ /**
14263
+ * How the tap should REACH the url.
14264
+ *
14265
+ * `navigate` (absent, and every button authored before this field) opens it:
14266
+ * the phone leaves the notification and shows whatever the callback returns.
14267
+ * That is right for a button whose answer the operator wants to read.
14268
+ *
14269
+ * `background` fires it as a POST and stays put. It exists for the buttons
14270
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
14271
+ * an answer to the notification, and being thrown into a browser tab to
14272
+ * confirm it costs more attention than the notification did. A backend that
14273
+ * cannot do a background call renders it as an ordinary link (the adapters
14274
+ * fall back rather than dropping the button), so this is a preference, never
14275
+ * a requirement.
14276
+ */
14277
+ mode: _enum(["navigate", "background"]).optional()
14278
+ });
14279
+ /** A delivery parameter value: what a notifier declared it takes. */
14280
+ var DeliveryParamValueSchema = union([
14281
+ string(),
14282
+ number$1(),
14283
+ boolean()
14284
+ ]);
14285
+ /**
14286
+ * One extra delivery parameter a notifier KIND declares (D938). A rule that
14287
+ * addresses a target of that kind may set it; a kind that declares nothing is
14288
+ * never handed one. The admin UI derives its control from this descriptor —
14289
+ * no editor knows a service by name.
14290
+ *
14291
+ * Optional fields are `.optional()`, never `.default()` (a Zod default does not
14292
+ * run on the addon cap path).
14293
+ */
14294
+ var DeliveryParamSchema = object({
14295
+ key: string().min(1),
14296
+ label: string(),
14297
+ /** i18n key of the label, resolved by the declaring addon's namespace. */
14298
+ labelKey: string().optional(),
14299
+ type: _enum([
14300
+ "enum",
14301
+ "number",
14302
+ "boolean",
14303
+ "string"
14304
+ ]),
14305
+ /** `enum` only: the closed set of accepted values. */
14306
+ options: array(object({
14307
+ value: string(),
14308
+ label: string()
14309
+ })).optional(),
14310
+ /** `number` only. */
14311
+ min: number$1().optional(),
14312
+ max: number$1().optional(),
14313
+ /** What the notifier does when the rule sets nothing; shown as the placeholder. */
14314
+ default: DeliveryParamValueSchema.optional()
14315
+ });
14316
+ /**
14317
+ * The canonical notification. `body` is the only hard field (Apprise model).
14318
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
14319
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
14320
+ * the adapter maps this ordinal onto its native level. `level?` is an
14321
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
14322
+ * `priority` for that one target.
14323
+ */
14324
+ var NotificationSchema = object({
14325
+ body: string(),
14326
+ title: string().optional(),
14327
+ format: NotificationFormatSchema.default("text"),
14328
+ priority: number$1().int().min(1).max(5).default(3),
14329
+ level: string().optional(),
14330
+ attachments: array(AttachmentSchema).optional(),
14331
+ clickUrl: string().optional(),
14332
+ actions: array(NotificationActionSchema).optional(),
14333
+ sound: string().optional(),
14334
+ ttl: number$1().optional(),
14335
+ tag: string().optional(),
14336
+ deviceId: number$1().optional(),
14337
+ eventId: string().optional(),
14338
+ metadata: record(string(), unknown()).optional(),
14339
+ /**
14340
+ * Values for the parameters the target's kind DECLARES in
14341
+ * {@link TargetKindSchema} `deliveryParams`. Already validated against the
14342
+ * declaration by the sender; a notifier reads only the keys it declared.
14343
+ */
14344
+ delivery: record(string(), DeliveryParamValueSchema).optional()
14345
+ });
14346
+ /** One declared native severity/priority level for a kind. */
14347
+ var TargetKindLevelSchema = object({
14348
+ id: string(),
14349
+ label: string(),
14350
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
14351
+ ordinal: number$1().int().min(1).max(5).nullable(),
14352
+ flags: object({
14353
+ critical: boolean().optional(),
14354
+ silent: boolean().optional(),
14355
+ noPush: boolean().optional()
14356
+ }).optional(),
14357
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
14358
+ requires: array(string()).optional(),
14359
+ description: string().optional()
14360
+ });
14361
+ /** The full capability block consulted before dispatch. */
14362
+ var TargetKindCapsSchema = object({
14363
+ attachments: object({
14364
+ mediaTypes: array(AttachmentMediaTypeSchema),
14365
+ mode: _enum([
14366
+ "url",
14367
+ "bytes",
14368
+ "both"
14369
+ ]),
14370
+ max: number$1().int().nonnegative(),
14371
+ maxBytes: number$1().int().positive().optional()
14372
+ }),
14373
+ /** Max action buttons (0 = none). */
14374
+ actions: number$1().int().nonnegative(),
14375
+ /**
14376
+ * Whether this kind renders a per-action ICON.
14377
+ *
14378
+ * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
14379
+ * run on the addon cap path — three production failures in one day taught
14380
+ * this repo that once. Absent is read as false by the degrade engine, which
14381
+ * is the safe direction: an icon that is not rendered costs nothing, an icon
14382
+ * assumed and dropped costs the operator's trust in the field.
14383
+ */
14384
+ actionIcons: boolean().optional(),
14385
+ levels: array(TargetKindLevelSchema),
14386
+ format: array(NotificationFormatSchema),
14387
+ clickUrl: boolean(),
14388
+ sound: boolean(),
14389
+ ttl: boolean(),
14390
+ bodyMaxLen: number$1().int().positive()
14391
+ });
14392
+ /**
14393
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
14394
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
14395
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
14396
+ * the union is large and not meant for runtime validation here; the exported
14397
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
14398
+ */
14399
+ var ConfigSchemaPassthrough$4 = unknown();
14400
+ var TargetKindSchema = object({
14401
+ kind: string(),
14402
+ label: string(),
14403
+ icon: string(),
14404
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
14405
+ addonId: string(),
14406
+ /**
14407
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
14408
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
14409
+ * when the addon bundles no icon for that kind — the client then falls back
14410
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
14411
+ *
14412
+ * Root-relative on purpose: it resolves against whatever origin serves a web
14413
+ * client, and a native client joins it onto its own hub base.
14414
+ *
14415
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
14416
+ * field that survived only because the runtime cap-router forwards provider
14417
+ * output verbatim — so every consumer had to re-declare it by hand to stop
14418
+ * its own Zod parse from stripping it, and the whole arrangement would have
14419
+ * broken silently the moment output validation was tightened anywhere.
14420
+ */
14421
+ iconUrl: string().optional(),
14422
+ /**
14423
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
14424
+ *
14425
+ * The server knows this and therefore says it, because the client cannot
14426
+ * safely guess: a React-Native client renders SVG and raster through two
14427
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
14428
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
14429
+ * placeholder glyph for every vector icon while the web build looked fine.
14430
+ *
14431
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
14432
+ * not been updated — a client that cannot determine the type should prefer
14433
+ * its raster path, which is the safe default for an unknown image.
14434
+ */
14435
+ iconMediaType: string().optional(),
14436
+ configSchema: ConfigSchemaPassthrough$4,
14437
+ /**
14438
+ * The config keys that decide WHERE this kind's secrets are sent (server,
14439
+ * base URL, instance, chat, recipient). A save that changes any of them
14440
+ * while leaving a secret redacted is refused by the provider (D634), so an
14441
+ * editor uses this to ask for the secret on exactly those edits instead of
14442
+ * on every edit.
14443
+ *
14444
+ * Optional: absent from a provider that holds no secret (the Home Assistant
14445
+ * kinds) and from a provider older than this field — an editor that finds
14446
+ * it absent must assume ANY edit may need the secret.
14447
+ */
14448
+ destinationKeys: array(string()).optional(),
14449
+ supportsDiscovery: boolean(),
14450
+ /**
14451
+ * Extra delivery parameters a rule may set for a target of this kind (D938).
14452
+ * Absent = none; the rule editor then offers nothing.
14453
+ */
14454
+ deliveryParams: array(DeliveryParamSchema).optional(),
14455
+ caps: TargetKindCapsSchema
14456
+ });
14457
+ /**
14458
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
14459
+ * (return a presence marker only) when serving `listTargets` — never
14460
+ * round-trip a stored secret to the UI.
14461
+ */
14462
+ var TargetSchema = object({
14463
+ id: string(),
14464
+ name: string(),
14465
+ kind: string(),
14466
+ addonId: string(),
14467
+ enabled: boolean(),
14468
+ config: record(string(), unknown())
14469
+ });
14470
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
14471
+ var DiscoveredTargetSchema = object({
14472
+ kind: string(),
14473
+ suggestedName: string(),
14474
+ config: record(string(), unknown())
14475
+ });
14476
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
14477
+ var RenderedAsSchema = object({
14478
+ level: string(),
14479
+ format: NotificationFormatSchema,
14480
+ attachmentsSent: number$1().int().nonnegative(),
14481
+ actionsSent: number$1().int().nonnegative(),
14482
+ truncated: boolean(),
14483
+ dropped: array(string())
14484
+ });
14485
+ var SendResultSchema = object({
14486
+ success: boolean(),
14487
+ error: string().optional(),
14488
+ renderedAs: RenderedAsSchema.optional()
14489
+ });
14490
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
14491
+ var TestResultSchema = SendResultSchema;
14492
+ /**
14493
+ * Pre-save credential probe against a partial (unsaved) target config.
14494
+ * `resolvedUrl` never includes secrets (token stripped from path).
14495
+ */
14496
+ var ProbeTargetConfigResultSchema = object({
14497
+ ok: boolean(),
14498
+ resolvedUrl: string(),
14499
+ httpStatus: number$1().int().optional(),
14500
+ stateInstance: string().optional(),
14501
+ error: string().optional()
14502
+ });
14503
+ var notificationOutputCapability = {
14504
+ name: "notification-output",
14505
+ scope: "system",
14506
+ mode: "collection",
14507
+ methods: {
14508
+ listTargetKinds: method(object({}), array(TargetKindSchema)),
14509
+ listTargets: method(object({}), array(TargetSchema), { auth: "admin" }),
14510
+ /**
14511
+ * One target WITH secrets — admin edit / duplicate hydration only.
14512
+ * `listTargets` stays redacted; the editor loads this so password fields
14513
+ * are filled (masked in the input) and a save can round-trip the token
14514
+ * when a destination changes (Duplicate with a new chatId, etc.).
14515
+ */
14516
+ getTarget: method(object({ targetId: string() }), TargetSchema, { auth: "admin" }),
14517
+ discoverTargets: method(object({
14518
+ kind: string(),
14519
+ config: record(string(), unknown()).optional(),
14520
+ /**
14521
+ * When editing / duplicating, the blank password field never round-trips
14522
+ * the stored token. Pass the source target id so the provider can restore
14523
+ * secrets server-side before dialling (same merge as upsert).
14524
+ */
14525
+ targetId: string().optional()
14526
+ }), array(DiscoveredTargetSchema), { auth: "admin" }),
14527
+ probeTargetConfig: method(object({
14528
+ kind: string(),
14529
+ config: record(string(), unknown()),
14530
+ /** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
14531
+ targetId: string().optional()
14532
+ }), ProbeTargetConfigResultSchema, {
14533
+ kind: "mutation",
14534
+ auth: "admin"
14535
+ }),
14536
+ send: method(object({
14537
+ targetId: string(),
14538
+ notification: NotificationSchema
14539
+ }), SendResultSchema, {
14540
+ kind: "mutation",
14541
+ auth: "admin"
14542
+ }),
14543
+ testTarget: method(object({
14544
+ targetId: string(),
14545
+ sample: NotificationSchema.optional()
14546
+ }), TestResultSchema, {
14547
+ kind: "mutation",
14548
+ auth: "admin"
14549
+ }),
14550
+ upsertTarget: method(object({
14551
+ target: TargetSchema,
14552
+ /**
14553
+ * Duplicate flow: create a NEW target id while copying secrets from an
14554
+ * existing row. The UI never holds the stored token; blank / redacted
14555
+ * password fields are restored from this source (D634 still applies if
14556
+ * destinations diverge without a re-typed secret).
14557
+ */
14558
+ cloneSecretsFrom: string().optional()
14559
+ }), TargetSchema, {
14560
+ kind: "mutation",
14561
+ auth: "admin"
14562
+ }),
14563
+ deleteTarget: method(object({ targetId: string() }), _void(), {
14564
+ kind: "mutation",
14565
+ auth: "admin"
14566
+ }),
14567
+ setTargetEnabled: method(object({
14568
+ targetId: string(),
14569
+ enabled: boolean()
14570
+ }), _void(), {
14571
+ kind: "mutation",
14572
+ auth: "admin"
14573
+ })
14574
+ }
14575
+ };
14576
+ /**
14577
+ * Notification targets as instances of the function cap `notification-output` (epic #440, step 6).
14578
+ *
14579
+ * A notification target used to be one row of one record kind (`{ name, kind, addonId, enabled,
14580
+ * config }`) with a free-form `config` blob whose secrets nobody could name. Now every target KIND
14581
+ * the provider offers (ntfy, telegram, webhook, ...) is a record kind of its own whose flat body IS
14582
+ * that kind's form: the adapter's `configSchema` lifted into a Zod object, a `password` field marked
14583
+ * `.meta({ secret: true })` so the records door keeps it in the keychain, an `instance-select` field
14584
+ * declared as a link so the hub refuses to delete what a target points at.
14585
+ *
14586
+ * Three providers (notifiers, Home Assistant, Alexa) serve the same shape, so the pieces are shared:
14587
+ * - {@link defineNotificationTargetKind}: the record kind of one `TargetKind` descriptor;
14588
+ * - {@link NotificationTargetStore}: the `Target`-shaped facade the existing provider code keeps
14589
+ * using, over one handle per kind (the old `notification-output` CRUD keeps working on it);
14590
+ * - {@link buildNotificationTargetInstances}: the `integration-instances` provider, one kind each;
14591
+ * - {@link moveTargetRows}: the D987 procedure that flattens the old `config` blobs into the bodies.
14592
+ */
14593
+ /** The function cap every notification target is an instance of. */
14594
+ var NOTIFICATION_OUTPUT_CAP = "notification-output";
14595
+ /** The most targets one provider lists (a settings page, not a table). */
14596
+ var TARGETS_LIST_MAX = 1e3;
14597
+ /** The record kind name of one target kind: `telegram` is stored under `notify-telegram`. */
14598
+ function notificationTargetKindName(kind) {
14599
+ return `notify-${kind}`;
14600
+ }
14601
+ /** The body schema of a target kind: the entry's name and switch plus the adapter's fields. */
14602
+ var TargetBodyBaseSchema = looseObject({
14603
+ name: string().min(1),
14604
+ enabled: boolean().optional()
14605
+ });
14606
+ /** The shape of one `editable-array` row: the item fields, a `password` one a secret. */
14607
+ function rowSchemaOf(itemFields) {
14608
+ const shape = {};
14609
+ let idKey = null;
14610
+ let hasSecret = false;
14611
+ for (const item of itemFields) {
14612
+ if (item.type === "password") {
14613
+ shape[item.key] = string().optional().meta({ secret: true });
14614
+ hasSecret = true;
14615
+ continue;
14616
+ }
14617
+ shape[item.key] = unknown().optional();
14618
+ idKey ??= item.key;
14619
+ }
14620
+ return {
14621
+ row: looseObject(shape),
14622
+ idKey,
14623
+ hasSecret
14624
+ };
14625
+ }
14626
+ /**
14627
+ * The Zod fields of a descriptor's form. A `password` field is a secret; an `instance-select` field is
14628
+ * a link to an instance of its function cap; an `editable-array` keeps its rows (a password inside a
14629
+ * row is a secret matched by the row's first plain field); every other field keeps whatever the
14630
+ * adapter's own parse accepts, because the form already validated its shape. The body is loose, so a key
14631
+ * the schema does not know (the owner of a personal target) survives a write.
14632
+ */
14633
+ function fieldsOf(descriptor) {
14634
+ const shape = {};
14635
+ const secretArrayKeys = {};
14636
+ for (const section of descriptor.configSchema.sections) for (const field of section.fields) switch (field.type) {
14637
+ case "info": break;
14638
+ case "password":
14639
+ shape[field.key] = string().optional().meta({ secret: true });
14640
+ break;
14641
+ case "instance-select":
14642
+ shape[field.key] = optionalLinkField({
14643
+ cap: field.functionCap,
14644
+ ...field.kind !== void 0 ? { kind: field.kind } : {}
14645
+ });
14646
+ break;
14647
+ case "editable-array": {
14648
+ const { row, idKey, hasSecret } = rowSchemaOf(field.itemFields);
14649
+ shape[field.key] = array(row).optional();
14650
+ if (hasSecret && idKey !== null) secretArrayKeys[field.key] = idKey;
14651
+ break;
14652
+ }
14653
+ default: shape[field.key] = unknown().optional();
14654
+ }
14655
+ return {
14656
+ shape,
14657
+ secretArrayKeys
14658
+ };
14659
+ }
14660
+ /** The record kind of one target kind, derived from its descriptor. */
14661
+ function defineNotificationTargetKind(descriptor) {
14662
+ const { shape, secretArrayKeys } = fieldsOf(descriptor);
14663
+ return defineEntryKind(notificationTargetKindName(descriptor.kind), {
14664
+ title: `${descriptor.label} notification targets`,
14665
+ schema: TargetBodyBaseSchema.extend(shape),
14666
+ writeTrigger: `an operator adds, edits, switches or removes a ${descriptor.label} notification target`,
14667
+ ...Object.keys(secretArrayKeys).length > 0 ? { secretArrayKeys } : {}
14668
+ });
14669
+ }
14670
+ /** The record kinds of a provider's descriptors, keyed by the record kind name (for `records.declare`). */
14671
+ function notificationTargetKinds(descriptors) {
14672
+ return Object.fromEntries(descriptors.map((descriptor) => {
14673
+ const defined = defineNotificationTargetKind(descriptor);
14674
+ return [defined.name, defined];
14675
+ }));
14676
+ }
14677
+ /** The body a stored target becomes: the switch and the name, and the config fields flat beside them. */
14678
+ function targetBody(target) {
14679
+ return {
14680
+ ...target.config,
14681
+ name: target.name.length > 0 ? target.name : target.id,
14682
+ enabled: target.enabled
14683
+ };
14684
+ }
14685
+ /** The `Target` a record of a kind is; null when the row is not a target. */
14686
+ function targetOfRecord(addonId, kind, id, data) {
14687
+ const { name, enabled: _enabled, ...config } = data;
14688
+ const parsed = TargetSchema.safeParse({
14689
+ id,
14690
+ name,
14691
+ kind,
14692
+ addonId,
14693
+ enabled: isInstanceEnabled(data),
14694
+ config
14695
+ });
14696
+ return parsed.success ? parsed.data : null;
14697
+ }
14698
+ function targetOfOld(addonId, row) {
14699
+ return {
14700
+ id: row.id,
14701
+ name: row.name,
14702
+ kind: row.kind,
14703
+ addonId,
14704
+ enabled: row.enabled,
14705
+ config: { ...row.config }
14706
+ };
14707
+ }
14708
+ /**
14709
+ * The `Target`-shaped facade over one record kind per target kind. `list` returns the stored form (a
14710
+ * secret is a keychain marker: every reader redacts it anyway); `getById` returns the target with its
14711
+ * secrets resolved, which is what a send, a probe and an edit's secret merge need. A keychain that
14712
+ * cannot answer throws: a write never goes ahead with a secret it could not read (D49).
14713
+ */
14714
+ var NotificationTargetStore = class NotificationTargetStore {
14715
+ addonId;
14716
+ handles;
14717
+ options;
14718
+ constructor(addonId, handles, options) {
14719
+ this.addonId = addonId;
14720
+ this.handles = handles;
14721
+ this.options = options;
14722
+ }
14723
+ static of(records, addonId, kinds, descriptors, options = {}) {
14724
+ const handles = [];
14725
+ for (const descriptor of descriptors) {
14726
+ const defined = kinds[notificationTargetKindName(descriptor.kind)];
14727
+ if (defined === void 0) continue;
14728
+ handles.push({
14729
+ kind: descriptor.kind,
14730
+ handle: records.kind(defined)
14731
+ });
14732
+ }
14733
+ return new NotificationTargetStore(addonId, handles, options);
14734
+ }
14735
+ /** The record handle of each kind, for the migration ports. */
14736
+ handlesByKind() {
14737
+ return new Map(this.handles.map((entry) => [entry.kind, entry.handle]));
14738
+ }
14739
+ async list() {
14740
+ const out = [];
14741
+ for (const { kind, handle } of this.handles) {
14742
+ const page = await handle.query({ limit: TARGETS_LIST_MAX });
14743
+ for (const record of page.records) {
14744
+ const target = targetOfRecord(this.addonId, kind, record.id, record.data);
14745
+ if (target !== null) {
14746
+ out.push(target);
14747
+ continue;
14748
+ }
14749
+ this.options.logger?.warn("notification target record is not a valid target; left out of the list", { meta: {
14750
+ targetId: record.id,
14751
+ kind
14752
+ } });
14753
+ }
14754
+ }
14755
+ const held = new Set(out.map((target) => target.id));
14756
+ for (const row of await this.legacyRows()) if (!held.has(row.id)) out.push(targetOfOld(this.addonId, row));
14757
+ return out;
14758
+ }
14759
+ /** The legacy rows of a kind this provider serves (the others are not ours to show). */
14760
+ async legacyRows() {
14761
+ const legacy = this.options.legacy;
14762
+ if (legacy === void 0) return [];
14763
+ const served = new Set(this.handles.map((entry) => entry.kind));
14764
+ return (await legacy.list()).filter((row) => served.has(row.kind));
14765
+ }
14766
+ async getById(id) {
14767
+ for (const { kind, handle } of this.handles) {
14768
+ const row = await handle.getResolved(id);
14769
+ if (row !== null) return targetOfRecord(this.addonId, kind, row.id, row.data);
14770
+ }
14771
+ const old = (await this.legacyRows()).find((row) => row.id === id);
14772
+ return old === void 0 ? null : targetOfOld(this.addonId, old);
14773
+ }
14774
+ /** Insert or replace by id; a target whose kind changed leaves the record of its old kind. */
14775
+ async upsert(target) {
14776
+ const own = this.handles.find((entry) => entry.kind === target.kind);
14777
+ if (own === void 0) throw new Error(`invalid target '${target.id}': unknown kind '${target.kind}'`);
14778
+ await own.handle.put({
14779
+ id: target.id,
14780
+ data: targetBody(target)
14781
+ });
14782
+ for (const other of this.handles) if (other !== own) await other.handle.delete(target.id);
14783
+ await this.options.legacy?.delete(target.id);
14784
+ }
14785
+ async delete(id) {
14786
+ for (const { handle } of this.handles) await handle.delete(id);
14787
+ await this.options.legacy?.delete(id);
14788
+ }
14789
+ /** Flip the switch on an existing target; null when it does not exist. */
14790
+ async setEnabled(id, enabled) {
14791
+ for (const { kind, handle } of this.handles) {
14792
+ if (await handle.get(id) === null) continue;
14793
+ await handle.patch(id, { enabled });
14794
+ const row = await handle.get(id);
14795
+ return row === null ? null : targetOfRecord(this.addonId, kind, row.id, row.data);
14796
+ }
14797
+ const old = (await this.legacyRows()).find((row) => row.id === id);
14798
+ if (old === void 0) return null;
14799
+ const moved = {
14800
+ ...targetOfOld(this.addonId, old),
14801
+ enabled
14802
+ };
14803
+ await this.upsert(moved);
14804
+ return moved;
14805
+ }
14806
+ };
14807
+ /**
14808
+ * A probe's answer as the tri-state of `testInstance`: `ok` validated; a refusal that carries an HTTP
14809
+ * status is the remote saying no (`rejected`); a failure with no answer at all (the network, a missing
14810
+ * field) proves nothing about the credentials (`inconclusive`).
14811
+ */
14812
+ function probeToTestResult(probed) {
14813
+ if (probed.ok) return {
14814
+ success: true,
14815
+ status: "validated",
14816
+ ...probed.stateInstance !== void 0 ? { detail: probed.stateInstance } : {}
14817
+ };
14818
+ const error = probed.error ?? "the probe failed";
14819
+ return {
14820
+ success: false,
14821
+ status: probed.httpStatus !== void 0 ? "rejected" : "inconclusive",
14822
+ error
14823
+ };
14824
+ }
14825
+ /** The form of a kind: the adapter's own sections. */
14826
+ function formOf(descriptor) {
14827
+ return descriptor.configSchema;
14828
+ }
14829
+ /** The instance kinds of a notification provider, one per target kind: a part a composition can take. */
14830
+ function notificationTargetParts(records, spec) {
14831
+ return spec.descriptors.flatMap((descriptor) => {
14832
+ const defined = spec.kinds[notificationTargetKindName(descriptor.kind)];
14833
+ if (defined === void 0) return [];
14834
+ const canTest = spec.probe !== void 0 && spec.canProbe(descriptor.kind);
14835
+ const instances = new RecordIntegrationInstances(records, defined, {
14836
+ description: {
14837
+ role: "services",
14838
+ instanceMode: "multiple",
14839
+ kind: "target",
14840
+ canTest
14841
+ },
14842
+ formSchema: () => formOf(descriptor),
14843
+ initialBody: (name) => ({ name }),
14844
+ mintId: () => spec.mintId(),
14845
+ configured: () => true,
14846
+ test: async ({ id, values }) => {
14847
+ if (spec.probe === void 0 || !canTest) return {
14848
+ success: false,
14849
+ status: "unsupported",
14850
+ error: "This kind cannot be tested"
14851
+ };
14852
+ return probeToTestResult(await spec.probe({
14853
+ kind: descriptor.kind,
14854
+ config: values,
14855
+ targetId: id ?? void 0
14856
+ }));
14857
+ }
14858
+ });
14859
+ return [{
14860
+ kind: {
14861
+ id: descriptor.kind,
14862
+ label: descriptor.label,
14863
+ functionCap: NOTIFICATION_OUTPUT_CAP,
14864
+ role: "services",
14865
+ canTest
14866
+ },
14867
+ provider: instances.toProvider()
14868
+ }];
14869
+ });
14870
+ }
14871
+ TargetSchema.omit({ id: true });
14111
14872
  var EncodeProfileSchema = object({
14112
14873
  video: object({
14113
14874
  codec: _enum([
@@ -22433,7 +23194,6 @@ var SYSTEM_CAP_VOCABULARY = {
22433
23194
  "calendar-source": system(INTERNAL),
22434
23195
  calendars: system("calendars feed schedules and conditions, not device actions"),
22435
23196
  composer: system("device composition — an authoring tool, not an action"),
22436
- "connection-test": system(INTERNAL),
22437
23197
  "core-blocks": system(INTERNAL),
22438
23198
  "custom-model-registry": system(ML),
22439
23199
  "data-store-provider": system(INTERNAL),
@@ -44410,449 +45170,6 @@ var UnitWarningSchema = object({
44410
45170
  assumedUnit: string(),
44411
45171
  message: string()
44412
45172
  });
44413
- /**
44414
- * notification-output — canonical, capability-gated notification delivery.
44415
- *
44416
- * Apprise-derived model (see
44417
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
44418
- * callers emit ONE canonical `Notification`; each provider declares a
44419
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
44420
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
44421
- * message to what the kind supports — callers never special-case a service.
44422
- *
44423
- * DESIGN DECISIONS (locked):
44424
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
44425
- * `setTargetEnabled`), each provider persisting via the `settings-store`
44426
- * cap. Rationale: the admin UI needs one uniform surface across the
44427
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
44428
- * alternative would fork the UI per addon and cannot host the
44429
- * discovery→adopt flow.
44430
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
44431
- * the generated cap-mount auto-`concatCollection`-fans them across every
44432
- * registered provider (notifiers addon + HA addon) so one catalog is
44433
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
44434
- * `addonId` the generated collection router extracts from the call input.
44435
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
44436
- * transformer) + UDS MsgPack both round-trip typed arrays — already used by
44437
- * `storage` / `storage-provider` / `recording` caps over the same path. No
44438
- * base64 fallback needed.
44439
- *
44440
- * TODO (deferred, closed-set change — separate decision): add
44441
- * `providerKind: 'notify'` so notification providers surface on the unified
44442
- * admin "Integrations" page.
44443
- */
44444
- /**
44445
- * Zentik-derived typed-media enum — the superset across every kind. Each
44446
- * adapter picks what it supports and the degrade engine filters the rest.
44447
- */
44448
- var AttachmentMediaTypeSchema = _enum([
44449
- "image",
44450
- "video",
44451
- "gif",
44452
- "audio",
44453
- "icon"
44454
- ]);
44455
- /**
44456
- * A single attachment. Exactly one of `url` (remote source, most adapters
44457
- * prefer this) or `bytes` (inline source; required for Pushover-style
44458
- * bytes-only kinds) MUST be present — the degrade engine expresses a
44459
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
44460
- */
44461
- var AttachmentSchema = object({
44462
- mediaType: AttachmentMediaTypeSchema,
44463
- url: string().optional(),
44464
- bytes: _instanceof(Uint8Array).optional(),
44465
- mime: string().optional(),
44466
- name: string().optional()
44467
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
44468
- var NotificationFormatSchema = _enum([
44469
- "text",
44470
- "markdown",
44471
- "html"
44472
- ]);
44473
- /**
44474
- * The CLOSED icon vocabulary an action button may use.
44475
- *
44476
- * A closed set, not a free string, and that is the whole point: an arbitrary
44477
- * icon name is one that ntfy renders, zentik silently drops, and nobody
44478
- * notices — the same class of gap as a zone vocabulary nothing produced
44479
- * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
44480
- * declares `actionIcons: false` and the degrade engine strips the field.
44481
- *
44482
- * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
44483
- * renderer's icon set; "acknowledge" survives an adapter that draws it
44484
- * differently.
44485
- *
44486
- * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
44487
- *
44488
- * These names are for US. **No adapter may forward one verbatim.** Each maps
44489
- * the whole set onto its own renderer's vocabulary through a
44490
- * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
44491
- * fallback, so adding a member here fails every adapter's build until someone
44492
- * decides its glyph, which is the only place that decision can be made
44493
- * honestly.
44494
- *
44495
- * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
44496
- * `disarm` straight through; iOS feeds that string to
44497
- * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
44498
- * and every snooze and alarm button arrived BLANK. A pass-through is not a
44499
- * mapping, and "the field is documented" is not "the value renders".
44500
- *
44501
- * Adding a member is TRAIN-BOUND. The enum lives in the published
44502
- * `@camstack/server` closure and the cap seam validates against the HUB's copy,
44503
- * so an addon that emits a token the running hub does not know does not lose an
44504
- * icon — its whole `send` fails Zod validation and the notification never
44505
- * arrives. Never emit a new token from an addon before the train carrying it.
44506
- */
44507
- var NotificationActionIconSchema = _enum([
44508
- "acknowledge",
44509
- "dismiss",
44510
- "silence",
44511
- "snooze",
44512
- "view",
44513
- "play",
44514
- "open",
44515
- "close",
44516
- "lock",
44517
- "unlock",
44518
- "arm",
44519
- "arm-home",
44520
- "arm-away",
44521
- "arm-night",
44522
- "disarm",
44523
- "light",
44524
- "alert",
44525
- "camera"
44526
- ]);
44527
- /** A single tap-through action button. */
44528
- var NotificationActionSchema = object({
44529
- id: string(),
44530
- label: string(),
44531
- url: string().optional(),
44532
- /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
44533
- icon: NotificationActionIconSchema.optional(),
44534
- /**
44535
- * Renders in a warning style where the notifier supports it.
44536
- *
44537
- * A HINT, never a gate. The callback's authority is its token and nothing
44538
- * else — see `notification-center/action-token.ts` for what that does and
44539
- * does not buy.
44540
- */
44541
- destructive: boolean().optional(),
44542
- /**
44543
- * How the tap should REACH the url.
44544
- *
44545
- * `navigate` (absent, and every button authored before this field) opens it:
44546
- * the phone leaves the notification and shows whatever the callback returns.
44547
- * That is right for a button whose answer the operator wants to read.
44548
- *
44549
- * `background` fires it as a POST and stays put. It exists for the buttons
44550
- * whose whole point is not to interrupt — "silence this for 30 minutes" is
44551
- * an answer to the notification, and being thrown into a browser tab to
44552
- * confirm it costs more attention than the notification did. A backend that
44553
- * cannot do a background call renders it as an ordinary link (the adapters
44554
- * fall back rather than dropping the button), so this is a preference, never
44555
- * a requirement.
44556
- */
44557
- mode: _enum(["navigate", "background"]).optional()
44558
- });
44559
- /** A delivery parameter value: what a notifier declared it takes. */
44560
- var DeliveryParamValueSchema = union([
44561
- string(),
44562
- number$1(),
44563
- boolean()
44564
- ]);
44565
- /**
44566
- * One extra delivery parameter a notifier KIND declares (D938). A rule that
44567
- * addresses a target of that kind may set it; a kind that declares nothing is
44568
- * never handed one. The admin UI derives its control from this descriptor —
44569
- * no editor knows a service by name.
44570
- *
44571
- * Optional fields are `.optional()`, never `.default()` (a Zod default does not
44572
- * run on the addon cap path).
44573
- */
44574
- var DeliveryParamSchema = object({
44575
- key: string().min(1),
44576
- label: string(),
44577
- /** i18n key of the label, resolved by the declaring addon's namespace. */
44578
- labelKey: string().optional(),
44579
- type: _enum([
44580
- "enum",
44581
- "number",
44582
- "boolean",
44583
- "string"
44584
- ]),
44585
- /** `enum` only: the closed set of accepted values. */
44586
- options: array(object({
44587
- value: string(),
44588
- label: string()
44589
- })).optional(),
44590
- /** `number` only. */
44591
- min: number$1().optional(),
44592
- max: number$1().optional(),
44593
- /** What the notifier does when the rule sets nothing; shown as the placeholder. */
44594
- default: DeliveryParamValueSchema.optional()
44595
- });
44596
- /**
44597
- * The canonical notification. `body` is the only hard field (Apprise model).
44598
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
44599
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
44600
- * the adapter maps this ordinal onto its native level. `level?` is an
44601
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
44602
- * `priority` for that one target.
44603
- */
44604
- var NotificationSchema = object({
44605
- body: string(),
44606
- title: string().optional(),
44607
- format: NotificationFormatSchema.default("text"),
44608
- priority: number$1().int().min(1).max(5).default(3),
44609
- level: string().optional(),
44610
- attachments: array(AttachmentSchema).optional(),
44611
- clickUrl: string().optional(),
44612
- actions: array(NotificationActionSchema).optional(),
44613
- sound: string().optional(),
44614
- ttl: number$1().optional(),
44615
- tag: string().optional(),
44616
- deviceId: number$1().optional(),
44617
- eventId: string().optional(),
44618
- metadata: record(string(), unknown()).optional(),
44619
- /**
44620
- * Values for the parameters the target's kind DECLARES in
44621
- * {@link TargetKindSchema} `deliveryParams`. Already validated against the
44622
- * declaration by the sender; a notifier reads only the keys it declared.
44623
- */
44624
- delivery: record(string(), DeliveryParamValueSchema).optional()
44625
- });
44626
- /** One declared native severity/priority level for a kind. */
44627
- var TargetKindLevelSchema = object({
44628
- id: string(),
44629
- label: string(),
44630
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
44631
- ordinal: number$1().int().min(1).max(5).nullable(),
44632
- flags: object({
44633
- critical: boolean().optional(),
44634
- silent: boolean().optional(),
44635
- noPush: boolean().optional()
44636
- }).optional(),
44637
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
44638
- requires: array(string()).optional(),
44639
- description: string().optional()
44640
- });
44641
- /** The full capability block consulted before dispatch. */
44642
- var TargetKindCapsSchema = object({
44643
- attachments: object({
44644
- mediaTypes: array(AttachmentMediaTypeSchema),
44645
- mode: _enum([
44646
- "url",
44647
- "bytes",
44648
- "both"
44649
- ]),
44650
- max: number$1().int().nonnegative(),
44651
- maxBytes: number$1().int().positive().optional()
44652
- }),
44653
- /** Max action buttons (0 = none). */
44654
- actions: number$1().int().nonnegative(),
44655
- /**
44656
- * Whether this kind renders a per-action ICON.
44657
- *
44658
- * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
44659
- * run on the addon cap path — three production failures in one day taught
44660
- * this repo that once. Absent is read as false by the degrade engine, which
44661
- * is the safe direction: an icon that is not rendered costs nothing, an icon
44662
- * assumed and dropped costs the operator's trust in the field.
44663
- */
44664
- actionIcons: boolean().optional(),
44665
- levels: array(TargetKindLevelSchema),
44666
- format: array(NotificationFormatSchema),
44667
- clickUrl: boolean(),
44668
- sound: boolean(),
44669
- ttl: boolean(),
44670
- bodyMaxLen: number$1().int().positive()
44671
- });
44672
- /**
44673
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
44674
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
44675
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
44676
- * the union is large and not meant for runtime validation here; the exported
44677
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
44678
- */
44679
- var ConfigSchemaPassthrough$4 = unknown();
44680
- var TargetKindSchema = object({
44681
- kind: string(),
44682
- label: string(),
44683
- icon: string(),
44684
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
44685
- addonId: string(),
44686
- /**
44687
- * URL of the kind's bundled BRAND icon, served by the providing addon over
44688
- * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
44689
- * when the addon bundles no icon for that kind — the client then falls back
44690
- * to a neutral glyph rather than rendering the raw `icon` NAME as text.
44691
- *
44692
- * Root-relative on purpose: it resolves against whatever origin serves a web
44693
- * client, and a native client joins it onto its own hub base.
44694
- *
44695
- * DECLARED here deliberately. It used to travel as an undeclared passthrough
44696
- * field that survived only because the runtime cap-router forwards provider
44697
- * output verbatim — so every consumer had to re-declare it by hand to stop
44698
- * its own Zod parse from stripping it, and the whole arrangement would have
44699
- * broken silently the moment output validation was tightened anywhere.
44700
- */
44701
- iconUrl: string().optional(),
44702
- /**
44703
- * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
44704
- *
44705
- * The server knows this and therefore says it, because the client cannot
44706
- * safely guess: a React-Native client renders SVG and raster through two
44707
- * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
44708
- * not decode SVG on iOS/Android), so without this it silently fell back to a
44709
- * placeholder glyph for every vector icon while the web build looked fine.
44710
- *
44711
- * Absent when {@link iconUrl} is absent, or for a legacy provider that has
44712
- * not been updated — a client that cannot determine the type should prefer
44713
- * its raster path, which is the safe default for an unknown image.
44714
- */
44715
- iconMediaType: string().optional(),
44716
- configSchema: ConfigSchemaPassthrough$4,
44717
- /**
44718
- * The config keys that decide WHERE this kind's secrets are sent (server,
44719
- * base URL, instance, chat, recipient). A save that changes any of them
44720
- * while leaving a secret redacted is refused by the provider (D634), so an
44721
- * editor uses this to ask for the secret on exactly those edits instead of
44722
- * on every edit.
44723
- *
44724
- * Optional: absent from a provider that holds no secret (the Home Assistant
44725
- * kinds) and from a provider older than this field — an editor that finds
44726
- * it absent must assume ANY edit may need the secret.
44727
- */
44728
- destinationKeys: array(string()).optional(),
44729
- supportsDiscovery: boolean(),
44730
- /**
44731
- * Extra delivery parameters a rule may set for a target of this kind (D938).
44732
- * Absent = none; the rule editor then offers nothing.
44733
- */
44734
- deliveryParams: array(DeliveryParamSchema).optional(),
44735
- caps: TargetKindCapsSchema
44736
- });
44737
- /**
44738
- * A persisted target. `config` holds secrets; providers REDACT secret fields
44739
- * (return a presence marker only) when serving `listTargets` — never
44740
- * round-trip a stored secret to the UI.
44741
- */
44742
- var TargetSchema = object({
44743
- id: string(),
44744
- name: string(),
44745
- kind: string(),
44746
- addonId: string(),
44747
- enabled: boolean(),
44748
- config: record(string(), unknown())
44749
- });
44750
- /** A discovery-surfaced candidate (config is partial + non-secret). */
44751
- var DiscoveredTargetSchema = object({
44752
- kind: string(),
44753
- suggestedName: string(),
44754
- config: record(string(), unknown())
44755
- });
44756
- /** The degrade engine's report — what was resolved / dropped / degraded. */
44757
- var RenderedAsSchema = object({
44758
- level: string(),
44759
- format: NotificationFormatSchema,
44760
- attachmentsSent: number$1().int().nonnegative(),
44761
- actionsSent: number$1().int().nonnegative(),
44762
- truncated: boolean(),
44763
- dropped: array(string())
44764
- });
44765
- var SendResultSchema = object({
44766
- success: boolean(),
44767
- error: string().optional(),
44768
- renderedAs: RenderedAsSchema.optional()
44769
- });
44770
- /** Same shape as SendResult — kept as a distinct name for the test panel. */
44771
- var TestResultSchema = SendResultSchema;
44772
- /**
44773
- * Pre-save credential probe against a partial (unsaved) target config.
44774
- * `resolvedUrl` never includes secrets (token stripped from path).
44775
- */
44776
- var ProbeTargetConfigResultSchema = object({
44777
- ok: boolean(),
44778
- resolvedUrl: string(),
44779
- httpStatus: number$1().int().optional(),
44780
- stateInstance: string().optional(),
44781
- error: string().optional()
44782
- });
44783
- var notificationOutputCapability = {
44784
- name: "notification-output",
44785
- scope: "system",
44786
- mode: "collection",
44787
- methods: {
44788
- listTargetKinds: method(object({}), array(TargetKindSchema)),
44789
- listTargets: method(object({}), array(TargetSchema), { auth: "admin" }),
44790
- /**
44791
- * One target WITH secrets — admin edit / duplicate hydration only.
44792
- * `listTargets` stays redacted; the editor loads this so password fields
44793
- * are filled (masked in the input) and a save can round-trip the token
44794
- * when a destination changes (Duplicate with a new chatId, etc.).
44795
- */
44796
- getTarget: method(object({ targetId: string() }), TargetSchema, { auth: "admin" }),
44797
- discoverTargets: method(object({
44798
- kind: string(),
44799
- config: record(string(), unknown()).optional(),
44800
- /**
44801
- * When editing / duplicating, the blank password field never round-trips
44802
- * the stored token. Pass the source target id so the provider can restore
44803
- * secrets server-side before dialling (same merge as upsert).
44804
- */
44805
- targetId: string().optional()
44806
- }), array(DiscoveredTargetSchema), { auth: "admin" }),
44807
- probeTargetConfig: method(object({
44808
- kind: string(),
44809
- config: record(string(), unknown()),
44810
- /** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
44811
- targetId: string().optional()
44812
- }), ProbeTargetConfigResultSchema, {
44813
- kind: "mutation",
44814
- auth: "admin"
44815
- }),
44816
- send: method(object({
44817
- targetId: string(),
44818
- notification: NotificationSchema
44819
- }), SendResultSchema, {
44820
- kind: "mutation",
44821
- auth: "admin"
44822
- }),
44823
- testTarget: method(object({
44824
- targetId: string(),
44825
- sample: NotificationSchema.optional()
44826
- }), TestResultSchema, {
44827
- kind: "mutation",
44828
- auth: "admin"
44829
- }),
44830
- upsertTarget: method(object({
44831
- target: TargetSchema,
44832
- /**
44833
- * Duplicate flow: create a NEW target id while copying secrets from an
44834
- * existing row. The UI never holds the stored token; blank / redacted
44835
- * password fields are restored from this source (D634 still applies if
44836
- * destinations diverge without a re-typed secret).
44837
- */
44838
- cloneSecretsFrom: string().optional()
44839
- }), TargetSchema, {
44840
- kind: "mutation",
44841
- auth: "admin"
44842
- }),
44843
- deleteTarget: method(object({ targetId: string() }), _void(), {
44844
- kind: "mutation",
44845
- auth: "admin"
44846
- }),
44847
- setTargetEnabled: method(object({
44848
- targetId: string(),
44849
- enabled: boolean()
44850
- }), _void(), {
44851
- kind: "mutation",
44852
- auth: "admin"
44853
- })
44854
- }
44855
- };
44856
45173
  _enum([
44857
45174
  "automation",
44858
45175
  "action",
@@ -49456,37 +49773,6 @@ method(object({
49456
49773
  kind: "mutation",
49457
49774
  auth: "admin"
49458
49775
  }), method(object({ blockIds: array(string()) }), object({ states: array(CompositionBlockStateSchema) }), { auth: "admin" });
49459
- var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
49460
- object({
49461
- outcome: literal("validated"),
49462
- /** Round-trip of the sign-in, when the provider measured it. */
49463
- latencyMs: number$1().nonnegative().optional(),
49464
- /** Optional human detail worth showing next to the tick
49465
- * ("3 devices visible on this account"). */
49466
- detail: string().optional()
49467
- }).strict(),
49468
- object({
49469
- outcome: literal("rejected"),
49470
- error: string()
49471
- }).strict(),
49472
- object({
49473
- outcome: literal("inconclusive"),
49474
- error: string()
49475
- }).strict()
49476
- ]);
49477
- var ConnectionTestInputSchema = object({
49478
- /** Candidate integration settings, exactly as the create form collected them. */
49479
- settings: record(string(), unknown()) });
49480
- /**
49481
- * What the provider's test actually DOES, so the UI can say it in words before
49482
- * the operator presses the button ("Signs in to the Dreo cloud"). Purely
49483
- * descriptive — it never changes routing.
49484
- */
49485
- var ConnectionTestDescriptorSchema = object({ label: string() });
49486
- method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
49487
- kind: "mutation",
49488
- auth: "admin"
49489
- }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
49490
49776
  /**
49491
49777
  * Upstream-system connectivity sensor — distinct from `device-status`,
49492
49778
  * which is the kernel-managed online/offline flag for the device's
@@ -56640,10 +56926,9 @@ var AvailableIntegrationTypeSchema = object({
56640
56926
  * locations" checkbox. Provider-declared in the addon manifest. */
56641
56927
  supportsLocationImport: boolean(),
56642
56928
  /**
56643
- * True when this integration DECLARES a pre-creation test (the
56644
- * `connection-test` cap, or a broker whose settings it stores). Drives the
56645
- * Test button: an integration that cannot be tested must say so up front
56646
- * rather than offering a button that always answers the same nonsense.
56929
+ * True when this integration's instances declare a Test (`integration-instances.describe().canTest`,
56930
+ * D1016). An integration that cannot be tested must say so up front rather than offering a
56931
+ * button that always answers the same nonsense.
56647
56932
  */
56648
56933
  canTest: boolean(),
56649
56934
  existingInstances: array(object({
@@ -56697,7 +56982,9 @@ var CreateIntegrationInputSchema = object({
56697
56982
  info: record(string(), unknown()).optional(),
56698
56983
  /** `stableId` of the discovery candidate the form was seeded from (D984). */
56699
56984
  originStableId: string().optional(),
56700
- settings: record(string(), unknown()).optional()
56985
+ settings: record(string(), unknown()).optional(),
56986
+ /** The instance kind being created, for a provider with several; omitted = its only testable kind. */
56987
+ kind: string().min(1).optional()
56701
56988
  });
56702
56989
  var UpdateIntegrationInputSchema = object({
56703
56990
  id: string(),
@@ -56723,13 +57010,7 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
56723
57010
  }), method(object({ id: string() }), DeleteIntegrationResultSchema, {
56724
57011
  kind: "mutation",
56725
57012
  auth: "admin"
56726
- }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" }), method(object({
56727
- addonId: string(),
56728
- settings: record(string(), unknown())
56729
- }), TestConnectionResultSchema, {
56730
- kind: "mutation",
56731
- auth: "admin"
56732
- });
57013
+ }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" });
56733
57014
  /**
56734
57015
  * `integration-instances` - the connection of an addon that holds ONE or SEVERAL
56735
57016
  * (an account, a relay, a destination) as typed records (D985, D986). The addon's
@@ -65472,13 +65753,6 @@ var AddBrokerInputSchema = object({
65472
65753
  });
65473
65754
  var AddBrokerResultSchema = object({ id: string() });
65474
65755
  var IdInputSchema = object({ id: string() });
65475
- var TestResultSchema$1 = discriminatedUnion("ok", [object({
65476
- ok: literal(true),
65477
- latencyMs: number$1()
65478
- }), object({
65479
- ok: literal(false),
65480
- error: string()
65481
- })]);
65482
65756
  var StartEmbeddedInputSchema = object({
65483
65757
  port: number$1().int().min(1).max(65535).default(1883),
65484
65758
  /** Allow anonymous connect (no username/password). Default: false. */
@@ -65501,9 +65775,6 @@ method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnection
65501
65775
  }), method(IdInputSchema, _void(), {
65502
65776
  kind: "mutation",
65503
65777
  auth: "admin"
65504
- }), method(IdInputSchema, TestResultSchema$1, {
65505
- kind: "mutation",
65506
- auth: "admin"
65507
65778
  }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, {
65508
65779
  kind: "mutation",
65509
65780
  auth: "admin"
@@ -78610,7 +78881,7 @@ var TrainingProviderTypeSchema = object({
78610
78881
  label: string(),
78611
78882
  /** Path under the addon package (e.g. `assets/training-modal/icon.svg`). */
78612
78883
  iconAsset: string(),
78613
- /** Owning `training-provider` addon — collection selector for createEntry. */
78884
+ /** Owning `training-provider` addon. */
78614
78885
  addonId: string(),
78615
78886
  /**
78616
78887
  * Fields for one entry of this type (credentials + defaults). Absent on a
@@ -78655,36 +78926,6 @@ var TrainingProviderDescriptorSchema = object({
78655
78926
  */
78656
78927
  formValues: record(string(), unknown()).optional()
78657
78928
  });
78658
- /**
78659
- * Patch one entry's name, credentials and defaults. Pin with owning `{ addonId }`.
78660
- * An omitted or empty `tokenSecret` means KEEP the stored secret (D787).
78661
- * `{ $secretClear: true }` clears it.
78662
- */
78663
- var TrainingProviderUpdateEntryInputSchema = object({
78664
- entryId: string().min(1),
78665
- name: string().min(1),
78666
- tokenId: string().optional(),
78667
- tokenSecret: union([string(), object({ $secretClear: literal(true) })]).optional(),
78668
- environment: string().optional(),
78669
- gpuType: string().optional(),
78670
- region: string().optional(),
78671
- costCapUsd: number$1().nonnegative().optional(),
78672
- maxRuntimeSec: number$1().int().positive().optional(),
78673
- failedDatasetGraceHours: number$1().nonnegative().optional()
78674
- });
78675
- var TrainingCredentialCheckSchema = object({
78676
- ok: boolean(),
78677
- /** Workspace / account name, when the provider says. */
78678
- account: string().optional(),
78679
- reason: _enum([
78680
- "missing",
78681
- "invalid",
78682
- "forbidden",
78683
- "network",
78684
- "quota"
78685
- ]).optional(),
78686
- detail: string().optional()
78687
- });
78688
78929
  var ProviderJobStateSchema = _enum([
78689
78930
  "pending",
78690
78931
  "starting",
@@ -78732,22 +78973,6 @@ var TrainingLogLineSchema = object({
78732
78973
  text: string()
78733
78974
  });
78734
78975
  method(object({}), array(TrainingProviderDescriptorSchema).readonly(), { auth: "admin" }), method(object({}), array(TrainingProviderTypeSchema).readonly(), { auth: "admin" }), method(object({}), array(TrainingProviderEntrySchema).readonly(), { auth: "admin" }), method(object({
78735
- typeId: string().min(1),
78736
- name: string().min(1)
78737
- }), object({ entryId: string() }), {
78738
- kind: "mutation",
78739
- auth: "admin"
78740
- }), method(object({ entryId: string().min(1) }), object({ ok: literal(true) }), {
78741
- kind: "mutation",
78742
- auth: "admin"
78743
- }), method(TrainingProviderUpdateEntryInputSchema, object({ ok: literal(true) }), {
78744
- kind: "mutation",
78745
- auth: "admin"
78746
- }), method(object({ entryId: string().min(1) }), TrainingCredentialCheckSchema, {
78747
- kind: "mutation",
78748
- auth: "admin",
78749
- timeoutMs: 3e4
78750
- }), method(object({
78751
78976
  entryId: string().min(1),
78752
78977
  gpuType: string(),
78753
78978
  region: string(),
@@ -84108,20 +84333,6 @@ Object.freeze({
84108
84333
  access: "view",
84109
84334
  role: "admin"
84110
84335
  },
84111
- "connectionTest.describeTest": {
84112
- capName: "connection-test",
84113
- capScope: "system",
84114
- addonId: null,
84115
- access: "view",
84116
- role: "admin"
84117
- },
84118
- "connectionTest.testSettings": {
84119
- capName: "connection-test",
84120
- capScope: "system",
84121
- addonId: null,
84122
- access: "create",
84123
- role: "admin"
84124
- },
84125
84336
  "connectivity.getStatus": {
84126
84337
  capName: "connectivity",
84127
84338
  capScope: "device",
@@ -86488,13 +86699,6 @@ Object.freeze({
86488
86699
  access: "view",
86489
86700
  role: "admin"
86490
86701
  },
86491
- "integrations.testConnection": {
86492
- capName: "integrations",
86493
- capScope: "system",
86494
- addonId: null,
86495
- access: "create",
86496
- role: "admin"
86497
- },
86498
86702
  "integrations.update": {
86499
86703
  capName: "integrations",
86500
86704
  capScope: "system",
@@ -87727,13 +87931,6 @@ Object.freeze({
87727
87931
  access: "create",
87728
87932
  role: "admin"
87729
87933
  },
87730
- "mqttBroker.testConnection": {
87731
- capName: "mqtt-broker",
87732
- capScope: "system",
87733
- addonId: null,
87734
- access: "create",
87735
- role: "admin"
87736
- },
87737
87934
  "nativeObjectDetection.getOptions": {
87738
87935
  capName: "native-object-detection",
87739
87936
  capScope: "device",
@@ -92564,20 +92761,6 @@ Object.freeze({
92564
92761
  access: "create",
92565
92762
  role: "admin"
92566
92763
  },
92567
- "trainingProvider.createEntry": {
92568
- capName: "training-provider",
92569
- capScope: "system",
92570
- addonId: null,
92571
- access: "create",
92572
- role: "admin"
92573
- },
92574
- "trainingProvider.deleteEntry": {
92575
- capName: "training-provider",
92576
- capScope: "system",
92577
- addonId: null,
92578
- access: "delete",
92579
- role: "admin"
92580
- },
92581
92764
  "trainingProvider.describe": {
92582
92765
  capName: "training-provider",
92583
92766
  capScope: "system",
@@ -92648,13 +92831,6 @@ Object.freeze({
92648
92831
  access: "create",
92649
92832
  role: "admin"
92650
92833
  },
92651
- "trainingProvider.updateEntry": {
92652
- capName: "training-provider",
92653
- capScope: "system",
92654
- addonId: null,
92655
- access: "create",
92656
- role: "admin"
92657
- },
92658
92834
  "trainingProvider.uploadDataset": {
92659
92835
  capName: "training-provider",
92660
92836
  capScope: "system",
@@ -92662,13 +92838,6 @@ Object.freeze({
92662
92838
  access: "create",
92663
92839
  role: "admin"
92664
92840
  },
92665
- "trainingProvider.validateCredentials": {
92666
- capName: "training-provider",
92667
- capScope: "system",
92668
- addonId: null,
92669
- access: "create",
92670
- role: "admin"
92671
- },
92672
92841
  "turnProvider.getTurnServers": {
92673
92842
  capName: "turn-provider",
92674
92843
  capScope: "system",
@@ -145218,452 +145387,6 @@ function regionOfDomain(domain) {
145218
145387
  if (tail === "amazon.co.jp" || tail === "amazon.com.au" || tail === "amazon.in") return "FE";
145219
145388
  return "EU";
145220
145389
  }
145221
- process.platform, process.env["SystemRoot"];
145222
- /**
145223
- * Files only their owner may read: the one place a secret-bearing file is
145224
- * restricted, on every OS.
145225
- *
145226
- * `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
145227
- * attribute and nothing else, and `chmod` is a no-op for permissions, so a
145228
- * "0600" `agent.json` (the mesh private key) would have
145229
- * been readable by every local user. Windows has its own tool for this:
145230
- * `icacls`. This module removes the inherited ACL entries and grants access to
145231
- * the current user and SYSTEM only — the Windows reading of "0600".
145232
- *
145233
- * ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
145234
- * this single file (see `packages/desktop/tsup.config.ts`) instead of the
145235
- * whole `@camstack/types` barrel.
145236
- *
145237
- * Failure is NOT swallowed. A secret file that could not be restricted is a
145238
- * secret file the caller must know about, so `OwnerOnlyError` propagates; the
145239
- * write order below makes sure nothing sensitive is in the file yet when that
145240
- * happens (the temp file is empty until it has been restricted).
145241
- *
145242
- * Out of scope on purpose: files that are only tidy, not secret (the job
145243
- * journal, the addon manifest) keep a plain `mode`, since one `icacls` per
145244
- * write would be a process spawn on a hot path.
145245
- */
145246
- /** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
145247
- var WINDOWS_SYSTEM_SID = "*S-1-5-18";
145248
- var FILE_MODE = 384;
145249
- var DIR_MODE = 448;
145250
- var OwnerOnlyError = class extends Error {
145251
- target;
145252
- constructor(target, detail) {
145253
- super(`could not restrict ${target} to its owner: ${detail}`);
145254
- this.target = target;
145255
- this.name = "OwnerOnlyError";
145256
- }
145257
- };
145258
- function currentPrincipal() {
145259
- const user = process.env["USERNAME"] ?? (0, node_os.userInfo)().username;
145260
- const domain = process.env["USERDOMAIN"];
145261
- return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
145262
- }
145263
- /** Resolved per call, not at import: a platform stubbed by a test must be seen. */
145264
- function resolveDeps(overrides) {
145265
- return {
145266
- platform: process.platform,
145267
- exec: (file, args) => {
145268
- (0, node_child_process.execFileSync)(file, [...args], {
145269
- stdio: "pipe",
145270
- windowsHide: true
145271
- });
145272
- },
145273
- principal: currentPrincipal,
145274
- systemRoot: process.env["SystemRoot"],
145275
- ...overrides
145276
- };
145277
- }
145278
- /** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
145279
- function icaclsArgs(target, principal, isDirectory) {
145280
- const inherit = isDirectory ? "(OI)(CI)" : "";
145281
- return [
145282
- target,
145283
- "/inheritance:r",
145284
- "/grant:r",
145285
- `${principal}:${inherit}F`,
145286
- "/grant:r",
145287
- `${WINDOWS_SYSTEM_SID}:${inherit}F`
145288
- ];
145289
- }
145290
- function restrictWindows(target, isDirectory, deps) {
145291
- const icacls = node_path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
145292
- try {
145293
- deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
145294
- } catch (err) {
145295
- throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
145296
- }
145297
- }
145298
- /** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
145299
- function restrictFileToOwnerSync(target, overrides = {}) {
145300
- const deps = resolveDeps(overrides);
145301
- if (deps.platform === "win32") {
145302
- restrictWindows(target, false, deps);
145303
- return;
145304
- }
145305
- node_fs.chmodSync(target, FILE_MODE);
145306
- }
145307
- /** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
145308
- function restrictDirToOwnerSync(target, overrides = {}) {
145309
- const deps = resolveDeps(overrides);
145310
- if (deps.platform === "win32") {
145311
- restrictWindows(target, true, deps);
145312
- return;
145313
- }
145314
- node_fs.chmodSync(target, DIR_MODE);
145315
- }
145316
- /**
145317
- * Atomically write a secret-bearing file that only its owner can read.
145318
- *
145319
- * Order matters: the temp file is created EMPTY, restricted, and only then
145320
- * given its content, so there is no moment at which the secret exists under a
145321
- * permissive ACL. The rename carries the (already restricted) ACL with it.
145322
- */
145323
- function writeOwnerOnlyFileSync(target, data, overrides = {}) {
145324
- node_fs.mkdirSync(node_path$1.dirname(target), { recursive: true });
145325
- const tmp = `${target}.tmp.${process.pid}`;
145326
- node_fs.writeFileSync(tmp, "", { mode: FILE_MODE });
145327
- try {
145328
- restrictFileToOwnerSync(tmp, overrides);
145329
- node_fs.writeFileSync(tmp, data);
145330
- node_fs.renameSync(tmp, target);
145331
- } catch (err) {
145332
- node_fs.rmSync(tmp, { force: true });
145333
- throw err;
145334
- }
145335
- }
145336
- //#endregion
145337
- //#region src/speak/secret-file.ts
145338
- /**
145339
- * Hand a refresh token or a saved Amazon session to the Python worker as an
145340
- * owner-only file. The path travels on stdin. The bytes never travel on
145341
- * argv or in the environment, and they are removed when the call settles.
145342
- */
145343
- var REDACTED = "‹redacted›";
145344
- function decodeSession(sessionB64) {
145345
- if (sessionB64.length === 0) return Buffer.alloc(0);
145346
- return Buffer.from(sessionB64, "base64");
145347
- }
145348
- async function readSessionB64(sessionPath) {
145349
- const bytes = await node_fs_promises.readFile(sessionPath);
145350
- if (bytes.length === 0) return "";
145351
- return bytes.toString("base64");
145352
- }
145353
- /**
145354
- * Create a 0700 directory, write the refresh token and the session 0600, run
145355
- * `body`, then delete the directory whether `body` succeeded or threw.
145356
- * Returns the session file as base64 so the caller can store it in the keychain.
145357
- */
145358
- async function withSpeakSecretFiles(input, body) {
145359
- const dir = await node_fs_promises.mkdtemp(node_path.join(node_os.tmpdir(), "camstack-alexa-"));
145360
- const paths = {
145361
- dir,
145362
- refreshTokenPath: node_path.join(dir, "refresh-token"),
145363
- sessionPath: node_path.join(dir, "session")
145364
- };
145365
- try {
145366
- restrictDirToOwnerSync(dir);
145367
- writeOwnerOnlyFileSync(paths.refreshTokenPath, input.refreshToken);
145368
- writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
145369
- await body(paths);
145370
- return { sessionB64: await readSessionB64(paths.sessionPath) };
145371
- } finally {
145372
- await node_fs_promises.rm(dir, {
145373
- recursive: true,
145374
- force: true
145375
- });
145376
- }
145377
- }
145378
- /** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
145379
- function scrubSecrets(text, secrets) {
145380
- let out = text;
145381
- for (const secret of secrets) {
145382
- if (secret.length === 0) continue;
145383
- out = out.split(secret).join(REDACTED);
145384
- }
145385
- return out;
145386
- }
145387
- //#endregion
145388
- //#region src/speak/broker-echo.ts
145389
- function withDevice(deviceId) {
145390
- return deviceId === void 0 ? {} : { deviceId };
145391
- }
145392
- var BrokerEcho = class {
145393
- deps;
145394
- currentState = "starting";
145395
- currentError = null;
145396
- currentDevices = [];
145397
- backoffMs = 0;
145398
- limitedUntil = 0;
145399
- channelState = "down";
145400
- channelWasUp = false;
145401
- secrets = [];
145402
- unknownPushLogged = /* @__PURE__ */ new Set();
145403
- constructor(deps) {
145404
- this.deps = deps;
145405
- deps.worker.onPush((push) => this.onPush(push));
145406
- }
145407
- /** Whether Amazon's push channel is open for this account right now. */
145408
- get pushChannel() {
145409
- return this.channelState;
145410
- }
145411
- /** A reason is text from the library: capped, then scrubbed of the secrets in play. */
145412
- safeReason(reason) {
145413
- return scrubSecrets(reason.slice(0, 200), this.secrets);
145414
- }
145415
- onPush(push) {
145416
- const tags = { tags: { brokerId: this.deps.brokerId } };
145417
- if (push.push === "channel") {
145418
- if (push.state === this.channelState) {
145419
- if (push.state === "down") this.deps.logger.debug("alexa echo: push channel still down", {
145420
- ...tags,
145421
- meta: {
145422
- reason: this.safeReason(push.reason),
145423
- retryInMs: push.retryInMs
145424
- }
145425
- });
145426
- return;
145427
- }
145428
- this.channelState = push.state;
145429
- if (push.state === "up") {
145430
- this.deps.logger.info(this.channelWasUp ? "alexa echo: push channel reconnected, polling slows down" : "alexa echo: push channel connected, polling slows down", tags);
145431
- this.channelWasUp = true;
145432
- } else this.deps.logger.warn("alexa echo: push channel down, polling at the normal pace", {
145433
- ...tags,
145434
- meta: {
145435
- reason: this.safeReason(push.reason),
145436
- retryInMs: push.retryInMs
145437
- }
145438
- });
145439
- this.deps.push?.channel(this.deps.brokerId, push.state);
145440
- return;
145441
- }
145442
- if (PUSH_READ_COMMANDS.has(push.command)) {
145443
- if (push.serial === null) {
145444
- this.deps.logger.debug("alexa echo: push event without an Echo, ignored", {
145445
- ...tags,
145446
- meta: { command: push.command }
145447
- });
145448
- return;
145449
- }
145450
- this.deps.push?.event(this.deps.brokerId, push.serial);
145451
- return;
145452
- }
145453
- if (PUSH_IGNORED_COMMANDS.has(push.command)) return;
145454
- if (this.unknownPushLogged.has(push.command)) return;
145455
- this.unknownPushLogged.add(push.command);
145456
- this.deps.logger.info("alexa echo: unknown push event, ignored (logged once)", {
145457
- ...tags,
145458
- meta: { command: push.command }
145459
- });
145460
- }
145461
- get id() {
145462
- return this.deps.brokerId;
145463
- }
145464
- get name() {
145465
- return this.deps.brokerName;
145466
- }
145467
- get state() {
145468
- return this.currentState;
145469
- }
145470
- get error() {
145471
- return this.currentError;
145472
- }
145473
- get devices() {
145474
- return this.currentDevices;
145475
- }
145476
- /** Ms epoch until which Amazon asked us to wait (429), or null when the account is free. */
145477
- get rateLimitedUntil() {
145478
- return this.limitedUntil > this.nowMs() ? this.limitedUntil : null;
145479
- }
145480
- nowMs() {
145481
- return (this.deps.now ?? Date.now)();
145482
- }
145483
- stop() {
145484
- this.deps.worker.stop();
145485
- if (this.channelState === "up") {
145486
- this.channelState = "down";
145487
- this.deps.push?.channel(this.deps.brokerId, "down");
145488
- }
145489
- }
145490
- /** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
145491
- async signIn() {
145492
- const { brokerId, worker, sessions, logger } = this.deps;
145493
- const tags = { tags: { brokerId } };
145494
- let secrets = [];
145495
- try {
145496
- const seed = await this.deps.readSeed();
145497
- const stored = await sessions.get(brokerId);
145498
- logger.info("alexa speak: broker sign-in material", {
145499
- ...tags,
145500
- meta: {
145501
- seedFound: seed !== null,
145502
- hasRefreshToken: seed !== null && seed.refreshToken.length > 0,
145503
- hasDeviceAppName: seed !== null && seed.deviceAppName.length > 0,
145504
- hasStoredSession: stored.length > 0
145505
- }
145506
- });
145507
- if (seed === null && stored.length === 0) {
145508
- this.currentState = "broker-not-signed-in";
145509
- this.currentError = null;
145510
- logger.warn("alexa speak: the broker has no sign-in to share yet", tags);
145511
- return;
145512
- }
145513
- secrets = seed === null ? [] : [seed.refreshToken];
145514
- worker.noteSecrets(secrets);
145515
- this.secrets = secrets;
145516
- let outcome = parseLoginResult({
145517
- status: "failed",
145518
- error: "worker gave no answer"
145519
- });
145520
- const held = await withSpeakSecretFiles({
145521
- refreshToken: seed?.refreshToken ?? "",
145522
- sessionB64: stored
145523
- }, async (paths) => {
145524
- outcome = parseLoginResult(await worker.request({
145525
- op: "login_broker",
145526
- label: brokerId,
145527
- refreshTokenPath: seed === null ? "" : paths.refreshTokenPath,
145528
- deviceAppName: seed?.deviceAppName ?? "",
145529
- domain: this.deps.domain,
145530
- sessionPath: paths.sessionPath,
145531
- region: regionOfDomain(this.deps.domain)
145532
- }));
145533
- });
145534
- if (held.sessionB64.length > 0 && held.sessionB64 !== stored) await sessions.put(brokerId, held.sessionB64);
145535
- this.currentError = outcome.status === "ok" ? null : outcome.error ?? "sign-in failed";
145536
- this.currentState = outcome.status === "ok" ? "ready" : "failed";
145537
- if (outcome.status !== "ok") {
145538
- logger.warn("alexa speak: sign-in as the broker failed", {
145539
- ...tags,
145540
- meta: { error: this.currentError }
145541
- });
145542
- return;
145543
- }
145544
- await this.refreshDevices();
145545
- } catch (err) {
145546
- this.currentState = "failed";
145547
- this.currentError = scrubSecrets(err instanceof Error ? err.message : String(err), secrets);
145548
- logger.warn("alexa speak: sign-in as the broker failed", {
145549
- ...tags,
145550
- meta: { error: this.currentError }
145551
- });
145552
- }
145553
- }
145554
- /** A session that is not ready tries to sign in again before it gives up. */
145555
- async ensureReady() {
145556
- if (this.currentState !== "ready") await this.signIn();
145557
- return this.currentState === "ready";
145558
- }
145559
- async refreshDevices() {
145560
- if (this.currentState !== "ready") return;
145561
- try {
145562
- this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
145563
- } catch (err) {
145564
- this.deps.logger.warn("alexa speak: step 3 of 3, device list failed after a good sign-in", {
145565
- tags: { brokerId: this.deps.brokerId },
145566
- meta: { error: err instanceof Error ? err.message : String(err) }
145567
- });
145568
- }
145569
- }
145570
- async utter(op, targets, text) {
145571
- if (!await this.ensureReady()) throw new Error(this.notReadyReason());
145572
- return parseDeliveries(await this.deps.worker.request({
145573
- op,
145574
- targets,
145575
- text
145576
- }));
145577
- }
145578
- /**
145579
- * Run one worker call. A 429 starts (or doubles) the account's back-off and is
145580
- * rethrown as is; any answer ends it. Rate limiting is a status, not a failure.
145581
- */
145582
- async limited(call) {
145583
- try {
145584
- const result = await call();
145585
- this.backoffMs = 0;
145586
- this.limitedUntil = 0;
145587
- return result;
145588
- } catch (err) {
145589
- if (err instanceof RateLimitedError) {
145590
- this.backoffMs = this.backoffMs === 0 ? ECHO_RATE_BACKOFF_BASE_MS : Math.min(this.backoffMs * 2, ECHO_RATE_BACKOFF_MAX_MS);
145591
- this.limitedUntil = this.nowMs() + this.backoffMs;
145592
- this.deps.logger.info("alexa echo: amazon asked for a pause (429)", {
145593
- tags: { brokerId: this.deps.brokerId },
145594
- meta: { backoffMs: this.backoffMs }
145595
- });
145596
- }
145597
- throw err;
145598
- }
145599
- }
145600
- /** The raw player state of one Echo. Throws on any failure, a 429 included: never an empty answer. */
145601
- async playerState(serial, deviceType, deviceId) {
145602
- await this.requireReady();
145603
- return this.limited(async () => parsePlayerState(await this.deps.worker.request({
145604
- op: "player_state",
145605
- serial,
145606
- deviceType
145607
- }, withDevice(deviceId))));
145608
- }
145609
- async npCommand(serial, command, deviceId) {
145610
- await this.requireReady();
145611
- await this.limited(() => this.deps.worker.request({
145612
- op: "np_command",
145613
- serial,
145614
- command
145615
- }, withDevice(deviceId)));
145616
- }
145617
- async setVolume(serial, level, deviceId) {
145618
- await this.requireReady();
145619
- await this.limited(() => this.deps.worker.request({
145620
- op: "set_volume",
145621
- serial,
145622
- level
145623
- }, withDevice(deviceId)));
145624
- }
145625
- /** Do Not Disturb of every device of the account, by serial, in one call. */
145626
- async dndState() {
145627
- await this.requireReady();
145628
- return this.limited(async () => parseDndState(await this.deps.worker.request({ op: "dnd_state" })));
145629
- }
145630
- /** Speaker volume and mute of every device of the account, by serial, in one call. */
145631
- async deviceVolumes() {
145632
- await this.requireReady();
145633
- return this.limited(async () => parseDeviceVolumes(await this.deps.worker.request({ op: "device_volumes" })));
145634
- }
145635
- async setDnd(serial, enabled, deviceId) {
145636
- await this.requireReady();
145637
- await this.limited(() => this.deps.worker.request({
145638
- op: "set_dnd",
145639
- serial,
145640
- enabled
145641
- }, withDevice(deviceId)));
145642
- }
145643
- async requireReady() {
145644
- if (!await this.ensureReady()) throw new Error(this.notReadyReason());
145645
- }
145646
- async session() {
145647
- if (this.deps.worker.failed) return {
145648
- loggedIn: false,
145649
- ageSec: 0,
145650
- lastSuccessAt: null
145651
- };
145652
- try {
145653
- return parseSessionStatus(await this.deps.worker.request({ op: "session_status" }));
145654
- } catch {
145655
- return {
145656
- loggedIn: this.currentState === "ready",
145657
- ageSec: 0,
145658
- lastSuccessAt: null
145659
- };
145660
- }
145661
- }
145662
- notReadyReason() {
145663
- if (this.currentState === "broker-not-signed-in") return `account '${this.deps.brokerName}' is not signed in on its broker`;
145664
- return this.currentError ?? "alexa session is not ready";
145665
- }
145666
- };
145667
145390
  //#endregion
145668
145391
  //#region src/speak/rate-limit.ts
145669
145392
  /**
@@ -145887,7 +145610,6 @@ function discoveredFrom(broker, device) {
145887
145610
  }
145888
145611
  function createAlexaNotificationProvider(ports) {
145889
145612
  const logger = ports.logger ?? quietLogger$1();
145890
- let targets = ports.loadTargets();
145891
145613
  let ledger = emptySpeechLedger();
145892
145614
  let deliveries = [];
145893
145615
  function record(row) {
@@ -145906,10 +145628,6 @@ function createAlexaNotificationProvider(ports) {
145906
145628
  const owners = all.filter((broker) => broker.devices.some((device) => device.id === echoId));
145907
145629
  return owners.length === 1 ? owners[0] : void 0;
145908
145630
  }
145909
- function persist(next) {
145910
- targets = next;
145911
- ports.saveTargets(next);
145912
- }
145913
145631
  async function deliver(target, notification) {
145914
145632
  const speech = speechFromNotification(notification);
145915
145633
  const echoId = readString(target.config, "echoId");
@@ -146111,10 +145829,10 @@ function createAlexaNotificationProvider(ports) {
146111
145829
  return [alexaTargetKind()];
146112
145830
  },
146113
145831
  async listTargets() {
146114
- return [...targets];
145832
+ return [...await ports.loadTargets()];
146115
145833
  },
146116
145834
  async getTarget(input) {
146117
- const found = targets.find((target) => target.id === input.targetId);
145835
+ const found = (await ports.loadTargets()).find((target) => target.id === input.targetId);
146118
145836
  if (found === void 0) throw new Error(`target not found: ${input.targetId}`);
146119
145837
  return found;
146120
145838
  },
@@ -146133,7 +145851,7 @@ function createAlexaNotificationProvider(ports) {
146133
145851
  suggestedName: echoOptionLabel(device)
146134
145852
  }));
146135
145853
  }
146136
- const taken = new Set(targets.flatMap((row) => {
145854
+ const taken = new Set((await ports.loadTargets()).flatMap((row) => {
146137
145855
  const owner = brokerFor(row.config);
146138
145856
  return owner === void 0 ? [] : [`${owner.id}/${readString(row.config, "echoId")}`];
146139
145857
  }));
@@ -146151,7 +145869,7 @@ function createAlexaNotificationProvider(ports) {
146151
145869
  };
146152
145870
  },
146153
145871
  async send(input) {
146154
- const target = targets.find((row) => row.id === input.targetId);
145872
+ const target = (await ports.loadTargets()).find((row) => row.id === input.targetId);
146155
145873
  if (target === void 0) return {
146156
145874
  success: false,
146157
145875
  error: `target not found: ${input.targetId}`
@@ -146163,7 +145881,7 @@ function createAlexaNotificationProvider(ports) {
146163
145881
  return deliver(target, input.notification);
146164
145882
  },
146165
145883
  async testTarget(input) {
146166
- const found = targets.find((row) => row.id === input.targetId);
145884
+ const found = (await ports.loadTargets()).find((row) => row.id === input.targetId);
146167
145885
  if (found === void 0) return {
146168
145886
  success: false,
146169
145887
  error: `target not found: ${input.targetId}`
@@ -146193,20 +145911,554 @@ function createAlexaNotificationProvider(ports) {
146193
145911
  addonId: ALEXA_SPEAK_ADDON_ID,
146194
145912
  kind: ALEXA_SPEAK_KIND
146195
145913
  };
146196
- persist([...targets.filter((row) => row.id !== target.id), target]);
145914
+ await ports.putTarget(target);
146197
145915
  return target;
146198
145916
  },
146199
145917
  async deleteTarget(input) {
146200
- persist(targets.filter((row) => row.id !== input.targetId));
145918
+ await ports.removeTarget(input.targetId);
146201
145919
  },
146202
145920
  async setTargetEnabled(input) {
146203
- persist(targets.map((row) => row.id === input.targetId ? {
146204
- ...row,
145921
+ const found = (await ports.loadTargets()).find((row) => row.id === input.targetId);
145922
+ if (found !== void 0) await ports.putTarget({
145923
+ ...found,
146205
145924
  enabled: input.enabled
146206
- } : row));
145925
+ });
145926
+ }
145927
+ };
145928
+ }
145929
+ //#endregion
145930
+ //#region src/speak/echo-store.ts
145931
+ /**
145932
+ * The Echo targets as records: one `notify-alexa-echo` record per target (an instance of the function
145933
+ * cap `notification-output`), the id the one a notification rule names. The records are the one source:
145934
+ * the controller reads them on every use and writes only the row it changes.
145935
+ */
145936
+ var alexaNotifyRecordKinds = notificationTargetKinds([alexaTargetKind()]);
145937
+ function echoTargetStoreOver(records, options = {}) {
145938
+ return NotificationTargetStore.of(records, ALEXA_SPEAK_ADDON_ID, alexaNotifyRecordKinds, [alexaTargetKind()], options);
145939
+ }
145940
+ /** The `echoTargets` setting as the legacy source: a row the migration left there is still served. */
145941
+ function legacyEchoTargetsOver(setting) {
145942
+ return {
145943
+ list: async () => readEchoTargets({ echoTargets: setting.readRows() }).map((row) => ({
145944
+ id: row.id,
145945
+ name: row.name,
145946
+ kind: alexaTargetKind().kind,
145947
+ enabled: row.enabled,
145948
+ config: {
145949
+ ...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
145950
+ echoId: row.echoId,
145951
+ respectDnd: row.respectDnd
145952
+ }
145953
+ })),
145954
+ delete: async (id) => {
145955
+ const rest = setting.readRows().filter((row) => {
145956
+ const parsed = alexaEchoTargetSchema.safeParse(row);
145957
+ return !(parsed.success && parsed.data.id === id);
145958
+ });
145959
+ if (rest.length !== setting.readRows().length) await setting.writeRows(rest);
145960
+ }
145961
+ };
145962
+ }
145963
+ /** The row a stored target is for the settings page: the account link is '' until one is chosen. */
145964
+ function echoRowOf(target) {
145965
+ return {
145966
+ id: target.id,
145967
+ name: target.name,
145968
+ enabled: target.enabled,
145969
+ brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
145970
+ echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
145971
+ respectDnd: target.config["respectDnd"] !== false
145972
+ };
145973
+ }
145974
+ /** The migration's ports over the `notify-alexa-echo` record kind. */
145975
+ function echoRecordPortsOver(records, logger) {
145976
+ const handle = echoTargetStoreOver(records).handlesByKind().get(alexaTargetKind().kind);
145977
+ if (handle === void 0) throw new Error("import-alexa: the Echo target record kind is not declared");
145978
+ return {
145979
+ readEchoRecord: async (id) => (await handle.getResolved(id))?.data ?? null,
145980
+ writeEchoRecord: async (id, body) => {
145981
+ await handle.put({
145982
+ id,
145983
+ data: body
145984
+ });
145985
+ },
145986
+ dropEchoRecord: async (id) => {
145987
+ await handle.delete(id);
145988
+ },
145989
+ echoSkipped: (row) => {
145990
+ logger.warn("Echo target left in the echoTargets setting; it keeps being served", { meta: {
145991
+ targetId: row.id,
145992
+ reason: row.reason
145993
+ } });
146207
145994
  }
146208
145995
  };
146209
145996
  }
145997
+ /**
145998
+ * The Echo instance's test: the old probe (is the Amazon account of the target signed in on its
145999
+ * broker?) as the tri-state of `testInstance`. Speech not running yet answers `inconclusive` through
146000
+ * the same mapping (a failure with no HTTP status), never `unsupported`.
146001
+ */
146002
+ function echoTargetProbe(speak) {
146003
+ return async (input) => {
146004
+ const source = speak();
146005
+ if (source === null) return {
146006
+ ok: false,
146007
+ resolvedUrl: "alexa://session",
146008
+ error: "Echo speech is not running"
146009
+ };
146010
+ return source.provider.probeTargetConfig({
146011
+ kind: input.kind,
146012
+ config: input.config
146013
+ });
146014
+ };
146015
+ }
146016
+ process.platform, process.env["SystemRoot"];
146017
+ /**
146018
+ * Files only their owner may read: the one place a secret-bearing file is
146019
+ * restricted, on every OS.
146020
+ *
146021
+ * `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
146022
+ * attribute and nothing else, and `chmod` is a no-op for permissions, so a
146023
+ * "0600" `agent.json` (the mesh private key) would have
146024
+ * been readable by every local user. Windows has its own tool for this:
146025
+ * `icacls`. This module removes the inherited ACL entries and grants access to
146026
+ * the current user and SYSTEM only — the Windows reading of "0600".
146027
+ *
146028
+ * ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
146029
+ * this single file (see `packages/desktop/tsup.config.ts`) instead of the
146030
+ * whole `@camstack/types` barrel.
146031
+ *
146032
+ * Failure is NOT swallowed. A secret file that could not be restricted is a
146033
+ * secret file the caller must know about, so `OwnerOnlyError` propagates; the
146034
+ * write order below makes sure nothing sensitive is in the file yet when that
146035
+ * happens (the temp file is empty until it has been restricted).
146036
+ *
146037
+ * Out of scope on purpose: files that are only tidy, not secret (the job
146038
+ * journal, the addon manifest) keep a plain `mode`, since one `icacls` per
146039
+ * write would be a process spawn on a hot path.
146040
+ */
146041
+ /** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
146042
+ var WINDOWS_SYSTEM_SID = "*S-1-5-18";
146043
+ var FILE_MODE = 384;
146044
+ var DIR_MODE = 448;
146045
+ var OwnerOnlyError = class extends Error {
146046
+ target;
146047
+ constructor(target, detail) {
146048
+ super(`could not restrict ${target} to its owner: ${detail}`);
146049
+ this.target = target;
146050
+ this.name = "OwnerOnlyError";
146051
+ }
146052
+ };
146053
+ function currentPrincipal() {
146054
+ const user = process.env["USERNAME"] ?? (0, node_os.userInfo)().username;
146055
+ const domain = process.env["USERDOMAIN"];
146056
+ return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
146057
+ }
146058
+ /** Resolved per call, not at import: a platform stubbed by a test must be seen. */
146059
+ function resolveDeps(overrides) {
146060
+ return {
146061
+ platform: process.platform,
146062
+ exec: (file, args) => {
146063
+ (0, node_child_process.execFileSync)(file, [...args], {
146064
+ stdio: "pipe",
146065
+ windowsHide: true
146066
+ });
146067
+ },
146068
+ principal: currentPrincipal,
146069
+ systemRoot: process.env["SystemRoot"],
146070
+ ...overrides
146071
+ };
146072
+ }
146073
+ /** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
146074
+ function icaclsArgs(target, principal, isDirectory) {
146075
+ const inherit = isDirectory ? "(OI)(CI)" : "";
146076
+ return [
146077
+ target,
146078
+ "/inheritance:r",
146079
+ "/grant:r",
146080
+ `${principal}:${inherit}F`,
146081
+ "/grant:r",
146082
+ `${WINDOWS_SYSTEM_SID}:${inherit}F`
146083
+ ];
146084
+ }
146085
+ function restrictWindows(target, isDirectory, deps) {
146086
+ const icacls = node_path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
146087
+ try {
146088
+ deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
146089
+ } catch (err) {
146090
+ throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
146091
+ }
146092
+ }
146093
+ /** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
146094
+ function restrictFileToOwnerSync(target, overrides = {}) {
146095
+ const deps = resolveDeps(overrides);
146096
+ if (deps.platform === "win32") {
146097
+ restrictWindows(target, false, deps);
146098
+ return;
146099
+ }
146100
+ node_fs.chmodSync(target, FILE_MODE);
146101
+ }
146102
+ /** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
146103
+ function restrictDirToOwnerSync(target, overrides = {}) {
146104
+ const deps = resolveDeps(overrides);
146105
+ if (deps.platform === "win32") {
146106
+ restrictWindows(target, true, deps);
146107
+ return;
146108
+ }
146109
+ node_fs.chmodSync(target, DIR_MODE);
146110
+ }
146111
+ /**
146112
+ * Atomically write a secret-bearing file that only its owner can read.
146113
+ *
146114
+ * Order matters: the temp file is created EMPTY, restricted, and only then
146115
+ * given its content, so there is no moment at which the secret exists under a
146116
+ * permissive ACL. The rename carries the (already restricted) ACL with it.
146117
+ */
146118
+ function writeOwnerOnlyFileSync(target, data, overrides = {}) {
146119
+ node_fs.mkdirSync(node_path$1.dirname(target), { recursive: true });
146120
+ const tmp = `${target}.tmp.${process.pid}`;
146121
+ node_fs.writeFileSync(tmp, "", { mode: FILE_MODE });
146122
+ try {
146123
+ restrictFileToOwnerSync(tmp, overrides);
146124
+ node_fs.writeFileSync(tmp, data);
146125
+ node_fs.renameSync(tmp, target);
146126
+ } catch (err) {
146127
+ node_fs.rmSync(tmp, { force: true });
146128
+ throw err;
146129
+ }
146130
+ }
146131
+ //#endregion
146132
+ //#region src/speak/secret-file.ts
146133
+ /**
146134
+ * Hand a refresh token or a saved Amazon session to the Python worker as an
146135
+ * owner-only file. The path travels on stdin. The bytes never travel on
146136
+ * argv or in the environment, and they are removed when the call settles.
146137
+ */
146138
+ var REDACTED = "‹redacted›";
146139
+ function decodeSession(sessionB64) {
146140
+ if (sessionB64.length === 0) return Buffer.alloc(0);
146141
+ return Buffer.from(sessionB64, "base64");
146142
+ }
146143
+ async function readSessionB64(sessionPath) {
146144
+ const bytes = await node_fs_promises.readFile(sessionPath);
146145
+ if (bytes.length === 0) return "";
146146
+ return bytes.toString("base64");
146147
+ }
146148
+ /**
146149
+ * Create a 0700 directory, write the refresh token and the session 0600, run
146150
+ * `body`, then delete the directory whether `body` succeeded or threw.
146151
+ * Returns the session file as base64 so the caller can store it in the keychain.
146152
+ */
146153
+ async function withSpeakSecretFiles(input, body) {
146154
+ const dir = await node_fs_promises.mkdtemp(node_path.join(node_os.tmpdir(), "camstack-alexa-"));
146155
+ const paths = {
146156
+ dir,
146157
+ refreshTokenPath: node_path.join(dir, "refresh-token"),
146158
+ sessionPath: node_path.join(dir, "session")
146159
+ };
146160
+ try {
146161
+ restrictDirToOwnerSync(dir);
146162
+ writeOwnerOnlyFileSync(paths.refreshTokenPath, input.refreshToken);
146163
+ writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
146164
+ await body(paths);
146165
+ return { sessionB64: await readSessionB64(paths.sessionPath) };
146166
+ } finally {
146167
+ await node_fs_promises.rm(dir, {
146168
+ recursive: true,
146169
+ force: true
146170
+ });
146171
+ }
146172
+ }
146173
+ /** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
146174
+ function scrubSecrets(text, secrets) {
146175
+ let out = text;
146176
+ for (const secret of secrets) {
146177
+ if (secret.length === 0) continue;
146178
+ out = out.split(secret).join(REDACTED);
146179
+ }
146180
+ return out;
146181
+ }
146182
+ //#endregion
146183
+ //#region src/speak/broker-echo.ts
146184
+ function withDevice(deviceId) {
146185
+ return deviceId === void 0 ? {} : { deviceId };
146186
+ }
146187
+ var BrokerEcho = class {
146188
+ deps;
146189
+ currentState = "starting";
146190
+ currentError = null;
146191
+ currentDevices = [];
146192
+ backoffMs = 0;
146193
+ limitedUntil = 0;
146194
+ channelState = "down";
146195
+ channelWasUp = false;
146196
+ secrets = [];
146197
+ unknownPushLogged = /* @__PURE__ */ new Set();
146198
+ constructor(deps) {
146199
+ this.deps = deps;
146200
+ deps.worker.onPush((push) => this.onPush(push));
146201
+ }
146202
+ /** Whether Amazon's push channel is open for this account right now. */
146203
+ get pushChannel() {
146204
+ return this.channelState;
146205
+ }
146206
+ /** A reason is text from the library: capped, then scrubbed of the secrets in play. */
146207
+ safeReason(reason) {
146208
+ return scrubSecrets(reason.slice(0, 200), this.secrets);
146209
+ }
146210
+ onPush(push) {
146211
+ const tags = { tags: { brokerId: this.deps.brokerId } };
146212
+ if (push.push === "channel") {
146213
+ if (push.state === this.channelState) {
146214
+ if (push.state === "down") this.deps.logger.debug("alexa echo: push channel still down", {
146215
+ ...tags,
146216
+ meta: {
146217
+ reason: this.safeReason(push.reason),
146218
+ retryInMs: push.retryInMs
146219
+ }
146220
+ });
146221
+ return;
146222
+ }
146223
+ this.channelState = push.state;
146224
+ if (push.state === "up") {
146225
+ this.deps.logger.info(this.channelWasUp ? "alexa echo: push channel reconnected, polling slows down" : "alexa echo: push channel connected, polling slows down", tags);
146226
+ this.channelWasUp = true;
146227
+ } else this.deps.logger.warn("alexa echo: push channel down, polling at the normal pace", {
146228
+ ...tags,
146229
+ meta: {
146230
+ reason: this.safeReason(push.reason),
146231
+ retryInMs: push.retryInMs
146232
+ }
146233
+ });
146234
+ this.deps.push?.channel(this.deps.brokerId, push.state);
146235
+ return;
146236
+ }
146237
+ if (PUSH_READ_COMMANDS.has(push.command)) {
146238
+ if (push.serial === null) {
146239
+ this.deps.logger.debug("alexa echo: push event without an Echo, ignored", {
146240
+ ...tags,
146241
+ meta: { command: push.command }
146242
+ });
146243
+ return;
146244
+ }
146245
+ this.deps.push?.event(this.deps.brokerId, push.serial);
146246
+ return;
146247
+ }
146248
+ if (PUSH_IGNORED_COMMANDS.has(push.command)) return;
146249
+ if (this.unknownPushLogged.has(push.command)) return;
146250
+ this.unknownPushLogged.add(push.command);
146251
+ this.deps.logger.info("alexa echo: unknown push event, ignored (logged once)", {
146252
+ ...tags,
146253
+ meta: { command: push.command }
146254
+ });
146255
+ }
146256
+ get id() {
146257
+ return this.deps.brokerId;
146258
+ }
146259
+ get name() {
146260
+ return this.deps.brokerName;
146261
+ }
146262
+ get state() {
146263
+ return this.currentState;
146264
+ }
146265
+ get error() {
146266
+ return this.currentError;
146267
+ }
146268
+ get devices() {
146269
+ return this.currentDevices;
146270
+ }
146271
+ /** Ms epoch until which Amazon asked us to wait (429), or null when the account is free. */
146272
+ get rateLimitedUntil() {
146273
+ return this.limitedUntil > this.nowMs() ? this.limitedUntil : null;
146274
+ }
146275
+ nowMs() {
146276
+ return (this.deps.now ?? Date.now)();
146277
+ }
146278
+ stop() {
146279
+ this.deps.worker.stop();
146280
+ if (this.channelState === "up") {
146281
+ this.channelState = "down";
146282
+ this.deps.push?.channel(this.deps.brokerId, "down");
146283
+ }
146284
+ }
146285
+ /** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
146286
+ async signIn() {
146287
+ const { brokerId, worker, sessions, logger } = this.deps;
146288
+ const tags = { tags: { brokerId } };
146289
+ let secrets = [];
146290
+ try {
146291
+ const seed = await this.deps.readSeed();
146292
+ const stored = await sessions.get(brokerId);
146293
+ logger.info("alexa speak: broker sign-in material", {
146294
+ ...tags,
146295
+ meta: {
146296
+ seedFound: seed !== null,
146297
+ hasRefreshToken: seed !== null && seed.refreshToken.length > 0,
146298
+ hasDeviceAppName: seed !== null && seed.deviceAppName.length > 0,
146299
+ hasStoredSession: stored.length > 0
146300
+ }
146301
+ });
146302
+ if (seed === null && stored.length === 0) {
146303
+ this.currentState = "broker-not-signed-in";
146304
+ this.currentError = null;
146305
+ logger.warn("alexa speak: the broker has no sign-in to share yet", tags);
146306
+ return;
146307
+ }
146308
+ secrets = seed === null ? [] : [seed.refreshToken];
146309
+ worker.noteSecrets(secrets);
146310
+ this.secrets = secrets;
146311
+ let outcome = parseLoginResult({
146312
+ status: "failed",
146313
+ error: "worker gave no answer"
146314
+ });
146315
+ const held = await withSpeakSecretFiles({
146316
+ refreshToken: seed?.refreshToken ?? "",
146317
+ sessionB64: stored
146318
+ }, async (paths) => {
146319
+ outcome = parseLoginResult(await worker.request({
146320
+ op: "login_broker",
146321
+ label: brokerId,
146322
+ refreshTokenPath: seed === null ? "" : paths.refreshTokenPath,
146323
+ deviceAppName: seed?.deviceAppName ?? "",
146324
+ domain: this.deps.domain,
146325
+ sessionPath: paths.sessionPath,
146326
+ region: regionOfDomain(this.deps.domain)
146327
+ }));
146328
+ });
146329
+ if (held.sessionB64.length > 0 && held.sessionB64 !== stored) await sessions.put(brokerId, held.sessionB64);
146330
+ this.currentError = outcome.status === "ok" ? null : outcome.error ?? "sign-in failed";
146331
+ this.currentState = outcome.status === "ok" ? "ready" : "failed";
146332
+ if (outcome.status !== "ok") {
146333
+ logger.warn("alexa speak: sign-in as the broker failed", {
146334
+ ...tags,
146335
+ meta: { error: this.currentError }
146336
+ });
146337
+ return;
146338
+ }
146339
+ await this.refreshDevices();
146340
+ } catch (err) {
146341
+ this.currentState = "failed";
146342
+ this.currentError = scrubSecrets(err instanceof Error ? err.message : String(err), secrets);
146343
+ logger.warn("alexa speak: sign-in as the broker failed", {
146344
+ ...tags,
146345
+ meta: { error: this.currentError }
146346
+ });
146347
+ }
146348
+ }
146349
+ /** A session that is not ready tries to sign in again before it gives up. */
146350
+ async ensureReady() {
146351
+ if (this.currentState !== "ready") await this.signIn();
146352
+ return this.currentState === "ready";
146353
+ }
146354
+ async refreshDevices() {
146355
+ if (this.currentState !== "ready") return;
146356
+ try {
146357
+ this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
146358
+ } catch (err) {
146359
+ this.deps.logger.warn("alexa speak: step 3 of 3, device list failed after a good sign-in", {
146360
+ tags: { brokerId: this.deps.brokerId },
146361
+ meta: { error: err instanceof Error ? err.message : String(err) }
146362
+ });
146363
+ }
146364
+ }
146365
+ async utter(op, targets, text) {
146366
+ if (!await this.ensureReady()) throw new Error(this.notReadyReason());
146367
+ return parseDeliveries(await this.deps.worker.request({
146368
+ op,
146369
+ targets,
146370
+ text
146371
+ }));
146372
+ }
146373
+ /**
146374
+ * Run one worker call. A 429 starts (or doubles) the account's back-off and is
146375
+ * rethrown as is; any answer ends it. Rate limiting is a status, not a failure.
146376
+ */
146377
+ async limited(call) {
146378
+ try {
146379
+ const result = await call();
146380
+ this.backoffMs = 0;
146381
+ this.limitedUntil = 0;
146382
+ return result;
146383
+ } catch (err) {
146384
+ if (err instanceof RateLimitedError) {
146385
+ this.backoffMs = this.backoffMs === 0 ? ECHO_RATE_BACKOFF_BASE_MS : Math.min(this.backoffMs * 2, ECHO_RATE_BACKOFF_MAX_MS);
146386
+ this.limitedUntil = this.nowMs() + this.backoffMs;
146387
+ this.deps.logger.info("alexa echo: amazon asked for a pause (429)", {
146388
+ tags: { brokerId: this.deps.brokerId },
146389
+ meta: { backoffMs: this.backoffMs }
146390
+ });
146391
+ }
146392
+ throw err;
146393
+ }
146394
+ }
146395
+ /** The raw player state of one Echo. Throws on any failure, a 429 included: never an empty answer. */
146396
+ async playerState(serial, deviceType, deviceId) {
146397
+ await this.requireReady();
146398
+ return this.limited(async () => parsePlayerState(await this.deps.worker.request({
146399
+ op: "player_state",
146400
+ serial,
146401
+ deviceType
146402
+ }, withDevice(deviceId))));
146403
+ }
146404
+ async npCommand(serial, command, deviceId) {
146405
+ await this.requireReady();
146406
+ await this.limited(() => this.deps.worker.request({
146407
+ op: "np_command",
146408
+ serial,
146409
+ command
146410
+ }, withDevice(deviceId)));
146411
+ }
146412
+ async setVolume(serial, level, deviceId) {
146413
+ await this.requireReady();
146414
+ await this.limited(() => this.deps.worker.request({
146415
+ op: "set_volume",
146416
+ serial,
146417
+ level
146418
+ }, withDevice(deviceId)));
146419
+ }
146420
+ /** Do Not Disturb of every device of the account, by serial, in one call. */
146421
+ async dndState() {
146422
+ await this.requireReady();
146423
+ return this.limited(async () => parseDndState(await this.deps.worker.request({ op: "dnd_state" })));
146424
+ }
146425
+ /** Speaker volume and mute of every device of the account, by serial, in one call. */
146426
+ async deviceVolumes() {
146427
+ await this.requireReady();
146428
+ return this.limited(async () => parseDeviceVolumes(await this.deps.worker.request({ op: "device_volumes" })));
146429
+ }
146430
+ async setDnd(serial, enabled, deviceId) {
146431
+ await this.requireReady();
146432
+ await this.limited(() => this.deps.worker.request({
146433
+ op: "set_dnd",
146434
+ serial,
146435
+ enabled
146436
+ }, withDevice(deviceId)));
146437
+ }
146438
+ async requireReady() {
146439
+ if (!await this.ensureReady()) throw new Error(this.notReadyReason());
146440
+ }
146441
+ async session() {
146442
+ if (this.deps.worker.failed) return {
146443
+ loggedIn: false,
146444
+ ageSec: 0,
146445
+ lastSuccessAt: null
146446
+ };
146447
+ try {
146448
+ return parseSessionStatus(await this.deps.worker.request({ op: "session_status" }));
146449
+ } catch {
146450
+ return {
146451
+ loggedIn: this.currentState === "ready",
146452
+ ageSec: 0,
146453
+ lastSuccessAt: null
146454
+ };
146455
+ }
146456
+ }
146457
+ notReadyReason() {
146458
+ if (this.currentState === "broker-not-signed-in") return `account '${this.deps.brokerName}' is not signed in on its broker`;
146459
+ return this.currentError ?? "alexa session is not ready";
146460
+ }
146461
+ };
146210
146462
  //#endregion
146211
146463
  //#region src/speak/python-paths.ts
146212
146464
  /** Locate bundled `python/alexa_worker.py` from dist/ or from src/ under test. */
@@ -146585,34 +146837,9 @@ function startAlexaWorker(deps) {
146585
146837
  }
146586
146838
  //#endregion
146587
146839
  //#region src/speak/controller.ts
146588
- function targetsFrom(rows) {
146589
- return rows.map((row) => ({
146590
- id: row.id,
146591
- name: row.name,
146592
- kind: ALEXA_SPEAK_KIND,
146593
- addonId: ALEXA_SPEAK_ADDON_ID,
146594
- enabled: row.enabled,
146595
- config: {
146596
- ...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
146597
- echoId: row.echoId,
146598
- respectDnd: row.respectDnd
146599
- }
146600
- }));
146601
- }
146602
- function rowsFrom(targets) {
146603
- return targets.map((target) => ({
146604
- id: target.id,
146605
- name: target.name,
146606
- enabled: target.enabled,
146607
- brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
146608
- echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
146609
- respectDnd: target.config["respectDnd"] !== false
146610
- }));
146611
- }
146612
146840
  var AlexaSpeakController = class {
146613
146841
  host;
146614
146842
  provider;
146615
- rows;
146616
146843
  pythonPath = null;
146617
146844
  echoes = /* @__PURE__ */ new Map();
146618
146845
  timer = null;
@@ -146621,16 +146848,13 @@ var AlexaSpeakController = class {
146621
146848
  constructor(host) {
146622
146849
  this.host = host;
146623
146850
  this.now = host.now ?? (() => Date.now());
146624
- this.rows = readEchoTargets(host.readConfig());
146625
146851
  this.provider = createAlexaNotificationProvider({
146626
146852
  brokers: () => this.brokerViews(),
146627
146853
  now: this.now,
146628
146854
  logger: host.logger,
146629
- loadTargets: () => targetsFrom(this.rows),
146630
- saveTargets: (targets) => {
146631
- this.rows = rowsFrom(targets);
146632
- this.persist();
146633
- },
146855
+ loadTargets: () => host.readTargets(),
146856
+ putTarget: (target) => host.putTarget(target),
146857
+ removeTarget: (targetId) => host.removeTarget(targetId),
146634
146858
  speak: (command) => this.utter(command.brokerId, command.mode, command.targets, command.text)
146635
146859
  });
146636
146860
  }
@@ -146672,13 +146896,6 @@ var AlexaSpeakController = class {
146672
146896
  };
146673
146897
  });
146674
146898
  }
146675
- async persist() {
146676
- try {
146677
- await this.host.writeTargets(this.rows);
146678
- } catch (err) {
146679
- this.host.logger.warn("alexa speak: could not store targets", { meta: { error: err instanceof Error ? err.message : String(err) } });
146680
- }
146681
- }
146682
146899
  makeWorker(brokerId, pythonPath) {
146683
146900
  if (this.host.startWorker !== void 0) return this.host.startWorker(brokerId, pythonPath);
146684
146901
  return startAlexaWorker({
@@ -146880,6 +147097,80 @@ async function stripEchoTargetVolumeAndMode(ports) {
146880
147097
  };
146881
147098
  }
146882
147099
  //#endregion
147100
+ //#region src/migrations/echo-targets-to-records.ts
147101
+ /**
147102
+ * The Echo notification targets move from the `echoTargets` setting (one array in the addon's global
147103
+ * settings) to one `notify-alexa-echo` record each, under the SAME id so a notification rule naming it
147104
+ * still resolves (epic #440, step 6). The switch (`enabled`) is carried across, never reset.
147105
+ *
147106
+ * Each record is read back before its row leaves the setting. One bad row never aborts the others: a row
147107
+ * whose write or read-back fails is undone, reported with its id and reason, and stays in the setting,
147108
+ * where the Echo store keeps serving it. A record that already exists is never overwritten (an operator
147109
+ * may have edited it since). A row that does not parse is left where it is and keeps the setting in
147110
+ * place: nothing is destroyed on a guess (D49).
147111
+ */
147112
+ var ECHO_TARGETS_TO_RECORDS_ID = "import-alexa/2026-11-06-echo-targets-to-records";
147113
+ async function moveEchoTargetsToRecords(ports) {
147114
+ const rows = ports.readRows();
147115
+ if (rows.length === 0) return {
147116
+ outcome: "already-done",
147117
+ summary: { converted: 0 }
147118
+ };
147119
+ let converted = 0;
147120
+ let kept = 0;
147121
+ let unparsed = 0;
147122
+ const left = [];
147123
+ for (const row of rows) {
147124
+ const parsed = alexaEchoTargetSchema.safeParse(row);
147125
+ if (!parsed.success) {
147126
+ unparsed += 1;
147127
+ continue;
147128
+ }
147129
+ const target = parsed.data;
147130
+ try {
147131
+ if (await ports.readEchoRecord(target.id) !== null) {
147132
+ kept += 1;
147133
+ continue;
147134
+ }
147135
+ const body = targetBody({
147136
+ id: target.id,
147137
+ name: target.name,
147138
+ enabled: target.enabled,
147139
+ config: {
147140
+ ...target.brokerId.length > 0 ? { brokerId: target.brokerId } : {},
147141
+ echoId: target.echoId,
147142
+ respectDnd: target.respectDnd
147143
+ }
147144
+ });
147145
+ await ports.writeEchoRecord(target.id, body);
147146
+ const back = await ports.readEchoRecord(target.id);
147147
+ if (!(back !== null && back["name"] === body["name"] && back["enabled"] === body["enabled"] && back["echoId"] === body["echoId"] && back["respectDnd"] === body["respectDnd"] && back["brokerId"] === body["brokerId"])) {
147148
+ await ports.dropEchoRecord(target.id);
147149
+ throw new Error("the new record did not read back");
147150
+ }
147151
+ converted += 1;
147152
+ } catch (err) {
147153
+ await ports.dropEchoRecord(target.id).catch(() => void 0);
147154
+ ports.echoSkipped({
147155
+ id: target.id,
147156
+ reason: err instanceof Error ? err.message : String(err)
147157
+ });
147158
+ left.push(row);
147159
+ }
147160
+ }
147161
+ if (unparsed === 0) await ports.writeRows(left);
147162
+ return {
147163
+ outcome: converted > 0 || kept > 0 ? "done" : "already-done",
147164
+ summary: {
147165
+ converted,
147166
+ kept,
147167
+ unparsed,
147168
+ skipped: left.length,
147169
+ kind: ALEXA_SPEAK_KIND
147170
+ }
147171
+ };
147172
+ }
147173
+ //#endregion
146883
147174
  //#region src/migrations/index.ts
146884
147175
  /** The `import-alexa` catalogue (D987): offered to the engine when the addon initialises. */
146885
147176
  var ADDON_MIGRATIONS = [
@@ -146946,6 +147237,22 @@ var ADDON_MIGRATIONS = [
146946
147237
  fixtureMark: "the name and the extra field of the account that keeps its record"
146947
147238
  },
146948
147239
  procedure: moveBrokerLinksToConnections
147240
+ },
147241
+ {
147242
+ meta: {
147243
+ id: ECHO_TARGETS_TO_RECORDS_ID,
147244
+ owner: "import-alexa",
147245
+ level: "addon-state",
147246
+ seq: 5,
147247
+ dependsOn: ["import-alexa/2026-11-01-strip-echo-target-volume-and-mode"],
147248
+ store: "settings",
147249
+ scope: "cluster",
147250
+ onFailure: "tolerate",
147251
+ bodyKind: "procedure",
147252
+ description: "Move each Echo target from the echoTargets setting to a notify-alexa-echo record under the same id, the switch carried, read back before its row leaves the setting; a row that fails stays in the setting and is still served; a record that exists is never overwritten",
147253
+ fixtureMark: "the switch (off) and the id of an Echo target"
147254
+ },
147255
+ procedure: moveEchoTargetsToRecords
146949
147256
  }
146950
147257
  ];
146951
147258
  /** The migrations that rewrite the broker records: they run BEFORE anything reads or persists one. */
@@ -147055,6 +147362,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147055
147362
  accountInstances = null;
147056
147363
  activeBrokers = [];
147057
147364
  speak = null;
147365
+ echoStore = null;
147058
147366
  constructor() {
147059
147367
  super({ ...DEFAULTS });
147060
147368
  }
@@ -147062,7 +147370,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147062
147370
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
147063
147371
  const records = this.ctx.records;
147064
147372
  if (records === void 0) throw new Error("import-alexa: ctx.records is required (hub-only addon)");
147065
- await records.declare(alexaRecordKinds);
147373
+ await records.declare({
147374
+ ...alexaRecordKinds,
147375
+ ...alexaNotifyRecordKinds
147376
+ });
147066
147377
  this.brokerStore = AlexaBrokerStore.of(records);
147067
147378
  this.instances = buildAlexaInstances(records);
147068
147379
  this.accountInstances = buildAlexaAccountInstances(records, {
@@ -147112,12 +147423,16 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147112
147423
  commitCookie: (input) => this.commitCapturedCookie(input)
147113
147424
  }), ...speakIconRoutes()];
147114
147425
  await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: ADDON_MIGRATIONS.map((m) => m.meta.id).filter((id) => !BROKER_RECORD_MIGRATION_IDS.includes(id)) });
147426
+ const echoStore = echoTargetStoreOver(records, {
147427
+ logger: this.ctx.logger,
147428
+ legacy: legacyEchoTargetsOver(this.echoSettingPorts())
147429
+ });
147430
+ this.echoStore = echoStore;
147115
147431
  const speak = new AlexaSpeakController({
147116
147432
  logger: this.ctx.logger.child("speak"),
147117
- readConfig: () => this.config,
147118
- writeTargets: async (next) => {
147119
- await this.updateGlobalSettings({ echoTargets: [...next] });
147120
- },
147433
+ readTargets: () => echoStore.list(),
147434
+ putTarget: (target) => echoStore.upsert(target),
147435
+ removeTarget: (targetId) => echoStore.delete(targetId),
147121
147436
  listBrokers: () => this.activeBrokers.map((entry) => ({
147122
147437
  id: entry.id,
147123
147438
  name: entry.name,
@@ -147160,16 +147475,23 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147160
147475
  }
147161
147476
  ] };
147162
147477
  }
147163
- migrationPorts(records) {
147164
- const secrets = this.ctx.secrets;
147165
- if (secrets === void 0) throw new Error("import-alexa: ctx.secrets is required");
147478
+ /** The `echoTargets` setting, read and written as the migration and the legacy store see it. */
147479
+ echoSettingPorts() {
147166
147480
  return {
147167
147481
  readRows: () => Array.isArray(this.config.echoTargets) ? this.config.echoTargets : [],
147168
147482
  writeRows: async (rows) => {
147169
147483
  await this.updateGlobalSettings({ echoTargets: [...readEchoTargets({ echoTargets: rows })] });
147170
- },
147484
+ }
147485
+ };
147486
+ }
147487
+ migrationPorts(records) {
147488
+ const secrets = this.ctx.secrets;
147489
+ if (secrets === void 0) throw new Error("import-alexa: ctx.secrets is required");
147490
+ return {
147491
+ ...this.echoSettingPorts(),
147171
147492
  ...flattenPortsOver(records.kind(alexaBrokersEntryKind)),
147172
147493
  ...alexaBrokerLinkPorts(records, (message) => this.ctx.logger.warn(message)),
147494
+ ...echoRecordPortsOver(records, this.ctx.logger),
147173
147495
  ...alexaIntegrationRowsPorts({
147174
147496
  api: defaultIntegrationApiOver(this.ctx.api),
147175
147497
  records,
@@ -147181,28 +147503,40 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147181
147503
  /** One provider over the two record kinds: the account (broker) and the integrations using it. */
147182
147504
  buildInstancesProvider() {
147183
147505
  if (this.accountInstances === null) throw new Error("import-alexa: account instances not ready");
147184
- return composeInstanceKinds([{
147185
- kind: {
147186
- id: "account",
147187
- label: "Alexa account",
147188
- functionCap: "broker",
147189
- brokerKind: ALEXA_BROKER_KIND,
147190
- role: "services",
147191
- signIn: {
147192
- basePath: ALEXA_SIGN_IN_PATH,
147193
- methods: ["device-code", "proxy"]
147194
- }
147506
+ const records = this.ctx.records;
147507
+ if (records === void 0) throw new Error("import-alexa: ctx.records is required");
147508
+ return composeInstanceKinds([
147509
+ {
147510
+ kind: {
147511
+ id: "account",
147512
+ label: "Alexa account",
147513
+ functionCap: "broker",
147514
+ brokerKind: ALEXA_BROKER_KIND,
147515
+ role: "services",
147516
+ signIn: {
147517
+ basePath: ALEXA_SIGN_IN_PATH,
147518
+ methods: ["device-code", "proxy"]
147519
+ }
147520
+ },
147521
+ provider: guardInstanceDelete(this.accountInstances.toProvider(), () => this.brokerUsers())
147195
147522
  },
147196
- provider: guardInstanceDelete(this.accountInstances.toProvider(), () => this.brokerUsers())
147197
- }, {
147198
- kind: {
147199
- id: "integration",
147200
- label: "Alexa",
147201
- functionCap: "device-provider",
147202
- role: "devices"
147523
+ {
147524
+ kind: {
147525
+ id: "integration",
147526
+ label: "Alexa",
147527
+ functionCap: "device-provider",
147528
+ role: "devices"
147529
+ },
147530
+ provider: this.requireInstances().toProvider()
147203
147531
  },
147204
- provider: this.requireInstances().toProvider()
147205
- }], {
147532
+ ...notificationTargetParts(records, {
147533
+ descriptors: [alexaTargetKind()],
147534
+ kinds: alexaNotifyRecordKinds,
147535
+ mintId: () => (0, node_crypto.randomUUID)(),
147536
+ probe: echoTargetProbe(() => this.speak),
147537
+ canProbe: () => true
147538
+ })
147539
+ ], {
147206
147540
  role: "devices",
147207
147541
  instanceMode: "multiple",
147208
147542
  kind: "account",
@@ -147493,7 +147827,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147493
147827
  }
147494
147828
  /** Who still points at an Alexa account: its integrations and the Echo targets that speak through it. */
147495
147829
  brokerUsers() {
147496
- return [linkUserFinder(this.requireInstances(), "brokerId"), { find: async (brokerId) => readEchoTargets(this.config).filter((target) => target.brokerId === brokerId).map((target) => ({
147830
+ return [linkUserFinder(this.requireInstances(), "brokerId"), { find: async (brokerId) => (this.echoStore === null ? [] : (await this.echoStore.list()).map(echoRowOf)).filter((target) => target.brokerId === brokerId).map((target) => ({
147497
147831
  kind: "notification-target",
147498
147832
  id: target.id,
147499
147833
  name: target.name