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