@camstack/addon-import-alexa 0.2.192 → 0.2.194

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 +12 -0
  2. package/dist/addon.js +1783 -1422
  3. package/dist/addon.mjs +1784 -1423
  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-D7ZKm4nb.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).
@@ -6753,10 +6753,33 @@ function integrationRowsApiOver(api) {
6753
6753
  };
6754
6754
  }
6755
6755
  var PREFIX = "records/";
6756
+ /**
6757
+ * Cross-realm brand. An addon bundles its own copy of this module (D15) while
6758
+ * `ctx.records` is the HOST's door, so an error thrown by the host is an
6759
+ * instance of the host's classes, not of the addon's. The brand plus the code
6760
+ * make `instanceof` answer by identity of the failure, not of the copy.
6761
+ */
6762
+ var RECORDS_ERROR_BRAND = Symbol.for("camstack.records-error");
6763
+ function carriesBrand(value) {
6764
+ return typeof value === "object" && value !== null && Reflect.get(value, RECORDS_ERROR_BRAND) === true;
6765
+ }
6756
6766
  /** The base of every records failure. `detail` is the human part of the message. */
6757
6767
  var RecordsError = class extends Error {
6768
+ /** The code a subclass stands for; the base stands for every code. */
6769
+ static code = void 0;
6770
+ /**
6771
+ * `instanceof` across copies of this module: any branded error is a
6772
+ * `RecordsError`, and is an instance of a subclass only when its code is the
6773
+ * subclass's code. Same-copy answers are unchanged.
6774
+ */
6775
+ static [Symbol.hasInstance](value) {
6776
+ if (Function.prototype[Symbol.hasInstance].call(this, value)) return true;
6777
+ if (!carriesBrand(value)) return false;
6778
+ return this.code === void 0 || value.code === this.code;
6779
+ }
6758
6780
  code;
6759
6781
  detail;
6782
+ [RECORDS_ERROR_BRAND] = true;
6760
6783
  constructor(code, detail) {
6761
6784
  super(`${PREFIX}${code}: ${detail}`);
6762
6785
  this.name = "RecordsError";
@@ -6765,29 +6788,33 @@ var RecordsError = class extends Error {
6765
6788
  }
6766
6789
  };
6767
6790
  /** A write guarded by `ifRev` found another revision (or an existing row for `ifRev: 0`). */
6768
- var RevConflictError = class extends RecordsError {
6791
+ var RevConflictError = class RevConflictError extends RecordsError {
6792
+ static code = "rev-conflict";
6769
6793
  constructor(detail) {
6770
- super("rev-conflict", detail);
6794
+ super(RevConflictError.code, detail);
6771
6795
  this.name = "RevConflictError";
6772
6796
  }
6773
6797
  };
6774
6798
  /** The keychain could not store or serve a value: explicit, never an empty string. */
6775
- var RecordSecretUnavailableError = class extends RecordsError {
6799
+ var RecordSecretUnavailableError = class RecordSecretUnavailableError extends RecordsError {
6800
+ static code = "secret-unavailable";
6776
6801
  constructor(detail) {
6777
- super("secret-unavailable", detail);
6802
+ super(RecordSecretUnavailableError.code, detail);
6778
6803
  this.name = "RecordSecretUnavailableError";
6779
6804
  }
6780
6805
  };
6781
- var InvalidRecordError = class extends RecordsError {
6806
+ var InvalidRecordError = class InvalidRecordError extends RecordsError {
6807
+ static code = "invalid-record";
6782
6808
  constructor(detail) {
6783
- super("invalid-record", detail);
6809
+ super(InvalidRecordError.code, detail);
6784
6810
  this.name = "InvalidRecordError";
6785
6811
  }
6786
6812
  };
6787
6813
  /** A kind declaration the engine refuses (non-additive change, bad shape, wrong file). */
6788
- var DeclarationRefusedError = class extends RecordsError {
6814
+ var DeclarationRefusedError = class DeclarationRefusedError extends RecordsError {
6815
+ static code = "declaration-refused";
6789
6816
  constructor(detail) {
6790
- super("declaration-refused", detail);
6817
+ super(DeclarationRefusedError.code, detail);
6791
6818
  this.name = "DeclarationRefusedError";
6792
6819
  }
6793
6820
  };
@@ -6800,6 +6827,13 @@ var DeclarationRefusedError = class extends RecordsError {
6800
6827
  function linkField(target) {
6801
6828
  return string().meta({ link: target });
6802
6829
  }
6830
+ /**
6831
+ * A link that may stay empty (absent): for a link the addon fills itself, or one an instance does not
6832
+ * always need. The meta rides on the optional wrapper, where {@link linksOfKind} reads it.
6833
+ */
6834
+ function optionalLinkField(target) {
6835
+ return string().optional().meta({ link: target });
6836
+ }
6803
6837
  function linkTargetOf(meta) {
6804
6838
  if (typeof meta !== "object" || meta === null) return void 0;
6805
6839
  const link = Reflect.get(meta, "link");
@@ -9591,286 +9625,6 @@ function normalizeAddonInitResult(result) {
9591
9625
  if (Array.isArray(result)) return { providers: result };
9592
9626
  return result;
9593
9627
  }
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
9628
  object({
9875
9629
  field: string().min(1),
9876
9630
  on: literal("advance")
@@ -9963,6 +9717,286 @@ function optionalMethod(input, output, options) {
9963
9717
  function event(data) {
9964
9718
  return { data };
9965
9719
  }
9720
+ function defOf(schema) {
9721
+ const zodBag = Reflect.get(schema, "_zod");
9722
+ if (typeof zodBag !== "object" || zodBag === null) return {};
9723
+ const def = Reflect.get(zodBag, "def");
9724
+ if (typeof def !== "object" || def === null) return {};
9725
+ return def;
9726
+ }
9727
+ function declaredHere(schema) {
9728
+ return globalRegistry.get(schema)?.secret === true;
9729
+ }
9730
+ /** Wrapper kinds whose `innerType` is the field itself. */
9731
+ var WRAPPERS = new Set([
9732
+ "optional",
9733
+ "nullable",
9734
+ "default",
9735
+ "prefault",
9736
+ "catch",
9737
+ "readonly",
9738
+ "nonoptional"
9739
+ ]);
9740
+ /**
9741
+ * The schema layers that make up ONE field: the schema itself and every
9742
+ * wrapper below it (optional / default / nullable / readonly / catch), plus
9743
+ * both sides of a pipe (`z.preprocess` puts the real schema on `out`,
9744
+ * `.transform` on `in`).
9745
+ */
9746
+ function layersOf(schema) {
9747
+ const out = [];
9748
+ const queue = [schema];
9749
+ while (queue.length > 0) {
9750
+ const s = queue.shift();
9751
+ if (!s || out.includes(s)) continue;
9752
+ out.push(s);
9753
+ const def = defOf(s);
9754
+ if (def.type !== void 0 && WRAPPERS.has(def.type) && def.innerType) queue.push(def.innerType);
9755
+ if (def.type === "pipe") {
9756
+ if (def.in) queue.push(def.in);
9757
+ if (def.out) queue.push(def.out);
9758
+ }
9759
+ }
9760
+ return out;
9761
+ }
9762
+ /** True when any layer of this field declares `.meta({ secret: true })`. */
9763
+ function isZodSecret(schema) {
9764
+ return layersOf(schema).some(declaredHere);
9765
+ }
9766
+ /**
9767
+ * Every declared secret path in a config schema: dotted keys, `[]` for "each
9768
+ * element of this array" (`connection.password`, `rows[].token`), `{}` for "each value of this
9769
+ * map" (`applianceSecrets{}`). A declared
9770
+ * object or array is reported as one path and not descended into — everything
9771
+ * under it is secret.
9772
+ */
9773
+ function collectZodSecretPaths(schema, prefix = "") {
9774
+ if (isZodSecret(schema)) return prefix.length > 0 ? [prefix] : [];
9775
+ const paths = [];
9776
+ for (const layer of layersOf(schema)) {
9777
+ const def = defOf(layer);
9778
+ if (def.type === "object" && def.shape) for (const [key, child] of Object.entries(def.shape)) paths.push(...collectZodSecretPaths(child, prefix ? `${prefix}.${key}` : key));
9779
+ else if (def.type === "array" && def.element) paths.push(...collectZodSecretPaths(def.element, `${prefix}[]`));
9780
+ else if (def.type === "record" && def.valueType) paths.push(...collectZodSecretPaths(def.valueType, `${prefix}{}`));
9781
+ else if (def.type === "union" && def.options) for (const option of def.options) paths.push(...collectZodSecretPaths(option, prefix));
9782
+ else if (def.type === "intersection") {
9783
+ if (def.left) paths.push(...collectZodSecretPaths(def.left, prefix));
9784
+ if (def.right) paths.push(...collectZodSecretPaths(def.right, prefix));
9785
+ }
9786
+ }
9787
+ return [...new Set(paths)];
9788
+ }
9789
+ /**
9790
+ * Record kinds (D881): the declaration an addon makes of one kind of fact, the
9791
+ * Zod row schema that describes it, and the helper that turns the two into the
9792
+ * JSON the engine stores.
9793
+ *
9794
+ * A kind is declared ONCE, next to the code that owns it, and the engine's
9795
+ * reconciler creates its indexes - nothing creates DDL at runtime from a write.
9796
+ * Everything the generic machinery needs (secret fields, the entry descriptor)
9797
+ * is DERIVED from the Zod schema: `.meta({ secret: true })` marks a credential,
9798
+ * never a field name (D879).
9799
+ */
9800
+ /** A JSON field path: `trackId`, `auth.accessToken`. Charset-restricted: it is rendered into DDL. */
9801
+ var RecordFieldPathSchema = string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,63}(\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,3}$/);
9802
+ var RecordIndexSchema = object({
9803
+ name: string().regex(/^[a-z][a-z0-9_]{0,31}$/),
9804
+ fields: array(RecordFieldPathSchema).min(1).max(4),
9805
+ unique: boolean().optional()
9806
+ }).strict();
9807
+ var RecordRetentionSchema = union([
9808
+ object({
9809
+ ttlMs: number$1().int().positive(),
9810
+ from: _enum(["at", "write"])
9811
+ }).strict(),
9812
+ object({
9813
+ maxRows: number$1().int().positive().optional(),
9814
+ maxRowsPerDevice: number$1().int().positive().optional()
9815
+ }).strict().refine((r) => r.maxRows !== void 0 || r.maxRowsPerDevice !== void 0, { message: "name maxRows, maxRowsPerDevice or both" }),
9816
+ object({
9817
+ owner: string().min(1),
9818
+ reason: string().min(1)
9819
+ }).strict(),
9820
+ object({ none: string().min(1) }).strict()
9821
+ ]);
9822
+ /** The descriptor a generic entries UI reads (D881 "generic entries"). */
9823
+ var EntryDescriptorSchema = object({
9824
+ title: string().min(1),
9825
+ /** The record body field shown as the entry's name. */
9826
+ nameField: string().min(1),
9827
+ /** JSON Schema of the entry body; `secret: true` marks a password field. */
9828
+ fields: record(string(), unknown())
9829
+ }).strict();
9830
+ /** What crosses the wire from an addon: JSON only, no Zod. An unknown key is refused. */
9831
+ var RecordKindDeclarationSchema = object({
9832
+ database: _enum(RECORD_DATABASES).default("settings"),
9833
+ class: _enum(RECORD_CLASSES),
9834
+ writeTrigger: string().min(1),
9835
+ device: _enum([
9836
+ "required",
9837
+ "optional",
9838
+ "none"
9839
+ ]).default("none"),
9840
+ at: _enum(["required", "none"]).default("none"),
9841
+ indexes: array(RecordIndexSchema).max(8).default([]),
9842
+ retention: RecordRetentionSchema,
9843
+ deviceRemoval: _enum(["delete", "none"]).default("none"),
9844
+ maxDataBytes: number$1().int().positive().max(RECORD_MAX_DATA_BYTES).default(RECORD_DEFAULT_MAX_DATA_BYTES),
9845
+ /** Derived from the Zod schema; never written by hand. */
9846
+ secretPaths: array(string().min(1)).default([]),
9847
+ /** The id key of each array that carries a secret (`{ headers: 'name' }`). */
9848
+ secretArrayKeys: record(string(), string()).default({}),
9849
+ /** Kinds that must agree through `transact` sit in one file under one family name. */
9850
+ transactFamily: string().min(1).optional(),
9851
+ entry: EntryDescriptorSchema.optional()
9852
+ }).strict();
9853
+ /** The kind names: lower-case, dash separated, short enough for an index name. */
9854
+ var RecordKindNameSchema = string().regex(/^[a-z][a-z0-9-]{0,47}$/);
9855
+ /** Cross-field rules a declaration must satisfy. Throws {@link DeclarationRefusedError}. */
9856
+ function assertValidDeclaration(name, decl) {
9857
+ const refuse = (why) => {
9858
+ throw new DeclarationRefusedError(`kind "${name}": ${why}`);
9859
+ };
9860
+ if (!RecordKindNameSchema.safeParse(name).success) refuse("the name must be lower-case-dashed");
9861
+ const seen = /* @__PURE__ */ new Set();
9862
+ for (const index of decl.indexes) {
9863
+ if (seen.has(index.name)) refuse(`index name "${index.name}" is declared twice`);
9864
+ seen.add(index.name);
9865
+ }
9866
+ const { retention } = decl;
9867
+ const hasSecrets = decl.secretPaths.length > 0;
9868
+ if ("ttlMs" in retention) {
9869
+ if (retention.from === "at" && decl.at !== "required") refuse("a ttl from \"at\" needs at: \"required\"");
9870
+ if (hasSecrets) refuse("a kind with secret fields cannot use engine TTL (its keychain values would be orphaned)");
9871
+ }
9872
+ if ("maxRows" in retention || "maxRowsPerDevice" in retention) {
9873
+ if (retention.maxRowsPerDevice !== void 0 && decl.device !== "required") refuse("maxRowsPerDevice needs device: \"required\"");
9874
+ if (decl.at !== "required") refuse("a row bound trims the oldest \"at\": it needs at: \"required\"");
9875
+ if (hasSecrets) refuse("a kind with secret fields cannot use an engine row bound");
9876
+ const bound = Math.max(retention.maxRows ?? 0, retention.maxRowsPerDevice ?? 0);
9877
+ 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)`);
9878
+ }
9879
+ if (decl.deviceRemoval === "delete" && decl.device === "none") refuse("deviceRemoval: \"delete\" needs a device: \"required\" or \"optional\"");
9880
+ 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`);
9881
+ }
9882
+ function bodyFieldsOf(schema) {
9883
+ return { ...toJSONSchema(schema, {
9884
+ unrepresentable: "any",
9885
+ io: "input"
9886
+ }) };
9887
+ }
9888
+ function isRecordLike(value) {
9889
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9890
+ }
9891
+ /** Does every branch of the body schema carry `field`? (A discriminated union has several.) */
9892
+ function hasNameField(json, field) {
9893
+ const branches = json["oneOf"] ?? json["anyOf"];
9894
+ if (Array.isArray(branches)) return branches.length > 0 && branches.every((b) => isRecordLike(b) && hasNameField(b, field));
9895
+ const properties = json["properties"];
9896
+ return isRecordLike(properties) && Object.prototype.hasOwnProperty.call(properties, field);
9897
+ }
9898
+ /**
9899
+ * Declare the kinds of a package. The keys are the kind names. The result is
9900
+ * what `ctx.records.declare` sends to the engine and what `ctx.records.kind`
9901
+ * types its handle from.
9902
+ */
9903
+ function defineRecordKinds(specs) {
9904
+ const out = {};
9905
+ for (const [name, spec] of Object.entries(specs)) {
9906
+ const secretPaths = collectZodSecretPaths(spec.schema);
9907
+ const nameField = spec.entry?.nameField ?? "name";
9908
+ const fields = bodyFieldsOf(spec.schema);
9909
+ if (spec.entry !== void 0 && !hasNameField(fields, nameField)) throw new DeclarationRefusedError(`kind "${name}": an entry kind's schema needs a "${nameField}" field`);
9910
+ const parsed = RecordKindDeclarationSchema.parse({
9911
+ database: spec.database,
9912
+ class: spec.class,
9913
+ writeTrigger: spec.writeTrigger,
9914
+ device: spec.device,
9915
+ at: spec.at,
9916
+ indexes: spec.indexes === void 0 ? void 0 : [...spec.indexes],
9917
+ retention: spec.retention,
9918
+ deviceRemoval: spec.deviceRemoval,
9919
+ maxDataBytes: spec.maxDataBytes,
9920
+ secretPaths: [...new Set([...secretPaths, ...spec.retiredSecretPaths ?? []])],
9921
+ secretArrayKeys: spec.secretArrayKeys === void 0 ? void 0 : { ...spec.secretArrayKeys },
9922
+ transactFamily: spec.transactFamily,
9923
+ entry: spec.entry === void 0 ? void 0 : {
9924
+ title: spec.entry.title,
9925
+ nameField,
9926
+ fields
9927
+ }
9928
+ });
9929
+ assertValidDeclaration(name, parsed);
9930
+ out[name] = {
9931
+ name,
9932
+ schema: spec.schema,
9933
+ declaration: parsed
9934
+ };
9935
+ }
9936
+ return out;
9937
+ }
9938
+ /**
9939
+ * Entry kinds (D881): every list of user-created entries (notification
9940
+ * targets, brokers, LLM profiles) is ONE record kind with an id, a name and a
9941
+ * kind-specific body, and the admin UI lists/adds/edits/deletes all of them
9942
+ * through the `record-entries` capability with password fields derived from
9943
+ * the schema (`.meta({ secret: true })`).
9944
+ */
9945
+ /** The body of a plain entry: a name and the fields the kind adds. */
9946
+ function entryBody(shape) {
9947
+ return object({
9948
+ name: string().min(1),
9949
+ enabled: boolean().optional(),
9950
+ ...shape
9951
+ });
9952
+ }
9953
+ /**
9954
+ * Declare one entry kind. An entry is configuration: never trimmed by the
9955
+ * engine, never aged, belonging to no device.
9956
+ */
9957
+ function defineEntryKind(name, spec) {
9958
+ const defined = defineRecordKinds({ [name]: {
9959
+ schema: spec.schema,
9960
+ class: "config",
9961
+ writeTrigger: spec.writeTrigger ?? "an operator adds or edits an entry in the entries UI",
9962
+ retention: { none: "an entry goes only when the operator deletes it" },
9963
+ ...spec.maxDataBytes === void 0 ? {} : { maxDataBytes: spec.maxDataBytes },
9964
+ ...spec.database === void 0 ? {} : { database: spec.database },
9965
+ ...spec.indexes === void 0 ? {} : { indexes: spec.indexes },
9966
+ ...spec.secretArrayKeys === void 0 ? {} : { secretArrayKeys: spec.secretArrayKeys },
9967
+ ...spec.retiredSecretPaths === void 0 ? {} : { retiredSecretPaths: spec.retiredSecretPaths },
9968
+ entry: {
9969
+ title: spec.title,
9970
+ ...spec.nameField === void 0 ? {} : { nameField: spec.nameField }
9971
+ }
9972
+ } })[name];
9973
+ if (defined === void 0) throw new DeclarationRefusedError(`entry kind "${name}" was not built`);
9974
+ return defined;
9975
+ }
9976
+ /**
9977
+ * The share-view scope kinds a share-link token can carry. The hub mints
9978
+ * and validates them (`ShareViewGrantSchema` builds its
9979
+ * enum from this list); a data plane names the kinds it ACCEPTS in `share`.
9980
+ */
9981
+ var SHARE_VIEW_KINDS = ["grid-view", "events-view"];
9982
+ /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
9983
+ var PeerBytesTicketSchema = object({
9984
+ /** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
9985
+ url: string().min(1),
9986
+ /**
9987
+ * The HOST node this URL means something on — the hub or a named agent,
9988
+ * never a runner. {@link AddonPeerBytes.open} compares it to its own and
9989
+ * refuses `cross-node` by name when they differ, without dialling.
9990
+ */
9991
+ hostNodeId: string().min(1),
9992
+ expiresAtMs: number$1().int().nonnegative(),
9993
+ /**
9994
+ * What the producer DECLARED the body to be, when it knows — `null` when it
9995
+ * does not. Never `0` for unknown (D393): a consumer sizing a bound off this
9996
+ * must be able to tell "the producer did not say" from "the body is empty".
9997
+ */
9998
+ declaredBytes: number$1().int().nonnegative().nullable()
9999
+ });
9966
10000
  var READING_QUANTITIES = [
9967
10001
  "temperature",
9968
10002
  "pressure",
@@ -10541,7 +10575,6 @@ var CAP_NAMES = Object.freeze([
10541
10575
  "climate-control",
10542
10576
  "color",
10543
10577
  "composer",
10544
- "connection-test",
10545
10578
  "connectivity",
10546
10579
  "consumables",
10547
10580
  "contact",
@@ -12561,8 +12594,7 @@ Object.freeze({
12561
12594
  "offerArtifact": 3e5,
12562
12595
  "readResult": 3e5,
12563
12596
  "submitJob": 18e5,
12564
- "uploadDataset": 72e5,
12565
- "validateCredentials": 3e4
12597
+ "uploadDataset": 72e5
12566
12598
  }
12567
12599
  });
12568
12600
  object({
@@ -14063,6 +14095,17 @@ function composeInstanceKinds(parts, base) {
14063
14095
  if (part === void 0) throw new UnknownInstanceKindError(kindId);
14064
14096
  return part;
14065
14097
  };
14098
+ /**
14099
+ * The part a DRAFT test runs on. A named kind is that kind; an unnamed one is the provider's only
14100
+ * testable kind. Several testable kinds and none named is a typed refusal that lists them, never a throw.
14101
+ */
14102
+ const draftPart = (kindId) => {
14103
+ if (kindId !== void 0) return parts.find((candidate) => candidate.kind.id === kindId) ?? { error: `Unknown instance kind '${kindId}'` };
14104
+ const testable = parts.filter((part) => part.kind.canTest !== false);
14105
+ const only = testable.length === 1 ? testable[0] : parts.length === 1 ? parts[0] : void 0;
14106
+ if (only !== void 0) return only;
14107
+ return { error: `This provider has several kinds: name one of ${(testable.length > 0 ? testable : parts).map((part) => part.kind.id).join(", ")}` };
14108
+ };
14066
14109
  const holderOf = async (id) => {
14067
14110
  for (const part of parts) if (await part.provider.getInstance({ id }) !== null) return part;
14068
14111
  throw new UnknownInstanceError(id);
@@ -14098,7 +14141,13 @@ function composeInstanceKinds(parts, base) {
14098
14141
  setInstanceEnabled: async (input) => (await holderOf(input.id)).provider.setInstanceEnabled(input),
14099
14142
  deleteInstance: async (input) => (await holderOf(input.id)).provider.deleteInstance(input),
14100
14143
  testInstance: async (input) => {
14101
- const test = (input.id === null ? byKind(input.kind) : await holderOf(input.id)).provider.testInstance;
14144
+ const resolved = input.id === null ? draftPart(input.kind) : await holderOf(input.id);
14145
+ if ("error" in resolved) return {
14146
+ success: false,
14147
+ status: "inconclusive",
14148
+ error: resolved.error
14149
+ };
14150
+ const test = resolved.provider.testInstance;
14102
14151
  if (test === void 0) return {
14103
14152
  success: false,
14104
14153
  status: "inconclusive",
@@ -14108,6 +14157,745 @@ function composeInstanceKinds(parts, base) {
14108
14157
  }
14109
14158
  };
14110
14159
  }
14160
+ /**
14161
+ * notification-output — canonical, capability-gated notification delivery.
14162
+ *
14163
+ * Apprise-derived model (see
14164
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
14165
+ * callers emit ONE canonical `Notification`; each provider declares a
14166
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
14167
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
14168
+ * message to what the kind supports — callers never special-case a service.
14169
+ *
14170
+ * DESIGN DECISIONS (locked):
14171
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
14172
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
14173
+ * cap. Rationale: the admin UI needs one uniform surface across the
14174
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
14175
+ * alternative would fork the UI per addon and cannot host the
14176
+ * discovery→adopt flow.
14177
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
14178
+ * the generated cap-mount auto-`concatCollection`-fans them across every
14179
+ * registered provider (notifiers addon + HA addon) so one catalog is
14180
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
14181
+ * `addonId` the generated collection router extracts from the call input.
14182
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
14183
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
14184
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
14185
+ * base64 fallback needed.
14186
+ *
14187
+ * TODO (deferred, closed-set change — separate decision): add
14188
+ * `providerKind: 'notify'` so notification providers surface on the unified
14189
+ * admin "Integrations" page.
14190
+ */
14191
+ /**
14192
+ * Zentik-derived typed-media enum — the superset across every kind. Each
14193
+ * adapter picks what it supports and the degrade engine filters the rest.
14194
+ */
14195
+ var AttachmentMediaTypeSchema = _enum([
14196
+ "image",
14197
+ "video",
14198
+ "gif",
14199
+ "audio",
14200
+ "icon"
14201
+ ]);
14202
+ /**
14203
+ * A single attachment. Exactly one of `url` (remote source, most adapters
14204
+ * prefer this) or `bytes` (inline source; required for Pushover-style
14205
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
14206
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
14207
+ */
14208
+ var AttachmentSchema = object({
14209
+ mediaType: AttachmentMediaTypeSchema,
14210
+ url: string().optional(),
14211
+ bytes: _instanceof(Uint8Array).optional(),
14212
+ mime: string().optional(),
14213
+ name: string().optional()
14214
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
14215
+ var NotificationFormatSchema = _enum([
14216
+ "text",
14217
+ "markdown",
14218
+ "html"
14219
+ ]);
14220
+ /**
14221
+ * The CLOSED icon vocabulary an action button may use.
14222
+ *
14223
+ * A closed set, not a free string, and that is the whole point: an arbitrary
14224
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
14225
+ * notices — the same class of gap as a zone vocabulary nothing produced
14226
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
14227
+ * declares `actionIcons: false` and the degrade engine strips the field.
14228
+ *
14229
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
14230
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
14231
+ * differently.
14232
+ *
14233
+ * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
14234
+ *
14235
+ * These names are for US. **No adapter may forward one verbatim.** Each maps
14236
+ * the whole set onto its own renderer's vocabulary through a
14237
+ * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
14238
+ * fallback, so adding a member here fails every adapter's build until someone
14239
+ * decides its glyph, which is the only place that decision can be made
14240
+ * honestly.
14241
+ *
14242
+ * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
14243
+ * `disarm` straight through; iOS feeds that string to
14244
+ * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
14245
+ * and every snooze and alarm button arrived BLANK. A pass-through is not a
14246
+ * mapping, and "the field is documented" is not "the value renders".
14247
+ *
14248
+ * Adding a member is TRAIN-BOUND. The enum lives in the published
14249
+ * `@camstack/server` closure and the cap seam validates against the HUB's copy,
14250
+ * so an addon that emits a token the running hub does not know does not lose an
14251
+ * icon — its whole `send` fails Zod validation and the notification never
14252
+ * arrives. Never emit a new token from an addon before the train carrying it.
14253
+ */
14254
+ var NotificationActionIconSchema = _enum([
14255
+ "acknowledge",
14256
+ "dismiss",
14257
+ "silence",
14258
+ "snooze",
14259
+ "view",
14260
+ "play",
14261
+ "open",
14262
+ "close",
14263
+ "lock",
14264
+ "unlock",
14265
+ "arm",
14266
+ "arm-home",
14267
+ "arm-away",
14268
+ "arm-night",
14269
+ "disarm",
14270
+ "light",
14271
+ "alert",
14272
+ "camera"
14273
+ ]);
14274
+ /** A single tap-through action button. */
14275
+ var NotificationActionSchema = object({
14276
+ id: string(),
14277
+ label: string(),
14278
+ url: string().optional(),
14279
+ /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
14280
+ icon: NotificationActionIconSchema.optional(),
14281
+ /**
14282
+ * Renders in a warning style where the notifier supports it.
14283
+ *
14284
+ * A HINT, never a gate. The callback's authority is its token and nothing
14285
+ * else — see `notification-center/action-token.ts` for what that does and
14286
+ * does not buy.
14287
+ */
14288
+ destructive: boolean().optional(),
14289
+ /**
14290
+ * How the tap should REACH the url.
14291
+ *
14292
+ * `navigate` (absent, and every button authored before this field) opens it:
14293
+ * the phone leaves the notification and shows whatever the callback returns.
14294
+ * That is right for a button whose answer the operator wants to read.
14295
+ *
14296
+ * `background` fires it as a POST and stays put. It exists for the buttons
14297
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
14298
+ * an answer to the notification, and being thrown into a browser tab to
14299
+ * confirm it costs more attention than the notification did. A backend that
14300
+ * cannot do a background call renders it as an ordinary link (the adapters
14301
+ * fall back rather than dropping the button), so this is a preference, never
14302
+ * a requirement.
14303
+ */
14304
+ mode: _enum(["navigate", "background"]).optional()
14305
+ });
14306
+ /** A delivery parameter value: what a notifier declared it takes. */
14307
+ var DeliveryParamValueSchema = union([
14308
+ string(),
14309
+ number$1(),
14310
+ boolean()
14311
+ ]);
14312
+ /**
14313
+ * One extra delivery parameter a notifier KIND declares (D938). A rule that
14314
+ * addresses a target of that kind may set it; a kind that declares nothing is
14315
+ * never handed one. The admin UI derives its control from this descriptor —
14316
+ * no editor knows a service by name.
14317
+ *
14318
+ * Optional fields are `.optional()`, never `.default()` (a Zod default does not
14319
+ * run on the addon cap path).
14320
+ */
14321
+ var DeliveryParamSchema = object({
14322
+ key: string().min(1),
14323
+ label: string(),
14324
+ /** i18n key of the label, resolved by the declaring addon's namespace. */
14325
+ labelKey: string().optional(),
14326
+ type: _enum([
14327
+ "enum",
14328
+ "number",
14329
+ "boolean",
14330
+ "string"
14331
+ ]),
14332
+ /** `enum` only: the closed set of accepted values. */
14333
+ options: array(object({
14334
+ value: string(),
14335
+ label: string()
14336
+ })).optional(),
14337
+ /** `number` only. */
14338
+ min: number$1().optional(),
14339
+ max: number$1().optional(),
14340
+ /** What the notifier does when the rule sets nothing; shown as the placeholder. */
14341
+ default: DeliveryParamValueSchema.optional()
14342
+ });
14343
+ /**
14344
+ * The canonical notification. `body` is the only hard field (Apprise model).
14345
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
14346
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
14347
+ * the adapter maps this ordinal onto its native level. `level?` is an
14348
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
14349
+ * `priority` for that one target.
14350
+ */
14351
+ var NotificationSchema = object({
14352
+ body: string(),
14353
+ title: string().optional(),
14354
+ format: NotificationFormatSchema.default("text"),
14355
+ priority: number$1().int().min(1).max(5).default(3),
14356
+ level: string().optional(),
14357
+ attachments: array(AttachmentSchema).optional(),
14358
+ clickUrl: string().optional(),
14359
+ actions: array(NotificationActionSchema).optional(),
14360
+ sound: string().optional(),
14361
+ ttl: number$1().optional(),
14362
+ tag: string().optional(),
14363
+ deviceId: number$1().optional(),
14364
+ eventId: string().optional(),
14365
+ metadata: record(string(), unknown()).optional(),
14366
+ /**
14367
+ * Values for the parameters the target's kind DECLARES in
14368
+ * {@link TargetKindSchema} `deliveryParams`. Already validated against the
14369
+ * declaration by the sender; a notifier reads only the keys it declared.
14370
+ */
14371
+ delivery: record(string(), DeliveryParamValueSchema).optional()
14372
+ });
14373
+ /** One declared native severity/priority level for a kind. */
14374
+ var TargetKindLevelSchema = object({
14375
+ id: string(),
14376
+ label: string(),
14377
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
14378
+ ordinal: number$1().int().min(1).max(5).nullable(),
14379
+ flags: object({
14380
+ critical: boolean().optional(),
14381
+ silent: boolean().optional(),
14382
+ noPush: boolean().optional()
14383
+ }).optional(),
14384
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
14385
+ requires: array(string()).optional(),
14386
+ description: string().optional()
14387
+ });
14388
+ /** The full capability block consulted before dispatch. */
14389
+ var TargetKindCapsSchema = object({
14390
+ attachments: object({
14391
+ mediaTypes: array(AttachmentMediaTypeSchema),
14392
+ mode: _enum([
14393
+ "url",
14394
+ "bytes",
14395
+ "both"
14396
+ ]),
14397
+ max: number$1().int().nonnegative(),
14398
+ maxBytes: number$1().int().positive().optional()
14399
+ }),
14400
+ /** Max action buttons (0 = none). */
14401
+ actions: number$1().int().nonnegative(),
14402
+ /**
14403
+ * Whether this kind renders a per-action ICON.
14404
+ *
14405
+ * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
14406
+ * run on the addon cap path — three production failures in one day taught
14407
+ * this repo that once. Absent is read as false by the degrade engine, which
14408
+ * is the safe direction: an icon that is not rendered costs nothing, an icon
14409
+ * assumed and dropped costs the operator's trust in the field.
14410
+ */
14411
+ actionIcons: boolean().optional(),
14412
+ levels: array(TargetKindLevelSchema),
14413
+ format: array(NotificationFormatSchema),
14414
+ clickUrl: boolean(),
14415
+ sound: boolean(),
14416
+ ttl: boolean(),
14417
+ bodyMaxLen: number$1().int().positive()
14418
+ });
14419
+ /**
14420
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
14421
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
14422
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
14423
+ * the union is large and not meant for runtime validation here; the exported
14424
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
14425
+ */
14426
+ var ConfigSchemaPassthrough$4 = unknown();
14427
+ var TargetKindSchema = object({
14428
+ kind: string(),
14429
+ label: string(),
14430
+ icon: string(),
14431
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
14432
+ addonId: string(),
14433
+ /**
14434
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
14435
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
14436
+ * when the addon bundles no icon for that kind — the client then falls back
14437
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
14438
+ *
14439
+ * Root-relative on purpose: it resolves against whatever origin serves a web
14440
+ * client, and a native client joins it onto its own hub base.
14441
+ *
14442
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
14443
+ * field that survived only because the runtime cap-router forwards provider
14444
+ * output verbatim — so every consumer had to re-declare it by hand to stop
14445
+ * its own Zod parse from stripping it, and the whole arrangement would have
14446
+ * broken silently the moment output validation was tightened anywhere.
14447
+ */
14448
+ iconUrl: string().optional(),
14449
+ /**
14450
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
14451
+ *
14452
+ * The server knows this and therefore says it, because the client cannot
14453
+ * safely guess: a React-Native client renders SVG and raster through two
14454
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
14455
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
14456
+ * placeholder glyph for every vector icon while the web build looked fine.
14457
+ *
14458
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
14459
+ * not been updated — a client that cannot determine the type should prefer
14460
+ * its raster path, which is the safe default for an unknown image.
14461
+ */
14462
+ iconMediaType: string().optional(),
14463
+ configSchema: ConfigSchemaPassthrough$4,
14464
+ /**
14465
+ * The config keys that decide WHERE this kind's secrets are sent (server,
14466
+ * base URL, instance, chat, recipient). A save that changes any of them
14467
+ * while leaving a secret redacted is refused by the provider (D634), so an
14468
+ * editor uses this to ask for the secret on exactly those edits instead of
14469
+ * on every edit.
14470
+ *
14471
+ * Optional: absent from a provider that holds no secret (the Home Assistant
14472
+ * kinds) and from a provider older than this field — an editor that finds
14473
+ * it absent must assume ANY edit may need the secret.
14474
+ */
14475
+ destinationKeys: array(string()).optional(),
14476
+ supportsDiscovery: boolean(),
14477
+ /**
14478
+ * Extra delivery parameters a rule may set for a target of this kind (D938).
14479
+ * Absent = none; the rule editor then offers nothing.
14480
+ */
14481
+ deliveryParams: array(DeliveryParamSchema).optional(),
14482
+ caps: TargetKindCapsSchema
14483
+ });
14484
+ /**
14485
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
14486
+ * (return a presence marker only) when serving `listTargets` — never
14487
+ * round-trip a stored secret to the UI.
14488
+ */
14489
+ var TargetSchema = object({
14490
+ id: string(),
14491
+ name: string(),
14492
+ kind: string(),
14493
+ addonId: string(),
14494
+ enabled: boolean(),
14495
+ config: record(string(), unknown())
14496
+ });
14497
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
14498
+ var DiscoveredTargetSchema = object({
14499
+ kind: string(),
14500
+ suggestedName: string(),
14501
+ config: record(string(), unknown())
14502
+ });
14503
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
14504
+ var RenderedAsSchema = object({
14505
+ level: string(),
14506
+ format: NotificationFormatSchema,
14507
+ attachmentsSent: number$1().int().nonnegative(),
14508
+ actionsSent: number$1().int().nonnegative(),
14509
+ truncated: boolean(),
14510
+ dropped: array(string())
14511
+ });
14512
+ var SendResultSchema = object({
14513
+ success: boolean(),
14514
+ error: string().optional(),
14515
+ renderedAs: RenderedAsSchema.optional()
14516
+ });
14517
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
14518
+ var TestResultSchema = SendResultSchema;
14519
+ /**
14520
+ * Pre-save credential probe against a partial (unsaved) target config.
14521
+ * `resolvedUrl` never includes secrets (token stripped from path).
14522
+ */
14523
+ var ProbeTargetConfigResultSchema = object({
14524
+ ok: boolean(),
14525
+ resolvedUrl: string(),
14526
+ httpStatus: number$1().int().optional(),
14527
+ stateInstance: string().optional(),
14528
+ error: string().optional()
14529
+ });
14530
+ var notificationOutputCapability = {
14531
+ name: "notification-output",
14532
+ scope: "system",
14533
+ mode: "collection",
14534
+ methods: {
14535
+ listTargetKinds: method(object({}), array(TargetKindSchema)),
14536
+ listTargets: method(object({}), array(TargetSchema), { auth: "admin" }),
14537
+ /**
14538
+ * One target WITH secrets — admin edit / duplicate hydration only.
14539
+ * `listTargets` stays redacted; the editor loads this so password fields
14540
+ * are filled (masked in the input) and a save can round-trip the token
14541
+ * when a destination changes (Duplicate with a new chatId, etc.).
14542
+ */
14543
+ getTarget: method(object({ targetId: string() }), TargetSchema, { auth: "admin" }),
14544
+ discoverTargets: method(object({
14545
+ kind: string(),
14546
+ config: record(string(), unknown()).optional(),
14547
+ /**
14548
+ * When editing / duplicating, the blank password field never round-trips
14549
+ * the stored token. Pass the source target id so the provider can restore
14550
+ * secrets server-side before dialling (same merge as upsert).
14551
+ */
14552
+ targetId: string().optional()
14553
+ }), array(DiscoveredTargetSchema), { auth: "admin" }),
14554
+ probeTargetConfig: method(object({
14555
+ kind: string(),
14556
+ config: record(string(), unknown()),
14557
+ /** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
14558
+ targetId: string().optional()
14559
+ }), ProbeTargetConfigResultSchema, {
14560
+ kind: "mutation",
14561
+ auth: "admin"
14562
+ }),
14563
+ send: method(object({
14564
+ targetId: string(),
14565
+ notification: NotificationSchema
14566
+ }), SendResultSchema, {
14567
+ kind: "mutation",
14568
+ auth: "admin"
14569
+ }),
14570
+ testTarget: method(object({
14571
+ targetId: string(),
14572
+ sample: NotificationSchema.optional()
14573
+ }), TestResultSchema, {
14574
+ kind: "mutation",
14575
+ auth: "admin"
14576
+ }),
14577
+ upsertTarget: method(object({
14578
+ target: TargetSchema,
14579
+ /**
14580
+ * Duplicate flow: create a NEW target id while copying secrets from an
14581
+ * existing row. The UI never holds the stored token; blank / redacted
14582
+ * password fields are restored from this source (D634 still applies if
14583
+ * destinations diverge without a re-typed secret).
14584
+ */
14585
+ cloneSecretsFrom: string().optional()
14586
+ }), TargetSchema, {
14587
+ kind: "mutation",
14588
+ auth: "admin"
14589
+ }),
14590
+ deleteTarget: method(object({ targetId: string() }), _void(), {
14591
+ kind: "mutation",
14592
+ auth: "admin"
14593
+ }),
14594
+ setTargetEnabled: method(object({
14595
+ targetId: string(),
14596
+ enabled: boolean()
14597
+ }), _void(), {
14598
+ kind: "mutation",
14599
+ auth: "admin"
14600
+ })
14601
+ }
14602
+ };
14603
+ /**
14604
+ * Notification targets as instances of the function cap `notification-output` (epic #440, step 6).
14605
+ *
14606
+ * A notification target used to be one row of one record kind (`{ name, kind, addonId, enabled,
14607
+ * config }`) with a free-form `config` blob whose secrets nobody could name. Now every target KIND
14608
+ * the provider offers (ntfy, telegram, webhook, ...) is a record kind of its own whose flat body IS
14609
+ * that kind's form: the adapter's `configSchema` lifted into a Zod object, a `password` field marked
14610
+ * `.meta({ secret: true })` so the records door keeps it in the keychain, an `instance-select` field
14611
+ * declared as a link so the hub refuses to delete what a target points at.
14612
+ *
14613
+ * Three providers (notifiers, Home Assistant, Alexa) serve the same shape, so the pieces are shared:
14614
+ * - {@link defineNotificationTargetKind}: the record kind of one `TargetKind` descriptor;
14615
+ * - {@link NotificationTargetStore}: the `Target`-shaped facade the existing provider code keeps
14616
+ * using, over one handle per kind (the old `notification-output` CRUD keeps working on it);
14617
+ * - {@link buildNotificationTargetInstances}: the `integration-instances` provider, one kind each;
14618
+ * - {@link moveTargetRows}: the D987 procedure that flattens the old `config` blobs into the bodies.
14619
+ */
14620
+ /** The function cap every notification target is an instance of. */
14621
+ var NOTIFICATION_OUTPUT_CAP = "notification-output";
14622
+ /** The most targets one provider lists (a settings page, not a table). */
14623
+ var TARGETS_LIST_MAX = 1e3;
14624
+ /** The record kind name of one target kind: `telegram` is stored under `notify-telegram`. */
14625
+ function notificationTargetKindName(kind) {
14626
+ return `notify-${kind}`;
14627
+ }
14628
+ /** The body schema of a target kind: the entry's name and switch plus the adapter's fields. */
14629
+ var TargetBodyBaseSchema = looseObject({
14630
+ name: string().min(1),
14631
+ enabled: boolean().optional()
14632
+ });
14633
+ /** The shape of one `editable-array` row: the item fields, a `password` one a secret. */
14634
+ function rowSchemaOf(itemFields) {
14635
+ const shape = {};
14636
+ let idKey = null;
14637
+ let hasSecret = false;
14638
+ for (const item of itemFields) {
14639
+ if (item.type === "password") {
14640
+ shape[item.key] = string().optional().meta({ secret: true });
14641
+ hasSecret = true;
14642
+ continue;
14643
+ }
14644
+ shape[item.key] = unknown().optional();
14645
+ idKey ??= item.key;
14646
+ }
14647
+ return {
14648
+ row: looseObject(shape),
14649
+ idKey,
14650
+ hasSecret
14651
+ };
14652
+ }
14653
+ /**
14654
+ * The Zod fields of a descriptor's form. A `password` field is a secret; an `instance-select` field is
14655
+ * a link to an instance of its function cap; an `editable-array` keeps its rows (a password inside a
14656
+ * row is a secret matched by the row's first plain field); every other field keeps whatever the
14657
+ * adapter's own parse accepts, because the form already validated its shape. The body is loose, so a key
14658
+ * the schema does not know (the owner of a personal target) survives a write.
14659
+ */
14660
+ function fieldsOf(descriptor) {
14661
+ const shape = {};
14662
+ const secretArrayKeys = {};
14663
+ for (const section of descriptor.configSchema.sections) for (const field of section.fields) switch (field.type) {
14664
+ case "info": break;
14665
+ case "password":
14666
+ shape[field.key] = string().optional().meta({ secret: true });
14667
+ break;
14668
+ case "instance-select":
14669
+ shape[field.key] = optionalLinkField({
14670
+ cap: field.functionCap,
14671
+ ...field.kind !== void 0 ? { kind: field.kind } : {}
14672
+ });
14673
+ break;
14674
+ case "editable-array": {
14675
+ const { row, idKey, hasSecret } = rowSchemaOf(field.itemFields);
14676
+ shape[field.key] = array(row).optional();
14677
+ if (hasSecret && idKey !== null) secretArrayKeys[field.key] = idKey;
14678
+ break;
14679
+ }
14680
+ default: shape[field.key] = unknown().optional();
14681
+ }
14682
+ return {
14683
+ shape,
14684
+ secretArrayKeys
14685
+ };
14686
+ }
14687
+ /** The record kind of one target kind, derived from its descriptor. */
14688
+ function defineNotificationTargetKind(descriptor) {
14689
+ const { shape, secretArrayKeys } = fieldsOf(descriptor);
14690
+ return defineEntryKind(notificationTargetKindName(descriptor.kind), {
14691
+ title: `${descriptor.label} notification targets`,
14692
+ schema: TargetBodyBaseSchema.extend(shape),
14693
+ writeTrigger: `an operator adds, edits, switches or removes a ${descriptor.label} notification target`,
14694
+ ...Object.keys(secretArrayKeys).length > 0 ? { secretArrayKeys } : {}
14695
+ });
14696
+ }
14697
+ /** The record kinds of a provider's descriptors, keyed by the record kind name (for `records.declare`). */
14698
+ function notificationTargetKinds(descriptors) {
14699
+ return Object.fromEntries(descriptors.map((descriptor) => {
14700
+ const defined = defineNotificationTargetKind(descriptor);
14701
+ return [defined.name, defined];
14702
+ }));
14703
+ }
14704
+ /** The body a stored target becomes: the switch and the name, and the config fields flat beside them. */
14705
+ function targetBody(target) {
14706
+ return {
14707
+ ...target.config,
14708
+ name: target.name.length > 0 ? target.name : target.id,
14709
+ enabled: target.enabled
14710
+ };
14711
+ }
14712
+ /** The `Target` a record of a kind is; null when the row is not a target. */
14713
+ function targetOfRecord(addonId, kind, id, data) {
14714
+ const { name, enabled: _enabled, ...config } = data;
14715
+ const parsed = TargetSchema.safeParse({
14716
+ id,
14717
+ name,
14718
+ kind,
14719
+ addonId,
14720
+ enabled: isInstanceEnabled(data),
14721
+ config
14722
+ });
14723
+ return parsed.success ? parsed.data : null;
14724
+ }
14725
+ function targetOfOld(addonId, row) {
14726
+ return {
14727
+ id: row.id,
14728
+ name: row.name,
14729
+ kind: row.kind,
14730
+ addonId,
14731
+ enabled: row.enabled,
14732
+ config: { ...row.config }
14733
+ };
14734
+ }
14735
+ /**
14736
+ * The `Target`-shaped facade over one record kind per target kind. `list` returns the stored form (a
14737
+ * secret is a keychain marker: every reader redacts it anyway); `getById` returns the target with its
14738
+ * secrets resolved, which is what a send, a probe and an edit's secret merge need. A keychain that
14739
+ * cannot answer throws: a write never goes ahead with a secret it could not read (D49).
14740
+ */
14741
+ var NotificationTargetStore = class NotificationTargetStore {
14742
+ addonId;
14743
+ handles;
14744
+ options;
14745
+ constructor(addonId, handles, options) {
14746
+ this.addonId = addonId;
14747
+ this.handles = handles;
14748
+ this.options = options;
14749
+ }
14750
+ static of(records, addonId, kinds, descriptors, options = {}) {
14751
+ const handles = [];
14752
+ for (const descriptor of descriptors) {
14753
+ const defined = kinds[notificationTargetKindName(descriptor.kind)];
14754
+ if (defined === void 0) continue;
14755
+ handles.push({
14756
+ kind: descriptor.kind,
14757
+ handle: records.kind(defined)
14758
+ });
14759
+ }
14760
+ return new NotificationTargetStore(addonId, handles, options);
14761
+ }
14762
+ /** The record handle of each kind, for the migration ports. */
14763
+ handlesByKind() {
14764
+ return new Map(this.handles.map((entry) => [entry.kind, entry.handle]));
14765
+ }
14766
+ async list() {
14767
+ const out = [];
14768
+ for (const { kind, handle } of this.handles) {
14769
+ const page = await handle.query({ limit: TARGETS_LIST_MAX });
14770
+ for (const record of page.records) {
14771
+ const target = targetOfRecord(this.addonId, kind, record.id, record.data);
14772
+ if (target !== null) {
14773
+ out.push(target);
14774
+ continue;
14775
+ }
14776
+ this.options.logger?.warn("notification target record is not a valid target; left out of the list", { meta: {
14777
+ targetId: record.id,
14778
+ kind
14779
+ } });
14780
+ }
14781
+ }
14782
+ const held = new Set(out.map((target) => target.id));
14783
+ for (const row of await this.legacyRows()) if (!held.has(row.id)) out.push(targetOfOld(this.addonId, row));
14784
+ return out;
14785
+ }
14786
+ /** The legacy rows of a kind this provider serves (the others are not ours to show). */
14787
+ async legacyRows() {
14788
+ const legacy = this.options.legacy;
14789
+ if (legacy === void 0) return [];
14790
+ const served = new Set(this.handles.map((entry) => entry.kind));
14791
+ return (await legacy.list()).filter((row) => served.has(row.kind));
14792
+ }
14793
+ async getById(id) {
14794
+ for (const { kind, handle } of this.handles) {
14795
+ const row = await handle.getResolved(id);
14796
+ if (row !== null) return targetOfRecord(this.addonId, kind, row.id, row.data);
14797
+ }
14798
+ const old = (await this.legacyRows()).find((row) => row.id === id);
14799
+ return old === void 0 ? null : targetOfOld(this.addonId, old);
14800
+ }
14801
+ /** Insert or replace by id; a target whose kind changed leaves the record of its old kind. */
14802
+ async upsert(target) {
14803
+ const own = this.handles.find((entry) => entry.kind === target.kind);
14804
+ if (own === void 0) throw new Error(`invalid target '${target.id}': unknown kind '${target.kind}'`);
14805
+ await own.handle.put({
14806
+ id: target.id,
14807
+ data: targetBody(target)
14808
+ });
14809
+ for (const other of this.handles) if (other !== own) await other.handle.delete(target.id);
14810
+ await this.options.legacy?.delete(target.id);
14811
+ }
14812
+ async delete(id) {
14813
+ for (const { handle } of this.handles) await handle.delete(id);
14814
+ await this.options.legacy?.delete(id);
14815
+ }
14816
+ /** Flip the switch on an existing target; null when it does not exist. */
14817
+ async setEnabled(id, enabled) {
14818
+ for (const { kind, handle } of this.handles) {
14819
+ if (await handle.get(id) === null) continue;
14820
+ await handle.patch(id, { enabled });
14821
+ const row = await handle.get(id);
14822
+ return row === null ? null : targetOfRecord(this.addonId, kind, row.id, row.data);
14823
+ }
14824
+ const old = (await this.legacyRows()).find((row) => row.id === id);
14825
+ if (old === void 0) return null;
14826
+ const moved = {
14827
+ ...targetOfOld(this.addonId, old),
14828
+ enabled
14829
+ };
14830
+ await this.upsert(moved);
14831
+ return moved;
14832
+ }
14833
+ };
14834
+ /**
14835
+ * A probe's answer as the tri-state of `testInstance`: `ok` validated; a refusal that carries an HTTP
14836
+ * status is the remote saying no (`rejected`); a failure with no answer at all (the network, a missing
14837
+ * field) proves nothing about the credentials (`inconclusive`).
14838
+ */
14839
+ function probeToTestResult(probed) {
14840
+ if (probed.ok) return {
14841
+ success: true,
14842
+ status: "validated",
14843
+ ...probed.stateInstance !== void 0 ? { detail: probed.stateInstance } : {}
14844
+ };
14845
+ const error = probed.error ?? "the probe failed";
14846
+ return {
14847
+ success: false,
14848
+ status: probed.httpStatus !== void 0 ? "rejected" : "inconclusive",
14849
+ error
14850
+ };
14851
+ }
14852
+ /** The form of a kind: the adapter's own sections. */
14853
+ function formOf(descriptor) {
14854
+ return descriptor.configSchema;
14855
+ }
14856
+ /** The instance kinds of a notification provider, one per target kind: a part a composition can take. */
14857
+ function notificationTargetParts(records, spec) {
14858
+ return spec.descriptors.flatMap((descriptor) => {
14859
+ const defined = spec.kinds[notificationTargetKindName(descriptor.kind)];
14860
+ if (defined === void 0) return [];
14861
+ const canTest = spec.probe !== void 0 && spec.canProbe(descriptor.kind);
14862
+ const instances = new RecordIntegrationInstances(records, defined, {
14863
+ description: {
14864
+ role: "services",
14865
+ instanceMode: "multiple",
14866
+ kind: "target",
14867
+ canTest
14868
+ },
14869
+ formSchema: () => formOf(descriptor),
14870
+ initialBody: (name) => ({ name }),
14871
+ mintId: () => spec.mintId(),
14872
+ configured: () => true,
14873
+ test: async ({ id, values }) => {
14874
+ if (spec.probe === void 0 || !canTest) return {
14875
+ success: false,
14876
+ status: "unsupported",
14877
+ error: "This kind cannot be tested"
14878
+ };
14879
+ return probeToTestResult(await spec.probe({
14880
+ kind: descriptor.kind,
14881
+ config: values,
14882
+ targetId: id ?? void 0
14883
+ }));
14884
+ }
14885
+ });
14886
+ return [{
14887
+ kind: {
14888
+ id: descriptor.kind,
14889
+ label: descriptor.label,
14890
+ functionCap: NOTIFICATION_OUTPUT_CAP,
14891
+ role: "services",
14892
+ canTest
14893
+ },
14894
+ provider: instances.toProvider()
14895
+ }];
14896
+ });
14897
+ }
14898
+ TargetSchema.omit({ id: true });
14111
14899
  var EncodeProfileSchema = object({
14112
14900
  video: object({
14113
14901
  codec: _enum([
@@ -22433,7 +23221,6 @@ var SYSTEM_CAP_VOCABULARY = {
22433
23221
  "calendar-source": system(INTERNAL),
22434
23222
  calendars: system("calendars feed schedules and conditions, not device actions"),
22435
23223
  composer: system("device composition — an authoring tool, not an action"),
22436
- "connection-test": system(INTERNAL),
22437
23224
  "core-blocks": system(INTERNAL),
22438
23225
  "custom-model-registry": system(ML),
22439
23226
  "data-store-provider": system(INTERNAL),
@@ -44410,449 +45197,6 @@ var UnitWarningSchema = object({
44410
45197
  assumedUnit: string(),
44411
45198
  message: string()
44412
45199
  });
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
45200
  _enum([
44857
45201
  "automation",
44858
45202
  "action",
@@ -49456,37 +49800,6 @@ method(object({
49456
49800
  kind: "mutation",
49457
49801
  auth: "admin"
49458
49802
  }), 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
49803
  /**
49491
49804
  * Upstream-system connectivity sensor — distinct from `device-status`,
49492
49805
  * which is the kernel-managed online/offline flag for the device's
@@ -56640,10 +56953,9 @@ var AvailableIntegrationTypeSchema = object({
56640
56953
  * locations" checkbox. Provider-declared in the addon manifest. */
56641
56954
  supportsLocationImport: boolean(),
56642
56955
  /**
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.
56956
+ * True when this integration's instances declare a Test (`integration-instances.describe().canTest`,
56957
+ * D1016). An integration that cannot be tested must say so up front rather than offering a
56958
+ * button that always answers the same nonsense.
56647
56959
  */
56648
56960
  canTest: boolean(),
56649
56961
  existingInstances: array(object({
@@ -56697,7 +57009,9 @@ var CreateIntegrationInputSchema = object({
56697
57009
  info: record(string(), unknown()).optional(),
56698
57010
  /** `stableId` of the discovery candidate the form was seeded from (D984). */
56699
57011
  originStableId: string().optional(),
56700
- settings: record(string(), unknown()).optional()
57012
+ settings: record(string(), unknown()).optional(),
57013
+ /** The instance kind being created, for a provider with several; omitted = its only testable kind. */
57014
+ kind: string().min(1).optional()
56701
57015
  });
56702
57016
  var UpdateIntegrationInputSchema = object({
56703
57017
  id: string(),
@@ -56723,13 +57037,7 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
56723
57037
  }), method(object({ id: string() }), DeleteIntegrationResultSchema, {
56724
57038
  kind: "mutation",
56725
57039
  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
- });
57040
+ }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" });
56733
57041
  /**
56734
57042
  * `integration-instances` - the connection of an addon that holds ONE or SEVERAL
56735
57043
  * (an account, a relay, a destination) as typed records (D985, D986). The addon's
@@ -65472,13 +65780,6 @@ var AddBrokerInputSchema = object({
65472
65780
  });
65473
65781
  var AddBrokerResultSchema = object({ id: string() });
65474
65782
  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
65783
  var StartEmbeddedInputSchema = object({
65483
65784
  port: number$1().int().min(1).max(65535).default(1883),
65484
65785
  /** Allow anonymous connect (no username/password). Default: false. */
@@ -65501,9 +65802,6 @@ method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnection
65501
65802
  }), method(IdInputSchema, _void(), {
65502
65803
  kind: "mutation",
65503
65804
  auth: "admin"
65504
- }), method(IdInputSchema, TestResultSchema$1, {
65505
- kind: "mutation",
65506
- auth: "admin"
65507
65805
  }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, {
65508
65806
  kind: "mutation",
65509
65807
  auth: "admin"
@@ -78610,7 +78908,7 @@ var TrainingProviderTypeSchema = object({
78610
78908
  label: string(),
78611
78909
  /** Path under the addon package (e.g. `assets/training-modal/icon.svg`). */
78612
78910
  iconAsset: string(),
78613
- /** Owning `training-provider` addon — collection selector for createEntry. */
78911
+ /** Owning `training-provider` addon. */
78614
78912
  addonId: string(),
78615
78913
  /**
78616
78914
  * Fields for one entry of this type (credentials + defaults). Absent on a
@@ -78655,36 +78953,6 @@ var TrainingProviderDescriptorSchema = object({
78655
78953
  */
78656
78954
  formValues: record(string(), unknown()).optional()
78657
78955
  });
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
78956
  var ProviderJobStateSchema = _enum([
78689
78957
  "pending",
78690
78958
  "starting",
@@ -78732,22 +79000,6 @@ var TrainingLogLineSchema = object({
78732
79000
  text: string()
78733
79001
  });
78734
79002
  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
79003
  entryId: string().min(1),
78752
79004
  gpuType: string(),
78753
79005
  region: string(),
@@ -84108,20 +84360,6 @@ Object.freeze({
84108
84360
  access: "view",
84109
84361
  role: "admin"
84110
84362
  },
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
84363
  "connectivity.getStatus": {
84126
84364
  capName: "connectivity",
84127
84365
  capScope: "device",
@@ -86488,13 +86726,6 @@ Object.freeze({
86488
86726
  access: "view",
86489
86727
  role: "admin"
86490
86728
  },
86491
- "integrations.testConnection": {
86492
- capName: "integrations",
86493
- capScope: "system",
86494
- addonId: null,
86495
- access: "create",
86496
- role: "admin"
86497
- },
86498
86729
  "integrations.update": {
86499
86730
  capName: "integrations",
86500
86731
  capScope: "system",
@@ -87727,13 +87958,6 @@ Object.freeze({
87727
87958
  access: "create",
87728
87959
  role: "admin"
87729
87960
  },
87730
- "mqttBroker.testConnection": {
87731
- capName: "mqtt-broker",
87732
- capScope: "system",
87733
- addonId: null,
87734
- access: "create",
87735
- role: "admin"
87736
- },
87737
87961
  "nativeObjectDetection.getOptions": {
87738
87962
  capName: "native-object-detection",
87739
87963
  capScope: "device",
@@ -92564,20 +92788,6 @@ Object.freeze({
92564
92788
  access: "create",
92565
92789
  role: "admin"
92566
92790
  },
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
92791
  "trainingProvider.describe": {
92582
92792
  capName: "training-provider",
92583
92793
  capScope: "system",
@@ -92648,13 +92858,6 @@ Object.freeze({
92648
92858
  access: "create",
92649
92859
  role: "admin"
92650
92860
  },
92651
- "trainingProvider.updateEntry": {
92652
- capName: "training-provider",
92653
- capScope: "system",
92654
- addonId: null,
92655
- access: "create",
92656
- role: "admin"
92657
- },
92658
92861
  "trainingProvider.uploadDataset": {
92659
92862
  capName: "training-provider",
92660
92863
  capScope: "system",
@@ -92662,13 +92865,6 @@ Object.freeze({
92662
92865
  access: "create",
92663
92866
  role: "admin"
92664
92867
  },
92665
- "trainingProvider.validateCredentials": {
92666
- capName: "training-provider",
92667
- capScope: "system",
92668
- addonId: null,
92669
- access: "create",
92670
- role: "admin"
92671
- },
92672
92868
  "turnProvider.getTurnServers": {
92673
92869
  capName: "turn-provider",
92674
92870
  capScope: "system",
@@ -145218,452 +145414,6 @@ function regionOfDomain(domain) {
145218
145414
  if (tail === "amazon.co.jp" || tail === "amazon.com.au" || tail === "amazon.in") return "FE";
145219
145415
  return "EU";
145220
145416
  }
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
145417
  //#endregion
145668
145418
  //#region src/speak/rate-limit.ts
145669
145419
  /**
@@ -145887,7 +145637,6 @@ function discoveredFrom(broker, device) {
145887
145637
  }
145888
145638
  function createAlexaNotificationProvider(ports) {
145889
145639
  const logger = ports.logger ?? quietLogger$1();
145890
- let targets = ports.loadTargets();
145891
145640
  let ledger = emptySpeechLedger();
145892
145641
  let deliveries = [];
145893
145642
  function record(row) {
@@ -145906,10 +145655,6 @@ function createAlexaNotificationProvider(ports) {
145906
145655
  const owners = all.filter((broker) => broker.devices.some((device) => device.id === echoId));
145907
145656
  return owners.length === 1 ? owners[0] : void 0;
145908
145657
  }
145909
- function persist(next) {
145910
- targets = next;
145911
- ports.saveTargets(next);
145912
- }
145913
145658
  async function deliver(target, notification) {
145914
145659
  const speech = speechFromNotification(notification);
145915
145660
  const echoId = readString(target.config, "echoId");
@@ -146111,10 +145856,10 @@ function createAlexaNotificationProvider(ports) {
146111
145856
  return [alexaTargetKind()];
146112
145857
  },
146113
145858
  async listTargets() {
146114
- return [...targets];
145859
+ return [...await ports.loadTargets()];
146115
145860
  },
146116
145861
  async getTarget(input) {
146117
- const found = targets.find((target) => target.id === input.targetId);
145862
+ const found = (await ports.loadTargets()).find((target) => target.id === input.targetId);
146118
145863
  if (found === void 0) throw new Error(`target not found: ${input.targetId}`);
146119
145864
  return found;
146120
145865
  },
@@ -146133,7 +145878,7 @@ function createAlexaNotificationProvider(ports) {
146133
145878
  suggestedName: echoOptionLabel(device)
146134
145879
  }));
146135
145880
  }
146136
- const taken = new Set(targets.flatMap((row) => {
145881
+ const taken = new Set((await ports.loadTargets()).flatMap((row) => {
146137
145882
  const owner = brokerFor(row.config);
146138
145883
  return owner === void 0 ? [] : [`${owner.id}/${readString(row.config, "echoId")}`];
146139
145884
  }));
@@ -146151,7 +145896,7 @@ function createAlexaNotificationProvider(ports) {
146151
145896
  };
146152
145897
  },
146153
145898
  async send(input) {
146154
- const target = targets.find((row) => row.id === input.targetId);
145899
+ const target = (await ports.loadTargets()).find((row) => row.id === input.targetId);
146155
145900
  if (target === void 0) return {
146156
145901
  success: false,
146157
145902
  error: `target not found: ${input.targetId}`
@@ -146163,7 +145908,7 @@ function createAlexaNotificationProvider(ports) {
146163
145908
  return deliver(target, input.notification);
146164
145909
  },
146165
145910
  async testTarget(input) {
146166
- const found = targets.find((row) => row.id === input.targetId);
145911
+ const found = (await ports.loadTargets()).find((row) => row.id === input.targetId);
146167
145912
  if (found === void 0) return {
146168
145913
  success: false,
146169
145914
  error: `target not found: ${input.targetId}`
@@ -146193,20 +145938,554 @@ function createAlexaNotificationProvider(ports) {
146193
145938
  addonId: ALEXA_SPEAK_ADDON_ID,
146194
145939
  kind: ALEXA_SPEAK_KIND
146195
145940
  };
146196
- persist([...targets.filter((row) => row.id !== target.id), target]);
145941
+ await ports.putTarget(target);
146197
145942
  return target;
146198
145943
  },
146199
145944
  async deleteTarget(input) {
146200
- persist(targets.filter((row) => row.id !== input.targetId));
145945
+ await ports.removeTarget(input.targetId);
146201
145946
  },
146202
145947
  async setTargetEnabled(input) {
146203
- persist(targets.map((row) => row.id === input.targetId ? {
146204
- ...row,
145948
+ const found = (await ports.loadTargets()).find((row) => row.id === input.targetId);
145949
+ if (found !== void 0) await ports.putTarget({
145950
+ ...found,
146205
145951
  enabled: input.enabled
146206
- } : row));
145952
+ });
145953
+ }
145954
+ };
145955
+ }
145956
+ //#endregion
145957
+ //#region src/speak/echo-store.ts
145958
+ /**
145959
+ * The Echo targets as records: one `notify-alexa-echo` record per target (an instance of the function
145960
+ * cap `notification-output`), the id the one a notification rule names. The records are the one source:
145961
+ * the controller reads them on every use and writes only the row it changes.
145962
+ */
145963
+ var alexaNotifyRecordKinds = notificationTargetKinds([alexaTargetKind()]);
145964
+ function echoTargetStoreOver(records, options = {}) {
145965
+ return NotificationTargetStore.of(records, ALEXA_SPEAK_ADDON_ID, alexaNotifyRecordKinds, [alexaTargetKind()], options);
145966
+ }
145967
+ /** The `echoTargets` setting as the legacy source: a row the migration left there is still served. */
145968
+ function legacyEchoTargetsOver(setting) {
145969
+ return {
145970
+ list: async () => readEchoTargets({ echoTargets: setting.readRows() }).map((row) => ({
145971
+ id: row.id,
145972
+ name: row.name,
145973
+ kind: alexaTargetKind().kind,
145974
+ enabled: row.enabled,
145975
+ config: {
145976
+ ...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
145977
+ echoId: row.echoId,
145978
+ respectDnd: row.respectDnd
145979
+ }
145980
+ })),
145981
+ delete: async (id) => {
145982
+ const rest = setting.readRows().filter((row) => {
145983
+ const parsed = alexaEchoTargetSchema.safeParse(row);
145984
+ return !(parsed.success && parsed.data.id === id);
145985
+ });
145986
+ if (rest.length !== setting.readRows().length) await setting.writeRows(rest);
145987
+ }
145988
+ };
145989
+ }
145990
+ /** The row a stored target is for the settings page: the account link is '' until one is chosen. */
145991
+ function echoRowOf(target) {
145992
+ return {
145993
+ id: target.id,
145994
+ name: target.name,
145995
+ enabled: target.enabled,
145996
+ brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
145997
+ echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
145998
+ respectDnd: target.config["respectDnd"] !== false
145999
+ };
146000
+ }
146001
+ /** The migration's ports over the `notify-alexa-echo` record kind. */
146002
+ function echoRecordPortsOver(records, logger) {
146003
+ const handle = echoTargetStoreOver(records).handlesByKind().get(alexaTargetKind().kind);
146004
+ if (handle === void 0) throw new Error("import-alexa: the Echo target record kind is not declared");
146005
+ return {
146006
+ readEchoRecord: async (id) => (await handle.getResolved(id))?.data ?? null,
146007
+ writeEchoRecord: async (id, body) => {
146008
+ await handle.put({
146009
+ id,
146010
+ data: body
146011
+ });
146012
+ },
146013
+ dropEchoRecord: async (id) => {
146014
+ await handle.delete(id);
146015
+ },
146016
+ echoSkipped: (row) => {
146017
+ logger.warn("Echo target left in the echoTargets setting; it keeps being served", { meta: {
146018
+ targetId: row.id,
146019
+ reason: row.reason
146020
+ } });
146207
146021
  }
146208
146022
  };
146209
146023
  }
146024
+ /**
146025
+ * The Echo instance's test: the old probe (is the Amazon account of the target signed in on its
146026
+ * broker?) as the tri-state of `testInstance`. Speech not running yet answers `inconclusive` through
146027
+ * the same mapping (a failure with no HTTP status), never `unsupported`.
146028
+ */
146029
+ function echoTargetProbe(speak) {
146030
+ return async (input) => {
146031
+ const source = speak();
146032
+ if (source === null) return {
146033
+ ok: false,
146034
+ resolvedUrl: "alexa://session",
146035
+ error: "Echo speech is not running"
146036
+ };
146037
+ return source.provider.probeTargetConfig({
146038
+ kind: input.kind,
146039
+ config: input.config
146040
+ });
146041
+ };
146042
+ }
146043
+ process.platform, process.env["SystemRoot"];
146044
+ /**
146045
+ * Files only their owner may read: the one place a secret-bearing file is
146046
+ * restricted, on every OS.
146047
+ *
146048
+ * `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
146049
+ * attribute and nothing else, and `chmod` is a no-op for permissions, so a
146050
+ * "0600" `agent.json` (the mesh private key) would have
146051
+ * been readable by every local user. Windows has its own tool for this:
146052
+ * `icacls`. This module removes the inherited ACL entries and grants access to
146053
+ * the current user and SYSTEM only — the Windows reading of "0600".
146054
+ *
146055
+ * ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
146056
+ * this single file (see `packages/desktop/tsup.config.ts`) instead of the
146057
+ * whole `@camstack/types` barrel.
146058
+ *
146059
+ * Failure is NOT swallowed. A secret file that could not be restricted is a
146060
+ * secret file the caller must know about, so `OwnerOnlyError` propagates; the
146061
+ * write order below makes sure nothing sensitive is in the file yet when that
146062
+ * happens (the temp file is empty until it has been restricted).
146063
+ *
146064
+ * Out of scope on purpose: files that are only tidy, not secret (the job
146065
+ * journal, the addon manifest) keep a plain `mode`, since one `icacls` per
146066
+ * write would be a process spawn on a hot path.
146067
+ */
146068
+ /** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
146069
+ var WINDOWS_SYSTEM_SID = "*S-1-5-18";
146070
+ var FILE_MODE = 384;
146071
+ var DIR_MODE = 448;
146072
+ var OwnerOnlyError = class extends Error {
146073
+ target;
146074
+ constructor(target, detail) {
146075
+ super(`could not restrict ${target} to its owner: ${detail}`);
146076
+ this.target = target;
146077
+ this.name = "OwnerOnlyError";
146078
+ }
146079
+ };
146080
+ function currentPrincipal() {
146081
+ const user = process.env["USERNAME"] ?? (0, node_os.userInfo)().username;
146082
+ const domain = process.env["USERDOMAIN"];
146083
+ return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
146084
+ }
146085
+ /** Resolved per call, not at import: a platform stubbed by a test must be seen. */
146086
+ function resolveDeps(overrides) {
146087
+ return {
146088
+ platform: process.platform,
146089
+ exec: (file, args) => {
146090
+ (0, node_child_process.execFileSync)(file, [...args], {
146091
+ stdio: "pipe",
146092
+ windowsHide: true
146093
+ });
146094
+ },
146095
+ principal: currentPrincipal,
146096
+ systemRoot: process.env["SystemRoot"],
146097
+ ...overrides
146098
+ };
146099
+ }
146100
+ /** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
146101
+ function icaclsArgs(target, principal, isDirectory) {
146102
+ const inherit = isDirectory ? "(OI)(CI)" : "";
146103
+ return [
146104
+ target,
146105
+ "/inheritance:r",
146106
+ "/grant:r",
146107
+ `${principal}:${inherit}F`,
146108
+ "/grant:r",
146109
+ `${WINDOWS_SYSTEM_SID}:${inherit}F`
146110
+ ];
146111
+ }
146112
+ function restrictWindows(target, isDirectory, deps) {
146113
+ const icacls = node_path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
146114
+ try {
146115
+ deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
146116
+ } catch (err) {
146117
+ throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
146118
+ }
146119
+ }
146120
+ /** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
146121
+ function restrictFileToOwnerSync(target, overrides = {}) {
146122
+ const deps = resolveDeps(overrides);
146123
+ if (deps.platform === "win32") {
146124
+ restrictWindows(target, false, deps);
146125
+ return;
146126
+ }
146127
+ node_fs.chmodSync(target, FILE_MODE);
146128
+ }
146129
+ /** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
146130
+ function restrictDirToOwnerSync(target, overrides = {}) {
146131
+ const deps = resolveDeps(overrides);
146132
+ if (deps.platform === "win32") {
146133
+ restrictWindows(target, true, deps);
146134
+ return;
146135
+ }
146136
+ node_fs.chmodSync(target, DIR_MODE);
146137
+ }
146138
+ /**
146139
+ * Atomically write a secret-bearing file that only its owner can read.
146140
+ *
146141
+ * Order matters: the temp file is created EMPTY, restricted, and only then
146142
+ * given its content, so there is no moment at which the secret exists under a
146143
+ * permissive ACL. The rename carries the (already restricted) ACL with it.
146144
+ */
146145
+ function writeOwnerOnlyFileSync(target, data, overrides = {}) {
146146
+ node_fs.mkdirSync(node_path$1.dirname(target), { recursive: true });
146147
+ const tmp = `${target}.tmp.${process.pid}`;
146148
+ node_fs.writeFileSync(tmp, "", { mode: FILE_MODE });
146149
+ try {
146150
+ restrictFileToOwnerSync(tmp, overrides);
146151
+ node_fs.writeFileSync(tmp, data);
146152
+ node_fs.renameSync(tmp, target);
146153
+ } catch (err) {
146154
+ node_fs.rmSync(tmp, { force: true });
146155
+ throw err;
146156
+ }
146157
+ }
146158
+ //#endregion
146159
+ //#region src/speak/secret-file.ts
146160
+ /**
146161
+ * Hand a refresh token or a saved Amazon session to the Python worker as an
146162
+ * owner-only file. The path travels on stdin. The bytes never travel on
146163
+ * argv or in the environment, and they are removed when the call settles.
146164
+ */
146165
+ var REDACTED = "‹redacted›";
146166
+ function decodeSession(sessionB64) {
146167
+ if (sessionB64.length === 0) return Buffer.alloc(0);
146168
+ return Buffer.from(sessionB64, "base64");
146169
+ }
146170
+ async function readSessionB64(sessionPath) {
146171
+ const bytes = await node_fs_promises.readFile(sessionPath);
146172
+ if (bytes.length === 0) return "";
146173
+ return bytes.toString("base64");
146174
+ }
146175
+ /**
146176
+ * Create a 0700 directory, write the refresh token and the session 0600, run
146177
+ * `body`, then delete the directory whether `body` succeeded or threw.
146178
+ * Returns the session file as base64 so the caller can store it in the keychain.
146179
+ */
146180
+ async function withSpeakSecretFiles(input, body) {
146181
+ const dir = await node_fs_promises.mkdtemp(node_path.join(node_os.tmpdir(), "camstack-alexa-"));
146182
+ const paths = {
146183
+ dir,
146184
+ refreshTokenPath: node_path.join(dir, "refresh-token"),
146185
+ sessionPath: node_path.join(dir, "session")
146186
+ };
146187
+ try {
146188
+ restrictDirToOwnerSync(dir);
146189
+ writeOwnerOnlyFileSync(paths.refreshTokenPath, input.refreshToken);
146190
+ writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
146191
+ await body(paths);
146192
+ return { sessionB64: await readSessionB64(paths.sessionPath) };
146193
+ } finally {
146194
+ await node_fs_promises.rm(dir, {
146195
+ recursive: true,
146196
+ force: true
146197
+ });
146198
+ }
146199
+ }
146200
+ /** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
146201
+ function scrubSecrets(text, secrets) {
146202
+ let out = text;
146203
+ for (const secret of secrets) {
146204
+ if (secret.length === 0) continue;
146205
+ out = out.split(secret).join(REDACTED);
146206
+ }
146207
+ return out;
146208
+ }
146209
+ //#endregion
146210
+ //#region src/speak/broker-echo.ts
146211
+ function withDevice(deviceId) {
146212
+ return deviceId === void 0 ? {} : { deviceId };
146213
+ }
146214
+ var BrokerEcho = class {
146215
+ deps;
146216
+ currentState = "starting";
146217
+ currentError = null;
146218
+ currentDevices = [];
146219
+ backoffMs = 0;
146220
+ limitedUntil = 0;
146221
+ channelState = "down";
146222
+ channelWasUp = false;
146223
+ secrets = [];
146224
+ unknownPushLogged = /* @__PURE__ */ new Set();
146225
+ constructor(deps) {
146226
+ this.deps = deps;
146227
+ deps.worker.onPush((push) => this.onPush(push));
146228
+ }
146229
+ /** Whether Amazon's push channel is open for this account right now. */
146230
+ get pushChannel() {
146231
+ return this.channelState;
146232
+ }
146233
+ /** A reason is text from the library: capped, then scrubbed of the secrets in play. */
146234
+ safeReason(reason) {
146235
+ return scrubSecrets(reason.slice(0, 200), this.secrets);
146236
+ }
146237
+ onPush(push) {
146238
+ const tags = { tags: { brokerId: this.deps.brokerId } };
146239
+ if (push.push === "channel") {
146240
+ if (push.state === this.channelState) {
146241
+ if (push.state === "down") this.deps.logger.debug("alexa echo: push channel still down", {
146242
+ ...tags,
146243
+ meta: {
146244
+ reason: this.safeReason(push.reason),
146245
+ retryInMs: push.retryInMs
146246
+ }
146247
+ });
146248
+ return;
146249
+ }
146250
+ this.channelState = push.state;
146251
+ if (push.state === "up") {
146252
+ this.deps.logger.info(this.channelWasUp ? "alexa echo: push channel reconnected, polling slows down" : "alexa echo: push channel connected, polling slows down", tags);
146253
+ this.channelWasUp = true;
146254
+ } else this.deps.logger.warn("alexa echo: push channel down, polling at the normal pace", {
146255
+ ...tags,
146256
+ meta: {
146257
+ reason: this.safeReason(push.reason),
146258
+ retryInMs: push.retryInMs
146259
+ }
146260
+ });
146261
+ this.deps.push?.channel(this.deps.brokerId, push.state);
146262
+ return;
146263
+ }
146264
+ if (PUSH_READ_COMMANDS.has(push.command)) {
146265
+ if (push.serial === null) {
146266
+ this.deps.logger.debug("alexa echo: push event without an Echo, ignored", {
146267
+ ...tags,
146268
+ meta: { command: push.command }
146269
+ });
146270
+ return;
146271
+ }
146272
+ this.deps.push?.event(this.deps.brokerId, push.serial);
146273
+ return;
146274
+ }
146275
+ if (PUSH_IGNORED_COMMANDS.has(push.command)) return;
146276
+ if (this.unknownPushLogged.has(push.command)) return;
146277
+ this.unknownPushLogged.add(push.command);
146278
+ this.deps.logger.info("alexa echo: unknown push event, ignored (logged once)", {
146279
+ ...tags,
146280
+ meta: { command: push.command }
146281
+ });
146282
+ }
146283
+ get id() {
146284
+ return this.deps.brokerId;
146285
+ }
146286
+ get name() {
146287
+ return this.deps.brokerName;
146288
+ }
146289
+ get state() {
146290
+ return this.currentState;
146291
+ }
146292
+ get error() {
146293
+ return this.currentError;
146294
+ }
146295
+ get devices() {
146296
+ return this.currentDevices;
146297
+ }
146298
+ /** Ms epoch until which Amazon asked us to wait (429), or null when the account is free. */
146299
+ get rateLimitedUntil() {
146300
+ return this.limitedUntil > this.nowMs() ? this.limitedUntil : null;
146301
+ }
146302
+ nowMs() {
146303
+ return (this.deps.now ?? Date.now)();
146304
+ }
146305
+ stop() {
146306
+ this.deps.worker.stop();
146307
+ if (this.channelState === "up") {
146308
+ this.channelState = "down";
146309
+ this.deps.push?.channel(this.deps.brokerId, "down");
146310
+ }
146311
+ }
146312
+ /** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
146313
+ async signIn() {
146314
+ const { brokerId, worker, sessions, logger } = this.deps;
146315
+ const tags = { tags: { brokerId } };
146316
+ let secrets = [];
146317
+ try {
146318
+ const seed = await this.deps.readSeed();
146319
+ const stored = await sessions.get(brokerId);
146320
+ logger.info("alexa speak: broker sign-in material", {
146321
+ ...tags,
146322
+ meta: {
146323
+ seedFound: seed !== null,
146324
+ hasRefreshToken: seed !== null && seed.refreshToken.length > 0,
146325
+ hasDeviceAppName: seed !== null && seed.deviceAppName.length > 0,
146326
+ hasStoredSession: stored.length > 0
146327
+ }
146328
+ });
146329
+ if (seed === null && stored.length === 0) {
146330
+ this.currentState = "broker-not-signed-in";
146331
+ this.currentError = null;
146332
+ logger.warn("alexa speak: the broker has no sign-in to share yet", tags);
146333
+ return;
146334
+ }
146335
+ secrets = seed === null ? [] : [seed.refreshToken];
146336
+ worker.noteSecrets(secrets);
146337
+ this.secrets = secrets;
146338
+ let outcome = parseLoginResult({
146339
+ status: "failed",
146340
+ error: "worker gave no answer"
146341
+ });
146342
+ const held = await withSpeakSecretFiles({
146343
+ refreshToken: seed?.refreshToken ?? "",
146344
+ sessionB64: stored
146345
+ }, async (paths) => {
146346
+ outcome = parseLoginResult(await worker.request({
146347
+ op: "login_broker",
146348
+ label: brokerId,
146349
+ refreshTokenPath: seed === null ? "" : paths.refreshTokenPath,
146350
+ deviceAppName: seed?.deviceAppName ?? "",
146351
+ domain: this.deps.domain,
146352
+ sessionPath: paths.sessionPath,
146353
+ region: regionOfDomain(this.deps.domain)
146354
+ }));
146355
+ });
146356
+ if (held.sessionB64.length > 0 && held.sessionB64 !== stored) await sessions.put(brokerId, held.sessionB64);
146357
+ this.currentError = outcome.status === "ok" ? null : outcome.error ?? "sign-in failed";
146358
+ this.currentState = outcome.status === "ok" ? "ready" : "failed";
146359
+ if (outcome.status !== "ok") {
146360
+ logger.warn("alexa speak: sign-in as the broker failed", {
146361
+ ...tags,
146362
+ meta: { error: this.currentError }
146363
+ });
146364
+ return;
146365
+ }
146366
+ await this.refreshDevices();
146367
+ } catch (err) {
146368
+ this.currentState = "failed";
146369
+ this.currentError = scrubSecrets(err instanceof Error ? err.message : String(err), secrets);
146370
+ logger.warn("alexa speak: sign-in as the broker failed", {
146371
+ ...tags,
146372
+ meta: { error: this.currentError }
146373
+ });
146374
+ }
146375
+ }
146376
+ /** A session that is not ready tries to sign in again before it gives up. */
146377
+ async ensureReady() {
146378
+ if (this.currentState !== "ready") await this.signIn();
146379
+ return this.currentState === "ready";
146380
+ }
146381
+ async refreshDevices() {
146382
+ if (this.currentState !== "ready") return;
146383
+ try {
146384
+ this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
146385
+ } catch (err) {
146386
+ this.deps.logger.warn("alexa speak: step 3 of 3, device list failed after a good sign-in", {
146387
+ tags: { brokerId: this.deps.brokerId },
146388
+ meta: { error: err instanceof Error ? err.message : String(err) }
146389
+ });
146390
+ }
146391
+ }
146392
+ async utter(op, targets, text) {
146393
+ if (!await this.ensureReady()) throw new Error(this.notReadyReason());
146394
+ return parseDeliveries(await this.deps.worker.request({
146395
+ op,
146396
+ targets,
146397
+ text
146398
+ }));
146399
+ }
146400
+ /**
146401
+ * Run one worker call. A 429 starts (or doubles) the account's back-off and is
146402
+ * rethrown as is; any answer ends it. Rate limiting is a status, not a failure.
146403
+ */
146404
+ async limited(call) {
146405
+ try {
146406
+ const result = await call();
146407
+ this.backoffMs = 0;
146408
+ this.limitedUntil = 0;
146409
+ return result;
146410
+ } catch (err) {
146411
+ if (err instanceof RateLimitedError) {
146412
+ this.backoffMs = this.backoffMs === 0 ? ECHO_RATE_BACKOFF_BASE_MS : Math.min(this.backoffMs * 2, ECHO_RATE_BACKOFF_MAX_MS);
146413
+ this.limitedUntil = this.nowMs() + this.backoffMs;
146414
+ this.deps.logger.info("alexa echo: amazon asked for a pause (429)", {
146415
+ tags: { brokerId: this.deps.brokerId },
146416
+ meta: { backoffMs: this.backoffMs }
146417
+ });
146418
+ }
146419
+ throw err;
146420
+ }
146421
+ }
146422
+ /** The raw player state of one Echo. Throws on any failure, a 429 included: never an empty answer. */
146423
+ async playerState(serial, deviceType, deviceId) {
146424
+ await this.requireReady();
146425
+ return this.limited(async () => parsePlayerState(await this.deps.worker.request({
146426
+ op: "player_state",
146427
+ serial,
146428
+ deviceType
146429
+ }, withDevice(deviceId))));
146430
+ }
146431
+ async npCommand(serial, command, deviceId) {
146432
+ await this.requireReady();
146433
+ await this.limited(() => this.deps.worker.request({
146434
+ op: "np_command",
146435
+ serial,
146436
+ command
146437
+ }, withDevice(deviceId)));
146438
+ }
146439
+ async setVolume(serial, level, deviceId) {
146440
+ await this.requireReady();
146441
+ await this.limited(() => this.deps.worker.request({
146442
+ op: "set_volume",
146443
+ serial,
146444
+ level
146445
+ }, withDevice(deviceId)));
146446
+ }
146447
+ /** Do Not Disturb of every device of the account, by serial, in one call. */
146448
+ async dndState() {
146449
+ await this.requireReady();
146450
+ return this.limited(async () => parseDndState(await this.deps.worker.request({ op: "dnd_state" })));
146451
+ }
146452
+ /** Speaker volume and mute of every device of the account, by serial, in one call. */
146453
+ async deviceVolumes() {
146454
+ await this.requireReady();
146455
+ return this.limited(async () => parseDeviceVolumes(await this.deps.worker.request({ op: "device_volumes" })));
146456
+ }
146457
+ async setDnd(serial, enabled, deviceId) {
146458
+ await this.requireReady();
146459
+ await this.limited(() => this.deps.worker.request({
146460
+ op: "set_dnd",
146461
+ serial,
146462
+ enabled
146463
+ }, withDevice(deviceId)));
146464
+ }
146465
+ async requireReady() {
146466
+ if (!await this.ensureReady()) throw new Error(this.notReadyReason());
146467
+ }
146468
+ async session() {
146469
+ if (this.deps.worker.failed) return {
146470
+ loggedIn: false,
146471
+ ageSec: 0,
146472
+ lastSuccessAt: null
146473
+ };
146474
+ try {
146475
+ return parseSessionStatus(await this.deps.worker.request({ op: "session_status" }));
146476
+ } catch {
146477
+ return {
146478
+ loggedIn: this.currentState === "ready",
146479
+ ageSec: 0,
146480
+ lastSuccessAt: null
146481
+ };
146482
+ }
146483
+ }
146484
+ notReadyReason() {
146485
+ if (this.currentState === "broker-not-signed-in") return `account '${this.deps.brokerName}' is not signed in on its broker`;
146486
+ return this.currentError ?? "alexa session is not ready";
146487
+ }
146488
+ };
146210
146489
  //#endregion
146211
146490
  //#region src/speak/python-paths.ts
146212
146491
  /** Locate bundled `python/alexa_worker.py` from dist/ or from src/ under test. */
@@ -146585,34 +146864,9 @@ function startAlexaWorker(deps) {
146585
146864
  }
146586
146865
  //#endregion
146587
146866
  //#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
146867
  var AlexaSpeakController = class {
146613
146868
  host;
146614
146869
  provider;
146615
- rows;
146616
146870
  pythonPath = null;
146617
146871
  echoes = /* @__PURE__ */ new Map();
146618
146872
  timer = null;
@@ -146621,16 +146875,13 @@ var AlexaSpeakController = class {
146621
146875
  constructor(host) {
146622
146876
  this.host = host;
146623
146877
  this.now = host.now ?? (() => Date.now());
146624
- this.rows = readEchoTargets(host.readConfig());
146625
146878
  this.provider = createAlexaNotificationProvider({
146626
146879
  brokers: () => this.brokerViews(),
146627
146880
  now: this.now,
146628
146881
  logger: host.logger,
146629
- loadTargets: () => targetsFrom(this.rows),
146630
- saveTargets: (targets) => {
146631
- this.rows = rowsFrom(targets);
146632
- this.persist();
146633
- },
146882
+ loadTargets: () => host.readTargets(),
146883
+ putTarget: (target) => host.putTarget(target),
146884
+ removeTarget: (targetId) => host.removeTarget(targetId),
146634
146885
  speak: (command) => this.utter(command.brokerId, command.mode, command.targets, command.text)
146635
146886
  });
146636
146887
  }
@@ -146672,13 +146923,6 @@ var AlexaSpeakController = class {
146672
146923
  };
146673
146924
  });
146674
146925
  }
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
146926
  makeWorker(brokerId, pythonPath) {
146683
146927
  if (this.host.startWorker !== void 0) return this.host.startWorker(brokerId, pythonPath);
146684
146928
  return startAlexaWorker({
@@ -146880,6 +147124,80 @@ async function stripEchoTargetVolumeAndMode(ports) {
146880
147124
  };
146881
147125
  }
146882
147126
  //#endregion
147127
+ //#region src/migrations/echo-targets-to-records.ts
147128
+ /**
147129
+ * The Echo notification targets move from the `echoTargets` setting (one array in the addon's global
147130
+ * settings) to one `notify-alexa-echo` record each, under the SAME id so a notification rule naming it
147131
+ * still resolves (epic #440, step 6). The switch (`enabled`) is carried across, never reset.
147132
+ *
147133
+ * Each record is read back before its row leaves the setting. One bad row never aborts the others: a row
147134
+ * whose write or read-back fails is undone, reported with its id and reason, and stays in the setting,
147135
+ * where the Echo store keeps serving it. A record that already exists is never overwritten (an operator
147136
+ * may have edited it since). A row that does not parse is left where it is and keeps the setting in
147137
+ * place: nothing is destroyed on a guess (D49).
147138
+ */
147139
+ var ECHO_TARGETS_TO_RECORDS_ID = "import-alexa/2026-11-06-echo-targets-to-records";
147140
+ async function moveEchoTargetsToRecords(ports) {
147141
+ const rows = ports.readRows();
147142
+ if (rows.length === 0) return {
147143
+ outcome: "already-done",
147144
+ summary: { converted: 0 }
147145
+ };
147146
+ let converted = 0;
147147
+ let kept = 0;
147148
+ let unparsed = 0;
147149
+ const left = [];
147150
+ for (const row of rows) {
147151
+ const parsed = alexaEchoTargetSchema.safeParse(row);
147152
+ if (!parsed.success) {
147153
+ unparsed += 1;
147154
+ continue;
147155
+ }
147156
+ const target = parsed.data;
147157
+ try {
147158
+ if (await ports.readEchoRecord(target.id) !== null) {
147159
+ kept += 1;
147160
+ continue;
147161
+ }
147162
+ const body = targetBody({
147163
+ id: target.id,
147164
+ name: target.name,
147165
+ enabled: target.enabled,
147166
+ config: {
147167
+ ...target.brokerId.length > 0 ? { brokerId: target.brokerId } : {},
147168
+ echoId: target.echoId,
147169
+ respectDnd: target.respectDnd
147170
+ }
147171
+ });
147172
+ await ports.writeEchoRecord(target.id, body);
147173
+ const back = await ports.readEchoRecord(target.id);
147174
+ if (!(back !== null && back["name"] === body["name"] && back["enabled"] === body["enabled"] && back["echoId"] === body["echoId"] && back["respectDnd"] === body["respectDnd"] && back["brokerId"] === body["brokerId"])) {
147175
+ await ports.dropEchoRecord(target.id);
147176
+ throw new Error("the new record did not read back");
147177
+ }
147178
+ converted += 1;
147179
+ } catch (err) {
147180
+ await ports.dropEchoRecord(target.id).catch(() => void 0);
147181
+ ports.echoSkipped({
147182
+ id: target.id,
147183
+ reason: err instanceof Error ? err.message : String(err)
147184
+ });
147185
+ left.push(row);
147186
+ }
147187
+ }
147188
+ if (unparsed === 0) await ports.writeRows(left);
147189
+ return {
147190
+ outcome: converted > 0 || kept > 0 ? "done" : "already-done",
147191
+ summary: {
147192
+ converted,
147193
+ kept,
147194
+ unparsed,
147195
+ skipped: left.length,
147196
+ kind: ALEXA_SPEAK_KIND
147197
+ }
147198
+ };
147199
+ }
147200
+ //#endregion
146883
147201
  //#region src/migrations/index.ts
146884
147202
  /** The `import-alexa` catalogue (D987): offered to the engine when the addon initialises. */
146885
147203
  var ADDON_MIGRATIONS = [
@@ -146946,6 +147264,22 @@ var ADDON_MIGRATIONS = [
146946
147264
  fixtureMark: "the name and the extra field of the account that keeps its record"
146947
147265
  },
146948
147266
  procedure: moveBrokerLinksToConnections
147267
+ },
147268
+ {
147269
+ meta: {
147270
+ id: ECHO_TARGETS_TO_RECORDS_ID,
147271
+ owner: "import-alexa",
147272
+ level: "addon-state",
147273
+ seq: 5,
147274
+ dependsOn: ["import-alexa/2026-11-01-strip-echo-target-volume-and-mode"],
147275
+ store: "settings",
147276
+ scope: "cluster",
147277
+ onFailure: "tolerate",
147278
+ bodyKind: "procedure",
147279
+ 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",
147280
+ fixtureMark: "the switch (off) and the id of an Echo target"
147281
+ },
147282
+ procedure: moveEchoTargetsToRecords
146949
147283
  }
146950
147284
  ];
146951
147285
  /** The migrations that rewrite the broker records: they run BEFORE anything reads or persists one. */
@@ -147055,6 +147389,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147055
147389
  accountInstances = null;
147056
147390
  activeBrokers = [];
147057
147391
  speak = null;
147392
+ echoStore = null;
147058
147393
  constructor() {
147059
147394
  super({ ...DEFAULTS });
147060
147395
  }
@@ -147062,7 +147397,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147062
147397
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
147063
147398
  const records = this.ctx.records;
147064
147399
  if (records === void 0) throw new Error("import-alexa: ctx.records is required (hub-only addon)");
147065
- await records.declare(alexaRecordKinds);
147400
+ await records.declare({
147401
+ ...alexaRecordKinds,
147402
+ ...alexaNotifyRecordKinds
147403
+ });
147066
147404
  this.brokerStore = AlexaBrokerStore.of(records);
147067
147405
  this.instances = buildAlexaInstances(records);
147068
147406
  this.accountInstances = buildAlexaAccountInstances(records, {
@@ -147112,12 +147450,16 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147112
147450
  commitCookie: (input) => this.commitCapturedCookie(input)
147113
147451
  }), ...speakIconRoutes()];
147114
147452
  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)) });
147453
+ const echoStore = echoTargetStoreOver(records, {
147454
+ logger: this.ctx.logger,
147455
+ legacy: legacyEchoTargetsOver(this.echoSettingPorts())
147456
+ });
147457
+ this.echoStore = echoStore;
147115
147458
  const speak = new AlexaSpeakController({
147116
147459
  logger: this.ctx.logger.child("speak"),
147117
- readConfig: () => this.config,
147118
- writeTargets: async (next) => {
147119
- await this.updateGlobalSettings({ echoTargets: [...next] });
147120
- },
147460
+ readTargets: () => echoStore.list(),
147461
+ putTarget: (target) => echoStore.upsert(target),
147462
+ removeTarget: (targetId) => echoStore.delete(targetId),
147121
147463
  listBrokers: () => this.activeBrokers.map((entry) => ({
147122
147464
  id: entry.id,
147123
147465
  name: entry.name,
@@ -147160,16 +147502,23 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147160
147502
  }
147161
147503
  ] };
147162
147504
  }
147163
- migrationPorts(records) {
147164
- const secrets = this.ctx.secrets;
147165
- if (secrets === void 0) throw new Error("import-alexa: ctx.secrets is required");
147505
+ /** The `echoTargets` setting, read and written as the migration and the legacy store see it. */
147506
+ echoSettingPorts() {
147166
147507
  return {
147167
147508
  readRows: () => Array.isArray(this.config.echoTargets) ? this.config.echoTargets : [],
147168
147509
  writeRows: async (rows) => {
147169
147510
  await this.updateGlobalSettings({ echoTargets: [...readEchoTargets({ echoTargets: rows })] });
147170
- },
147511
+ }
147512
+ };
147513
+ }
147514
+ migrationPorts(records) {
147515
+ const secrets = this.ctx.secrets;
147516
+ if (secrets === void 0) throw new Error("import-alexa: ctx.secrets is required");
147517
+ return {
147518
+ ...this.echoSettingPorts(),
147171
147519
  ...flattenPortsOver(records.kind(alexaBrokersEntryKind)),
147172
147520
  ...alexaBrokerLinkPorts(records, (message) => this.ctx.logger.warn(message)),
147521
+ ...echoRecordPortsOver(records, this.ctx.logger),
147173
147522
  ...alexaIntegrationRowsPorts({
147174
147523
  api: defaultIntegrationApiOver(this.ctx.api),
147175
147524
  records,
@@ -147181,28 +147530,40 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147181
147530
  /** One provider over the two record kinds: the account (broker) and the integrations using it. */
147182
147531
  buildInstancesProvider() {
147183
147532
  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
- }
147533
+ const records = this.ctx.records;
147534
+ if (records === void 0) throw new Error("import-alexa: ctx.records is required");
147535
+ return composeInstanceKinds([
147536
+ {
147537
+ kind: {
147538
+ id: "account",
147539
+ label: "Alexa account",
147540
+ functionCap: "broker",
147541
+ brokerKind: ALEXA_BROKER_KIND,
147542
+ role: "services",
147543
+ signIn: {
147544
+ basePath: ALEXA_SIGN_IN_PATH,
147545
+ methods: ["device-code", "proxy"]
147546
+ }
147547
+ },
147548
+ provider: guardInstanceDelete(this.accountInstances.toProvider(), () => this.brokerUsers())
147195
147549
  },
147196
- provider: guardInstanceDelete(this.accountInstances.toProvider(), () => this.brokerUsers())
147197
- }, {
147198
- kind: {
147199
- id: "integration",
147200
- label: "Alexa",
147201
- functionCap: "device-provider",
147202
- role: "devices"
147550
+ {
147551
+ kind: {
147552
+ id: "integration",
147553
+ label: "Alexa",
147554
+ functionCap: "device-provider",
147555
+ role: "devices"
147556
+ },
147557
+ provider: this.requireInstances().toProvider()
147203
147558
  },
147204
- provider: this.requireInstances().toProvider()
147205
- }], {
147559
+ ...notificationTargetParts(records, {
147560
+ descriptors: [alexaTargetKind()],
147561
+ kinds: alexaNotifyRecordKinds,
147562
+ mintId: () => (0, node_crypto.randomUUID)(),
147563
+ probe: echoTargetProbe(() => this.speak),
147564
+ canProbe: () => true
147565
+ })
147566
+ ], {
147206
147567
  role: "devices",
147207
147568
  instanceMode: "multiple",
147208
147569
  kind: "account",
@@ -147493,7 +147854,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
147493
147854
  }
147494
147855
  /** Who still points at an Alexa account: its integrations and the Echo targets that speak through it. */
147495
147856
  brokerUsers() {
147496
- return [linkUserFinder(this.requireInstances(), "brokerId"), { find: async (brokerId) => readEchoTargets(this.config).filter((target) => target.brokerId === brokerId).map((target) => ({
147857
+ 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
147858
  kind: "notification-target",
147498
147859
  id: target.id,
147499
147860
  name: target.name