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