@camstack/addon-export-alexa 1.2.132 → 1.2.133

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.
@@ -5379,7 +5379,7 @@ var ZodIssueCode = {
5379
5379
  var ZodFirstPartyTypeKind;
5380
5380
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5381
5381
  //#endregion
5382
- //#region ../types/dist/sleep-C16NCZR2.mjs
5382
+ //#region ../types/dist/sleep-DAJ9Er57.mjs
5383
5383
  /**
5384
5384
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5385
5385
  * window to float samples (D455).
@@ -23996,148 +23996,17 @@ method(_void(), array(PipelineEngineChoiceSchema)), method(_void(), PipelineEngi
23996
23996
  settings: record(string(), unknown()).optional()
23997
23997
  }), AudioTestResultSchema, { kind: "mutation" }), method(_void(), ConfigUISchemaNullableBridge);
23998
23998
  /**
23999
- * Wire shapes of the Load balancing surface (D748): the session placement
24000
- * policy, the atomic share write, the observed placement statistics, the
24001
- * hardware share suggestion and the stale device pins.
24002
- */
24003
- /**
24004
- * How a new detection session picks its node and device.
24005
- * - `least-loaded` — the historical rule: lowest `attached / weight`, a
24006
- * constant node order at a tie (D37). An idle node always ties at 0, so a
24007
- * weight only bites once a node already holds a session.
24008
- * - `share` — the share of PARALLEL sessions is held to the weights, and a
24009
- * full or drowning node yields to a free one (D748).
24010
- */
24011
- var SessionPlacementPolicySchema = _enum(["least-loaded", "share"]);
24012
- var LoadBalancingSharesInputSchema = object({
24013
- /** nodeId → share (> 0). A node left out keeps its weight. */
24014
- nodes: record(string(), number().positive()).default({}),
24015
- /** nodeId → deviceKey → share (> 0). A device left out keeps its weight. */
24016
- devices: record(string(), record(string(), number().positive())).default({})
24017
- });
24018
- var PlacementStatsInputSchema = object({
24019
- /** Window the observed shares are averaged over (default 1 h, max 24 h). */
24020
- windowMs: number().int().min(6e4).max(1440 * 60 * 1e3).default(3600 * 1e3) });
24021
- var PlacementDeviceStatsSchema = object({
24022
- deviceKey: string(),
24023
- /** Sessions running on this device now. */
24024
- sessionsNow: number(),
24025
- /** Sessions started on this device inside the window. */
24026
- sessionsStarted: number(),
24027
- /** Time-weighted share of the node's parallel sessions over the window (0–1); null = no session ran. */
24028
- observedShare: number().nullable()
24029
- });
24030
- var PlacementNodeStatsSchema = object({
24031
- nodeId: string(),
24032
- sessionsNow: number(),
24033
- sessionsStarted: number(),
24034
- /** Time-weighted share of the cluster's parallel sessions over the window (0–1); null = none ran. */
24035
- observedShare: number().nullable(),
24036
- /** Most sessions this node ran at once inside the window. */
24037
- peakSessions: number(),
24038
- /** Sessions a share election handed elsewhere because this node was full or drowning. */
24039
- yieldsAway: number(),
24040
- devices: array(PlacementDeviceStatsSchema).readonly()
24041
- });
24042
- /** A camera's device pin that the session path cannot honour. */
24043
- var StaleDevicePinSchema = object({
24044
- deviceId: number(),
24045
- deviceKey: string(),
24046
- /** `disabled` — the key exists on some node but is not enabled anywhere; `absent` — no node has it. */
24047
- reason: _enum(["disabled", "absent"])
24048
- });
24049
- var PlacementStatsSchema = object({
24050
- policy: SessionPlacementPolicySchema,
24051
- windowMs: number(),
24052
- /** How much of the window was actually observed (the hub keeps it in memory). */
24053
- observedForMs: number(),
24054
- sessionsNow: number(),
24055
- /** Most parallel sessions across the cluster inside the window. */
24056
- clusterPeakSessions: number(),
24057
- nodes: array(PlacementNodeStatsSchema).readonly(),
24058
- stalePins: array(StaleDevicePinSchema).readonly()
24059
- });
24060
- var SuggestSharesInputSchema = object({ headroom: number().min(.3).max(.5).default(.4) });
24061
- var SuggestedDeviceShareSchema = object({
24062
- deviceKey: string(),
24063
- /** Share of the node (0–100, rounded). */
24064
- share: number(),
24065
- modelId: string().nullable(),
24066
- /** Inferences per second at that model, from the measured table. */
24067
- fps: number().nullable(),
24068
- measured: boolean(),
24069
- /** Advisory session cap: ⌊fps × (1 − headroom) / camera fps⌋. Never enforced. */
24070
- suggestedMaxSessions: number().nullable()
24071
- });
24072
- var SuggestedNodeShareSchema = object({
24073
- nodeId: string(),
24074
- /** Share of the cluster (0–100, rounded); null = node skipped (offline, unknown hardware). */
24075
- share: number().nullable(),
24076
- /** Parallel sessions the node sustains at the suggested models with the headroom kept free. */
24077
- capacitySessions: number(),
24078
- devices: array(SuggestedDeviceShareSchema).readonly(),
24079
- skippedReason: string().nullable()
24080
- });
24081
- var SuggestSharesResultSchema = object({
24082
- headroom: number(),
24083
- /** Camera detection rate the capacity is counted in (the fastest camera's fps). */
24084
- cameraFps: number(),
24085
- /** How many share → model → capacity rounds ran before the shares stopped moving (≤ 3). */
24086
- iterations: number(),
24087
- nodes: array(SuggestedNodeShareSchema).readonly()
24088
- });
24089
- /**
24090
- * Where the node and device shares come from (D750).
24091
- * - `auto` — computed by the system from the cluster's measured capacity at
24092
- * the Auto tune models, and recomputed whenever a node or device joins,
24093
- * leaves, is switched on or off, or changes model.
24094
- * - `manual` — the operator's stored sliders. A node or device that cannot
24095
- * take sessions gives its share to the others in proportion; the stored
24096
- * values are never rewritten for that.
24097
- */
24098
- var ShareSourceSchema = _enum(["auto", "manual"]);
24099
- var SetShareSourceInputSchema = object({ source: ShareSourceSchema });
24100
- object({
24101
- nodes: record(string(), number()),
24102
- devices: record(string(), record(string(), number()))
24103
- });
24104
- var EffectiveDeviceShareSchema = object({
24105
- deviceKey: string(),
24106
- /** Can take sessions now (enabled, available, can run the root). */
24107
- eligible: boolean(),
24108
- /** The share the source assigns, as a percentage of the node's devices. */
24109
- configuredPct: number(),
24110
- /** The share actually used, after an ineligible device's share went to the others. */
24111
- effectivePct: number()
24112
- });
24113
- var EffectiveNodeShareSchema = object({
24114
- nodeId: string(),
24115
- eligible: boolean(),
24116
- configuredPct: number(),
24117
- effectivePct: number(),
24118
- devices: array(EffectiveDeviceShareSchema).readonly()
24119
- });
24120
- var EffectiveSharesSchema = object({
24121
- source: ShareSourceSchema,
24122
- policy: SessionPlacementPolicySchema,
24123
- /** `auto` with no computed answer yet: the stored sliders are used meanwhile. */
24124
- autoPending: boolean(),
24125
- /** When the automatic shares were last computed (ms epoch); null = never. */
24126
- computedAt: number().nullable(),
24127
- /** What made the system recompute them last. */
24128
- trigger: string().nullable(),
24129
- nodes: array(EffectiveNodeShareSchema).readonly(),
24130
- /** The relative weights the election reads (before eligibility). */
24131
- nodeWeights: record(string(), number()),
24132
- deviceWeights: record(string(), record(string(), number()))
24133
- });
24134
- /**
24135
23999
  * The wire shapes of the Auto tune plan and apply (D745), used by the
24136
24000
  * `pipeline-orchestrator` cap. The recommender's own types
24137
- * (`recommend.ts`, `demand.ts`) are the TS face of the same data; these
24001
+ * (`recommend.ts`) are the TS face of the same data; these
24138
24002
  * schemas are what crosses the cap boundary.
24139
24003
  */
24140
24004
  var ModelFormatSchema$1 = _enum(MODEL_FORMATS);
24005
+ /** The operator's range for it. */
24006
+ var MIN_SESSIONS_PER_DEVICE_RANGE = {
24007
+ min: 1,
24008
+ max: 8
24009
+ };
24141
24010
  var HardwareClassIdSchema = _enum([
24142
24011
  "intel-core-ultra",
24143
24012
  "intel-n-series",
@@ -24155,8 +24024,9 @@ var AcceleratorKindSchema = _enum([
24155
24024
  ]);
24156
24025
  var RecommendationReasonSchema = discriminatedUnion("kind", [
24157
24026
  object({
24158
- kind: literal("fits"),
24159
- utilisation: number()
24027
+ kind: literal("capacity"),
24028
+ sessions: number(),
24029
+ minSessions: number()
24160
24030
  }),
24161
24031
  object({
24162
24032
  kind: literal("tie-faster"),
@@ -24171,16 +24041,13 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
24171
24041
  }),
24172
24042
  object({
24173
24043
  kind: literal("no-fit-lightest"),
24174
- utilisation: number()
24044
+ sessions: number(),
24045
+ minSessions: number()
24175
24046
  }),
24176
24047
  object({
24177
24048
  kind: literal("derived-latency"),
24178
24049
  modelId: string()
24179
24050
  }),
24180
- object({
24181
- kind: literal("estimated-load"),
24182
- observedForMs: number().nullable()
24183
- }),
24184
24051
  object({ kind: literal("cpu-device") }),
24185
24052
  object({ kind: literal("coral-untouched") }),
24186
24053
  object({ kind: literal("device-disabled") }),
@@ -24196,28 +24063,13 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
24196
24063
  format: ModelFormatSchema$1
24197
24064
  })
24198
24065
  ]);
24199
- var DeviceDemandSchema = object({
24200
- nodeId: string(),
24201
- deviceKey: string(),
24202
- demandFps: number(),
24203
- maxCameraFps: number(),
24204
- alwaysOnCameras: number(),
24205
- onMotionSessions: number(),
24206
- onMotionSource: _enum([
24207
- "observed-24h",
24208
- "estimate",
24209
- "none"
24210
- ]),
24211
- observedForMs: number().nullable(),
24212
- /** How on-motion sessions were charged: per-device observation, or split by share (D748). */
24213
- placement: SessionPlacementPolicySchema
24214
- });
24215
24066
  var DeviceModelChoiceSchema = object({
24216
24067
  modelId: string(),
24217
24068
  p50Ms: number(),
24218
24069
  fps: number(),
24219
24070
  measured: boolean(),
24220
- utilisation: number(),
24071
+ /** Parallel sessions at the camera rate with the headroom kept free: ⌊fps × (1 − headroom) / camera fps⌋. */
24072
+ capacitySessions: number(),
24221
24073
  personRecall: number()
24222
24074
  });
24223
24075
  var DeviceRecommendationSchema = object({
@@ -24225,7 +24077,6 @@ var DeviceRecommendationSchema = object({
24225
24077
  accelerator: AcceleratorKindSchema.nullable(),
24226
24078
  enabled: boolean(),
24227
24079
  available: boolean(),
24228
- demand: DeviceDemandSchema.nullable(),
24229
24080
  chosen: DeviceModelChoiceSchema.nullable(),
24230
24081
  reasons: array(RecommendationReasonSchema).readonly()
24231
24082
  });
@@ -24286,20 +24137,20 @@ var AutoTuneOptionsSchema = object({
24286
24137
  /** Share of each accelerator's throughput kept free (0.3–0.5). */
24287
24138
  headroom: number().min(.3).max(.5).default(.4),
24288
24139
  /**
24289
- * What-if shares (D748): plan as if these node/device shares were stored
24290
- * and the policy were `share`. Absent = the stored shares under the stored
24291
- * policy. A what-if plan is for display; apply always re-plans from what
24292
- * is stored.
24140
+ * The least number of parallel sessions every device must sustain at the
24141
+ * camera rate with the headroom kept free (D755). The most accurate model
24142
+ * that reaches it wins.
24293
24143
  */
24294
- shares: LoadBalancingSharesInputSchema.optional()
24144
+ minSessionsPerDevice: number().int().min(MIN_SESSIONS_PER_DEVICE_RANGE.min).max(MIN_SESSIONS_PER_DEVICE_RANGE.max).default(2)
24295
24145
  });
24296
24146
  var AutoTunePlanSchema = object({
24297
24147
  /** Hash of everything the apply will write — the apply refuses a different one. */
24298
24148
  planHash: string(),
24299
24149
  family: literal("yolov9"),
24300
24150
  headroom: number(),
24301
- /** Planned under what-if shares (D748): for display — apply refuses it as stale. */
24302
- whatIf: boolean(),
24151
+ minSessionsPerDevice: number(),
24152
+ /** The detection rate a session is counted at (the fastest camera running the pipeline root). */
24153
+ cameraFps: number(),
24303
24154
  generatedAt: number(),
24304
24155
  nodes: array(NodeRecommendationSchema).readonly(),
24305
24156
  nodeChanges: array(NodeDeviceChangeSchema).readonly(),
@@ -24309,7 +24160,8 @@ var AutoTunePlanSchema = object({
24309
24160
  });
24310
24161
  var AutoTuneApplyInputSchema = object({
24311
24162
  planHash: string(),
24312
- headroom: number().min(.3).max(.5).default(.4)
24163
+ headroom: number().min(.3).max(.5).default(.4),
24164
+ minSessionsPerDevice: number().int().min(MIN_SESSIONS_PER_DEVICE_RANGE.min).max(MIN_SESSIONS_PER_DEVICE_RANGE.max).default(2)
24313
24165
  });
24314
24166
  var AutoTunePhaseResultSchema = object({
24315
24167
  phase: _enum([
@@ -24341,6 +24193,177 @@ var AutoTuneApplyResultSchema = discriminatedUnion("kind", [
24341
24193
  reason: _enum(["apply-in-progress"])
24342
24194
  })
24343
24195
  ]);
24196
+ /**
24197
+ * Wire shapes of the Load balancing surface (D748): the session placement
24198
+ * policy, the atomic share write, the observed placement statistics, the
24199
+ * hardware share suggestion and the stale device pins.
24200
+ */
24201
+ /**
24202
+ * How a new detection session picks its node and device.
24203
+ * - `least-loaded` — the historical rule: lowest `attached / weight`, a
24204
+ * constant node order at a tie (D37). An idle node always ties at 0, so a
24205
+ * weight only bites once a node already holds a session.
24206
+ * - `share` — the share of PARALLEL sessions is held to the weights, and a
24207
+ * full or drowning node yields to a free one (D748).
24208
+ */
24209
+ var SessionPlacementPolicySchema = _enum(["least-loaded", "share"]);
24210
+ var LoadBalancingSharesInputSchema = object({
24211
+ /** nodeId → share (> 0). A node left out keeps its weight. */
24212
+ nodes: record(string(), number().positive()).default({}),
24213
+ /** nodeId → deviceKey → share (> 0). A device left out keeps its weight. */
24214
+ devices: record(string(), record(string(), number().positive())).default({})
24215
+ });
24216
+ var PlacementStatsInputSchema = object({
24217
+ /** Window the observed shares are averaged over (default 1 h, max 24 h). */
24218
+ windowMs: number().int().min(6e4).max(1440 * 60 * 1e3).default(3600 * 1e3) });
24219
+ var PlacementDeviceStatsSchema = object({
24220
+ deviceKey: string(),
24221
+ /** Sessions running on this device now. */
24222
+ sessionsNow: number(),
24223
+ /** Sessions started on this device inside the window. */
24224
+ sessionsStarted: number(),
24225
+ /** Time-weighted share of the node's parallel sessions over the window (0–1); null = no session ran. */
24226
+ observedShare: number().nullable()
24227
+ });
24228
+ var PlacementNodeStatsSchema = object({
24229
+ nodeId: string(),
24230
+ sessionsNow: number(),
24231
+ sessionsStarted: number(),
24232
+ /** Time-weighted share of the cluster's parallel sessions over the window (0–1); null = none ran. */
24233
+ observedShare: number().nullable(),
24234
+ /** Most sessions this node ran at once inside the window. */
24235
+ peakSessions: number(),
24236
+ /** Sessions a share election handed elsewhere because this node was full or drowning. */
24237
+ yieldsAway: number(),
24238
+ devices: array(PlacementDeviceStatsSchema).readonly()
24239
+ });
24240
+ /** A camera's device pin that the session path cannot honour. */
24241
+ var StaleDevicePinSchema = object({
24242
+ deviceId: number(),
24243
+ deviceKey: string(),
24244
+ /** `disabled` — the key exists on some node but is not enabled anywhere; `absent` — no node has it. */
24245
+ reason: _enum(["disabled", "absent"])
24246
+ });
24247
+ var PlacementStatsSchema = object({
24248
+ policy: SessionPlacementPolicySchema,
24249
+ windowMs: number(),
24250
+ /** How much of the window was actually observed (the hub keeps it in memory). */
24251
+ observedForMs: number(),
24252
+ sessionsNow: number(),
24253
+ /** Most parallel sessions across the cluster inside the window. */
24254
+ clusterPeakSessions: number(),
24255
+ nodes: array(PlacementNodeStatsSchema).readonly(),
24256
+ stalePins: array(StaleDevicePinSchema).readonly()
24257
+ });
24258
+ /**
24259
+ * Where an automatic share's capacity comes from (D755).
24260
+ * - `hardware` — the static capacity of each device at its Auto tune model:
24261
+ * the measured latency table for the node's hardware class.
24262
+ * - `benchmark` — the node's own measured capacity (node benchmark results).
24263
+ * A node with no results falls back to `hardware`, and says so.
24264
+ */
24265
+ var CapacitySourceSchema = _enum(["hardware", "benchmark"]);
24266
+ /** Why a node under the `benchmark` source was sized from hardware instead. */
24267
+ var CapacityFallbackSchema = _enum(["no-benchmark-results", "partial-benchmark-results"]);
24268
+ var SuggestSharesInputSchema = object({
24269
+ headroom: number().min(.3).max(.5).default(.4),
24270
+ /** Default `hardware`. */
24271
+ source: CapacitySourceSchema.default("hardware")
24272
+ });
24273
+ var SuggestedDeviceShareSchema = object({
24274
+ deviceKey: string(),
24275
+ /** Share of the node (0–100, rounded). */
24276
+ share: number(),
24277
+ modelId: string().nullable(),
24278
+ /** Inferences per second at that model, from the measured table; null when sized by a benchmark. */
24279
+ fps: number().nullable(),
24280
+ /** False for a table cell derived from another chip; true for a measured cell or a benchmark. */
24281
+ measured: boolean(),
24282
+ /**
24283
+ * Advisory session cap: ⌊fps × (1 − headroom) / camera fps⌋ from the table,
24284
+ * or the benchmark's measured max sessions. Never enforced.
24285
+ */
24286
+ suggestedMaxSessions: number().nullable()
24287
+ });
24288
+ var SuggestedNodeShareSchema = object({
24289
+ nodeId: string(),
24290
+ /** Share of the cluster (0–100, rounded); null = node skipped (offline, unknown hardware). */
24291
+ share: number().nullable(),
24292
+ /** Parallel sessions the node sustains at the suggested models with the headroom kept free. */
24293
+ capacitySessions: number(),
24294
+ devices: array(SuggestedDeviceShareSchema).readonly(),
24295
+ skippedReason: string().nullable(),
24296
+ /** What this node's capacity was read from; null when the node is skipped. */
24297
+ capacitySource: CapacitySourceSchema.nullable(),
24298
+ /** Set when the `benchmark` source was asked for and this node used `hardware`. */
24299
+ capacityFallback: CapacityFallbackSchema.nullable()
24300
+ });
24301
+ var SuggestSharesResultSchema = object({
24302
+ headroom: number(),
24303
+ /** The source asked for; each node says what it actually used. */
24304
+ source: CapacitySourceSchema,
24305
+ /** Camera detection rate the capacity is counted in (the fastest camera's fps). */
24306
+ cameraFps: number(),
24307
+ /** The Auto tune capacity floor the models were picked at. */
24308
+ minSessionsPerDevice: number(),
24309
+ nodes: array(SuggestedNodeShareSchema).readonly()
24310
+ });
24311
+ /**
24312
+ * Where the node and device shares come from (D750, D755).
24313
+ * - `hardware` (default) — computed by the system from each node's static
24314
+ * capacity (its accelerators at their Auto tune models, from the measured
24315
+ * latency table), recomputed whenever a node or device joins, leaves, is
24316
+ * switched on or off, or changes model. No load input.
24317
+ * - `benchmark` — the same, from each node's measured capacity (node
24318
+ * benchmark results); a node without results falls back to `hardware`.
24319
+ * - `manual` — the operator's stored sliders.
24320
+ * Either way a node or device that cannot take sessions gives its share to
24321
+ * the others in proportion; the stored values are never rewritten for that.
24322
+ */
24323
+ var ShareSourceSchema = _enum([
24324
+ "hardware",
24325
+ "benchmark",
24326
+ "manual"
24327
+ ]);
24328
+ var SetShareSourceInputSchema = object({ source: ShareSourceSchema });
24329
+ object({
24330
+ nodes: record(string(), number()),
24331
+ devices: record(string(), record(string(), number()))
24332
+ });
24333
+ var EffectiveDeviceShareSchema = object({
24334
+ deviceKey: string(),
24335
+ /** Can take sessions now (enabled, available, can run the root). */
24336
+ eligible: boolean(),
24337
+ /** The share the source assigns, as a percentage of the node's devices. */
24338
+ configuredPct: number(),
24339
+ /** The share actually used, after an ineligible device's share went to the others. */
24340
+ effectivePct: number()
24341
+ });
24342
+ var EffectiveNodeShareSchema = object({
24343
+ nodeId: string(),
24344
+ eligible: boolean(),
24345
+ configuredPct: number(),
24346
+ effectivePct: number(),
24347
+ /** Automatic sources: what this node's share was computed from; null under `manual` or before the first answer. */
24348
+ capacitySource: CapacitySourceSchema.nullable(),
24349
+ /** Automatic `benchmark`: why this node used `hardware` instead. */
24350
+ capacityFallback: CapacityFallbackSchema.nullable(),
24351
+ devices: array(EffectiveDeviceShareSchema).readonly()
24352
+ });
24353
+ var EffectiveSharesSchema = object({
24354
+ source: ShareSourceSchema,
24355
+ policy: SessionPlacementPolicySchema,
24356
+ /** An automatic source with no computed answer yet: the stored sliders are used meanwhile. */
24357
+ autoPending: boolean(),
24358
+ /** When the automatic shares were last computed (ms epoch); null = never. */
24359
+ computedAt: number().nullable(),
24360
+ /** What made the system recompute them last. */
24361
+ trigger: string().nullable(),
24362
+ nodes: array(EffectiveNodeShareSchema).readonly(),
24363
+ /** The relative weights the election reads (before eligibility). */
24364
+ nodeWeights: record(string(), number()),
24365
+ deviceWeights: record(string(), record(string(), number()))
24366
+ });
24344
24367
  object({
24345
24368
  activeCameras: number(),
24346
24369
  throttledCameras: number(),
@@ -25520,8 +25543,10 @@ var CameraStepOverridePatchSchema = object({
25520
25543
  modelId: string().optional(),
25521
25544
  settings: record(string(), unknown()).readonly().optional()
25522
25545
  });
25546
+ var CameraStepSettingsSchema = record(string(), record(string(), unknown()).readonly());
25523
25547
  var CameraPipelineSettingsSchema = object({
25524
25548
  stepToggles: record(string(), boolean()).optional(),
25549
+ stepSettings: CameraStepSettingsSchema.optional(),
25525
25550
  stepOverridesByDevice: record(string(), record(string(), record(string(), CameraStepOverridePatchSchema))).optional(),
25526
25551
  pipelineByAgent: record(string(), CameraPipelineForAgentSchema).optional()
25527
25552
  });
@@ -26085,6 +26110,13 @@ method(object({
26085
26110
  }), object({ success: literal(true) }), {
26086
26111
  kind: "mutation",
26087
26112
  auth: "admin"
26113
+ }), method(object({
26114
+ deviceId: number(),
26115
+ addonId: string(),
26116
+ settings: record(string(), unknown()).nullable()
26117
+ }), object({ success: literal(true) }), {
26118
+ kind: "mutation",
26119
+ auth: "admin"
26088
26120
  }), method(object({
26089
26121
  deviceId: number(),
26090
26122
  agentNodeId: string(),
@@ -42160,6 +42192,12 @@ Object.freeze({
42160
42192
  addonId: null,
42161
42193
  access: "create"
42162
42194
  },
42195
+ "pipelineOrchestrator.setCameraStepSettings": {
42196
+ capName: "pipeline-orchestrator",
42197
+ capScope: "system",
42198
+ addonId: null,
42199
+ access: "create"
42200
+ },
42163
42201
  "pipelineOrchestrator.setCameraStepToggle": {
42164
42202
  capName: "pipeline-orchestrator",
42165
42203
  capScope: "system",
@@ -47608,6 +47646,11 @@ Object.freeze({
47608
47646
  form: "single",
47609
47647
  optional: false
47610
47648
  }],
47649
+ "pipelineOrchestrator.setCameraStepSettings": [{
47650
+ name: "deviceId",
47651
+ form: "single",
47652
+ optional: false
47653
+ }],
47611
47654
  "pipelineOrchestrator.setCameraStepToggle": [{
47612
47655
  name: "deviceId",
47613
47656
  form: "single",
@@ -49568,6 +49611,15 @@ var exportAlexaActions = defineCustomActions({
49568
49611
  unlinkAlexaAccounts: customAction(object({}).optional(), object({
49569
49612
  revokedSessions: number(),
49570
49613
  removedAccounts: number()
49614
+ }), { kind: "mutation" }),
49615
+ /**
49616
+ * Swap the per-link credential this hub presents to the skill Lambda's
49617
+ * token broker (D753) for a fresh one; the old one dies at once. Rotation
49618
+ * also happens implicitly on every re-link.
49619
+ */
49620
+ rotateBrokerCredential: customAction(object({}).optional(), object({
49621
+ rotated: number(),
49622
+ failed: number()
49571
49623
  }), { kind: "mutation" })
49572
49624
  });
49573
49625
  //#endregion
@@ -50159,6 +50211,110 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
50159
50211
  } };
50160
50212
  }
50161
50213
  //#endregion
50214
+ //#region src/broker-client.ts
50215
+ /**
50216
+ * The hub's side of the skill's token broker (D753).
50217
+ *
50218
+ * The hub holds NO Login With Amazon client secret. To push a proactive event
50219
+ * it needs an LWA access token for the linked Amazon account, and it gets one
50220
+ * from the skill's Lambda (the broker) by presenting the per-link credential
50221
+ * the Lambda minted at link time (`csb1.<linkId>.<secret>`). The Lambda keeps
50222
+ * the refresh token and the client secret; the hub keeps a credential that
50223
+ * opens exactly one link and can be revoked or rotated.
50224
+ *
50225
+ * Every function here is a single HTTPS call with a timeout and a Zod-parsed
50226
+ * answer. Errors carry a `kind` so the gateway can tell "re-link needed" from
50227
+ * "try again later"; their messages never contain a token or a credential.
50228
+ */
50229
+ var BROKER_TIMEOUT_MS = 8e3;
50230
+ /** Origin-only, HTTPS-only. Anything else is refused before a credential is sent. */
50231
+ function normaliseBrokerUrl(raw) {
50232
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 512) return null;
50233
+ try {
50234
+ const u = new URL(raw);
50235
+ if (u.protocol !== "https:" || u.username || u.password) return null;
50236
+ return u.origin;
50237
+ } catch {
50238
+ return null;
50239
+ }
50240
+ }
50241
+ var BrokerError = class extends Error {
50242
+ kind;
50243
+ status;
50244
+ constructor(kind, message, status) {
50245
+ super(message);
50246
+ this.kind = kind;
50247
+ this.status = status;
50248
+ this.name = "BrokerError";
50249
+ }
50250
+ };
50251
+ var AccessTokenSchema = object({
50252
+ accessToken: string().min(1),
50253
+ expiresAt: number().int().positive()
50254
+ });
50255
+ var MigrationSchema = AccessTokenSchema.extend({
50256
+ brokerCredential: string().min(1),
50257
+ brokerUrl: string().min(1)
50258
+ });
50259
+ var RotationSchema = object({ brokerCredential: string().min(1) });
50260
+ function kindForStatus(status) {
50261
+ if (status === 401) return "unauthorized";
50262
+ if (status === 410) return "revoked";
50263
+ if (status === 400 || status === 403 || status === 404 || status === 405) return "rejected";
50264
+ return "unavailable";
50265
+ }
50266
+ async function call(fetchImpl, brokerUrl, path, init, schema) {
50267
+ const origin = normaliseBrokerUrl(brokerUrl);
50268
+ if (!origin) throw new BrokerError("rejected", "broker URL is not an https origin");
50269
+ let response;
50270
+ try {
50271
+ response = await fetchImpl(`${origin}${path}`, {
50272
+ method: "POST",
50273
+ headers: {
50274
+ "content-type": "application/json",
50275
+ ...init.bearer ? { authorization: `Bearer ${init.bearer}` } : {}
50276
+ },
50277
+ body: JSON.stringify(init.body ?? {}),
50278
+ signal: AbortSignal.timeout(BROKER_TIMEOUT_MS)
50279
+ });
50280
+ } catch (err) {
50281
+ throw new BrokerError("unavailable", `broker ${path} unreachable: ${err instanceof Error ? err.message : String(err)}`);
50282
+ }
50283
+ const text = await response.text().catch(() => "");
50284
+ let json = null;
50285
+ try {
50286
+ json = text ? JSON.parse(text) : null;
50287
+ } catch {
50288
+ json = null;
50289
+ }
50290
+ if (!response.ok) {
50291
+ const code = json && typeof json === "object" && "error" in json && typeof json.error === "string" ? json.error : "error";
50292
+ throw new BrokerError(kindForStatus(response.status), `broker ${path} ${response.status} ${code}`, response.status);
50293
+ }
50294
+ const parsed = schema.safeParse(json);
50295
+ if (!parsed.success) throw new BrokerError("unavailable", `broker ${path} answered an unexpected shape`);
50296
+ return parsed.data;
50297
+ }
50298
+ /** A fresh (or still-valid) Event Gateway access token for this link. */
50299
+ function fetchBrokerAccessToken(fetchImpl, brokerUrl, credential) {
50300
+ return call(fetchImpl, brokerUrl, "/broker/token", { bearer: credential }, AccessTokenSchema);
50301
+ }
50302
+ /**
50303
+ * Hand a pre-D753 refresh token to the broker in exchange for a link. The
50304
+ * caller deletes its own copy of the refresh token on success.
50305
+ */
50306
+ function migrateLegacyGrant(fetchImpl, brokerUrl, input) {
50307
+ return call(fetchImpl, brokerUrl, "/broker/migrate", { body: input }, MigrationSchema);
50308
+ }
50309
+ /** Swap the credential's secret; the old one stops working at once. */
50310
+ async function rotateBrokerCredential(fetchImpl, brokerUrl, credential) {
50311
+ return (await call(fetchImpl, brokerUrl, "/broker/rotate", { bearer: credential }, RotationSchema)).brokerCredential;
50312
+ }
50313
+ /** Delete the link at the broker (the refresh token goes with it). */
50314
+ async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
50315
+ await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
50316
+ }
50317
+ //#endregion
50162
50318
  //#region src/handlers/security-panel-arm-state.ts
50163
50319
  /**
50164
50320
  * The panel modes behind each Alexa arm state, PRIMARY first — the mirror of
@@ -51768,8 +51924,12 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
51768
51924
  //#endregion
51769
51925
  //#region src/handlers/lwa-tokens-callback.ts
51770
51926
  /**
51771
- * `POST /addon/export-alexa/oauth/lwa-tokens` — callback the Alexa
51772
- * Lambda invokes after it completes the LWA grant exchange.
51927
+ * `POST /addon/export-alexa/oauth/lwa-tokens` — the LEGACY (pre-D753)
51928
+ * callback a Lambda without the token broker invokes after the LWA grant
51929
+ * exchange. It hands this hub the refresh token itself. Kept so an old Lambda
51930
+ * keeps linking during the cut-over; the hub cannot renew such a grant (it
51931
+ * holds no client secret) and migrates it to the broker instead. The D753
51932
+ * Lambda calls `/oauth/broker-link` (`broker-link-callback.ts`).
51773
51933
  *
51774
51934
  * Auth: Bearer `<granteeToken>` where the grantee token is the OAuth
51775
51935
  * access token (kind=`oauth-access`) issued by the hub's core OAuth
@@ -51822,7 +51982,7 @@ async function handleLwaTokensCallback(request, reply, deps) {
51822
51982
  userId,
51823
51983
  username
51824
51984
  });
51825
- deps.logger.info("alexa lwa-tokens-callback: stored LWA tokens + recorded link", { meta: {
51985
+ deps.logger.warn("alexa lwa-tokens-callback: linked through a Lambda WITHOUT the token broker — this hub holds no LWA secret, so the grant cannot be renewed here; update the skill Lambda (D753). It migrates automatically once the Lambda advertises its broker.", { meta: {
51826
51986
  userId,
51827
51987
  expiresInMs: body.expiresAt - Date.now()
51828
51988
  } });
@@ -51859,6 +52019,78 @@ function pickHeader(headers, name) {
51859
52019
  return null;
51860
52020
  }
51861
52021
  //#endregion
52022
+ //#region src/handlers/broker-link-callback.ts
52023
+ /**
52024
+ * `POST /addon/export-alexa/oauth/broker-link` — the skill Lambda tells this
52025
+ * hub that an Amazon account was linked (D753).
52026
+ *
52027
+ * The Lambda has already exchanged the AcceptGrant code with Amazon and kept
52028
+ * the refresh token. It gives this hub:
52029
+ * - `brokerUrl` where to ask for renewed access tokens,
52030
+ * - `brokerCredential` this link's credential at the broker,
52031
+ * - `lwaAccessToken` / `expiresAt` the first Event Gateway token.
52032
+ * No refresh token and no client secret reach the hub.
52033
+ *
52034
+ * Auth: identical to the legacy `/oauth/lwa-tokens` callback — Bearer
52035
+ * `<granteeToken>`, the hub's own `oauth-access` token Amazon presented with
52036
+ * the AcceptGrant, verified by `oauthVerifyAccessToken` (and the ingress-token
52037
+ * guard in `http-server.ts`). A Lambda that does not know this route gets 404
52038
+ * and falls back to the legacy callback, which is how the cut-over stays
52039
+ * zero-downtime in both directions.
52040
+ *
52041
+ * Errors: 400 `invalid_body` / `invalid_broker_url`, 401 `unauthorized`.
52042
+ */
52043
+ var BrokerLinkBodySchema = object({
52044
+ brokerUrl: string().min(1).max(512),
52045
+ brokerCredential: string().min(1).max(512),
52046
+ lwaAccessToken: string().min(1),
52047
+ expiresAt: number().int().nonnegative()
52048
+ });
52049
+ async function handleBrokerLinkCallback(request, reply, deps) {
52050
+ const parsed = BrokerLinkBodySchema.safeParse(request.body);
52051
+ if (!parsed.success) {
52052
+ reply.code(400).send({
52053
+ error: "invalid_body",
52054
+ message: parsed.error.message
52055
+ });
52056
+ return;
52057
+ }
52058
+ const body = parsed.data;
52059
+ const brokerUrl = normaliseBrokerUrl(body.brokerUrl);
52060
+ if (!brokerUrl) {
52061
+ reply.code(400).send({
52062
+ error: "invalid_broker_url",
52063
+ message: "brokerUrl must be an https origin"
52064
+ });
52065
+ return;
52066
+ }
52067
+ const caller = await resolveCaller(request, deps);
52068
+ if (!caller) {
52069
+ deps.logger.warn("alexa broker-link: unauthorized", { meta: { hasBearer: pickHeader(request.headers, "authorization") !== null } });
52070
+ reply.code(401).send({
52071
+ error: "unauthorized",
52072
+ message: "callback requires Bearer <granteeToken> (oauth-access token)"
52073
+ });
52074
+ return;
52075
+ }
52076
+ await deps.upsertToken({
52077
+ userId: caller.userId,
52078
+ accessToken: body.lwaAccessToken,
52079
+ expiresAt: body.expiresAt,
52080
+ brokerUrl,
52081
+ brokerCredential: body.brokerCredential
52082
+ });
52083
+ await deps.recordLinkedAccount({
52084
+ userId: caller.userId,
52085
+ username: caller.username
52086
+ });
52087
+ deps.logger.info("alexa broker-link: linked — push grant held by the skill broker", { meta: {
52088
+ userId: caller.userId,
52089
+ expiresInMs: body.expiresAt - Date.now()
52090
+ } });
52091
+ reply.code(200).send({ ok: true });
52092
+ }
52093
+ //#endregion
51862
52094
  //#region src/ingress-token.ts
51863
52095
  /**
51864
52096
  * Ingress token — a rotatable shared secret that the user-owned Alexa Lambda
@@ -51916,7 +52148,8 @@ function readIngressHeader(headers) {
51916
52148
  *
51917
52149
  * Public URLs (assuming the hub is reachable as `https://<hub>`):
51918
52150
  * POST /addon/export-alexa/directive
51919
- * POST /addon/export-alexa/oauth/lwa-tokens (Lambda → hub callback)
52151
+ * POST /addon/export-alexa/oauth/broker-link (Lambda → hub, D753 broker link)
52152
+ * POST /addon/export-alexa/oauth/lwa-tokens (Lambda → hub, legacy pre-D753)
51920
52153
  *
51921
52154
  * OAuth account-linking is handled by the core `/oauth2/*` server
51922
52155
  * (integration=export-alexa). This addon registers an `oauth-integration`
@@ -51927,6 +52160,8 @@ var OauthVerifiedResultSchema = object({
51927
52160
  username: string(),
51928
52161
  scopes: array(TokenScopeSchema)
51929
52162
  }).nullable();
52163
+ /** Header the D753 Lambda sends its broker origin on. */
52164
+ var BROKER_HEADER = "x-camstack-broker";
51930
52165
  function buildRoutes(deps) {
51931
52166
  return [
51932
52167
  {
@@ -51936,6 +52171,16 @@ function buildRoutes(deps) {
51936
52171
  description: "Alexa Smart Home directive ingress (forwarded by the user-owned Lambda)",
51937
52172
  handler: (req, reply) => handleDirective(req, reply, deps)
51938
52173
  },
52174
+ {
52175
+ method: "POST",
52176
+ path: "/oauth/broker-link",
52177
+ access: "public",
52178
+ description: "Lambda → hub broker link (D753): broker URL + per-link credential + first access token; bearer-authenticated via oauth-access token",
52179
+ handler: async (req, reply) => {
52180
+ if (ingressRejected(req, reply, deps, "alexa broker-link")) return;
52181
+ await handleBrokerLinkCallback(req, reply, deps.lwaTokensCallbackDeps);
52182
+ }
52183
+ },
51939
52184
  {
51940
52185
  method: "POST",
51941
52186
  path: "/oauth/lwa-tokens",
@@ -52046,6 +52291,8 @@ async function handleDirective(request, reply, deps) {
52046
52291
  reply.code(200).send(preParseErrorEnvelope("INVALID_AUTHORIZATION_CREDENTIAL", "token does not grant access to device capabilities"));
52047
52292
  return;
52048
52293
  }
52294
+ const advertised = readHeader(request.headers, BROKER_HEADER);
52295
+ if (advertised && deps.onBrokerAdvertised) deps.onBrokerAdvertised(advertised);
52049
52296
  const callerIdentity = {
52050
52297
  userId: verified.userId,
52051
52298
  username: verified.username,
@@ -52054,6 +52301,19 @@ async function handleDirective(request, reply, deps) {
52054
52301
  const result = await dispatchDirective(request.body, deps.directiveDeps, callerIdentity);
52055
52302
  reply.code(result.status).send(result.body);
52056
52303
  }
52304
+ function readHeader(headers, name) {
52305
+ for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === name) return value;
52306
+ }
52307
+ //#endregion
52308
+ //#region src/types.ts
52309
+ var DEFAULT_DEVICE_SETTINGS = {
52310
+ streamPreference: "auto",
52311
+ sourceProfile: "high"
52312
+ };
52313
+ /** A grant the hub can renew through the broker. */
52314
+ function isBrokerGrant(t) {
52315
+ return typeof t.brokerUrl === "string" && typeof t.brokerCredential === "string";
52316
+ }
52057
52317
  /** `listOauthSessions` returns every session for every integration. */
52058
52318
  var OauthSessionListSchema = array(object({
52059
52319
  id: string(),
@@ -52102,15 +52362,15 @@ function buildLinkStatusContent(input) {
52102
52362
  lines.push(`• ${acc.username} — linked ${stamp(acc.linkedAt)}`);
52103
52363
  lines.push(` ${describeGrant(acc, input)}`);
52104
52364
  }
52105
- if (!input.hasLwaCredentials) lines.push("Proactive push is DISABLED — no LWA client id/secret is configured, so motion, doorbell and discovery reports are all skipped regardless of the grants above.");
52106
52365
  return lines.join("\n");
52107
52366
  }
52108
52367
  function describeGrant(acc, input) {
52109
52368
  const token = input.tokens.find((t) => t.userId === acc.userId);
52110
52369
  if (!token) return "Amazon push grant: MISSING — proactive events (motion, doorbell, discovery) cannot be delivered for this account. Re-link the skill in the Alexa app to re-issue it.";
52111
52370
  const renewedAgo = minutes(input.now - token.receivedAt);
52112
- if (token.expiresAt <= input.now) return `Amazon push grant: EXPIRED ${minutes(input.now - token.expiresAt)} min ago (last renewed ${renewedAgo} min ago). The gateway renews ${minutes(RENEWAL_LEAD_MS)} min before expiry, so renewal is failing and proactive events are NOT reaching Alexa — check the addon log for "LWA refresh non-OK".`;
52113
- return `Amazon push grant: held, valid for another ${minutes(token.expiresAt - input.now)} min (last renewed ${renewedAgo} min ago).`;
52371
+ if (!isBrokerGrant(token)) return `Amazon push grant: LEGACY — delivered by a skill Lambda without the token broker, and this hub holds no LWA secret to renew it (D753); ${token.expiresAt <= input.now ? `its token EXPIRED ${minutes(input.now - token.expiresAt)} min ago, so proactive events are NOT reaching Alexa` : `its token is valid for another ${minutes(token.expiresAt - input.now)} min`}. It moves to the skill's broker automatically on the next Alexa request; if it does not, update the skill Lambda or re-link.`;
52372
+ if (token.expiresAt <= input.now) return `Amazon push grant: EXPIRED ${minutes(input.now - token.expiresAt)} min ago (last renewed ${renewedAgo} min ago). The gateway renews ${minutes(RENEWAL_LEAD_MS)} min before expiry through the skill's token broker, so renewal is failing and proactive events are NOT reaching Alexa — check the addon log for "broker renewal failed".`;
52373
+ return `Amazon push grant: held via the skill's token broker, valid for another ${minutes(token.expiresAt - input.now)} min (last renewed ${renewedAgo} min ago).`;
52114
52374
  }
52115
52375
  //#endregion
52116
52376
  //#region src/motion-clear-hold.ts
@@ -52211,15 +52471,16 @@ var MotionClearHold = class {
52211
52471
  * exposed.
52212
52472
  * 3. For each linked Alexa account, POST a `DoorbellEventSource/
52213
52473
  * DoorbellPress` payload to `api.amazonalexa.com/v3/events`.
52214
- * 4. On 401, refresh the LWA token via
52215
- * `https://api.amazon.com/auth/o2/token` and retry once.
52474
+ * 4. On 401, ask the skill's token broker for a fresh access token and
52475
+ * retry once.
52216
52476
  *
52217
- * The actual LWA grant exchange (Lambda → Amazon → access+refresh) is
52218
- * owned by the operator-side Lambda, which POSTs the resulting tokens to
52219
- * this addon's `/oauth/lwa-tokens` callback. This client assumes
52220
- * `this.config.tokens[userId]` is already populated with valid
52221
- * `accessToken` / `refreshToken` values. When tokens are absent we
52222
- * log + skip without erroring.
52477
+ * This hub holds NO Login With Amazon client secret (D753). The skill's
52478
+ * Lambda exchanges the AcceptGrant code, keeps the refresh token and the
52479
+ * secret, and hands this hub short-lived access tokens against a per-link
52480
+ * credential (`broker-client.ts`). A grant delivered by a pre-D753 Lambda
52481
+ * (with a refresh token) cannot be renewed here; it is migrated to the broker
52482
+ * as soon as the broker's URL is known, and the refresh token is deleted.
52483
+ * When no grant is stored we log + skip without erroring.
52223
52484
  *
52224
52485
  * Throttling: Alexa requires at most one DoorbellPress per endpoint
52225
52486
  * every 30s. We enforce this per (userId, endpointId) tuple in-memory.
@@ -52229,10 +52490,11 @@ var EVENT_GATEWAY_URLS = {
52229
52490
  EU: "https://api.eu.amazonalexa.com/v3/events",
52230
52491
  FE: "https://api.fe.amazonalexa.com/v3/events"
52231
52492
  };
52232
- var LWA_TOKEN_URL = "https://api.amazon.com/auth/o2/token";
52233
52493
  var THROTTLE_WINDOW_MS = 3e4;
52234
52494
  var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
52235
52495
  var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
52496
+ /** After a failed legacy-grant migration, wait this long before trying again. */
52497
+ var MIGRATION_RETRY_MS = 300 * 1e3;
52236
52498
  /** The interface an endpoint must advertise for a `DoorbellPress` to survive
52237
52499
  * the Alexa cloud. */
52238
52500
  var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
@@ -52282,9 +52544,13 @@ var LwaEventGateway = class {
52282
52544
  * Alexa Smart Home rate-limits ChangeReports per property; dedupe
52283
52545
  * so only level *changes* (or step-grain ones) hit the gateway. */
52284
52546
  lastBatteryLevel = /* @__PURE__ */ new Map();
52285
- /** Latches the "no LWA credentials" warning so it's logged ONCE, not on
52286
- * every doorbell/motion event when proactive push is unconfigured. */
52287
- warnedNoLwaCreds = false;
52547
+ /** Latches the "no grant at all" warning so it's logged ONCE, not on every
52548
+ * doorbell/motion event while no account has a push grant. */
52549
+ warnedNoGrant = false;
52550
+ /** Users whose legacy grant is waiting for the broker URL (warned once). */
52551
+ warnedLegacyWaiting = /* @__PURE__ */ new Set();
52552
+ /** Per-user earliest next migration attempt after a failure. */
52553
+ migrationRetryAt = /* @__PURE__ */ new Map();
52288
52554
  /** Users we have already reported as having no stored Amazon push grant.
52289
52555
  * Cleared for a user the moment a grant is found again, so a re-link
52290
52556
  * re-arms the warning instead of muting it for the process's lifetime. */
@@ -52329,6 +52595,8 @@ var LwaEventGateway = class {
52329
52595
  this.smartVisionDropped.clear();
52330
52596
  this.lastBatteryLevel.clear();
52331
52597
  this.warnedNoToken.clear();
52598
+ this.warnedLegacyWaiting.clear();
52599
+ this.migrationRetryAt.clear();
52332
52600
  }
52333
52601
  async onDoorbellPress(data) {
52334
52602
  const childDevice = await this.deps.ctx.api.deviceManager.getDevice.query({ deviceId: data.deviceId }).catch(() => null);
@@ -52640,12 +52908,15 @@ var LwaEventGateway = class {
52640
52908
  } });
52641
52909
  return true;
52642
52910
  }
52911
+ get fetchImpl() {
52912
+ return this.deps.fetchImpl ?? ((url, init) => fetch(url, init));
52913
+ }
52643
52914
  async refreshExpiringTokens() {
52644
52915
  if (!this.hasProactiveCredentials()) return;
52645
52916
  const tokens = this.deps.listTokens();
52646
52917
  const now = Date.now();
52647
52918
  for (const token of tokens) {
52648
- if (token.expiresAt - now > REFRESH_PROACTIVE_WINDOW_MS) continue;
52919
+ if (!(!isBrokerGrant(token) || token.expiresAt - now <= REFRESH_PROACTIVE_WINDOW_MS)) continue;
52649
52920
  await this.refreshAccessToken(token.userId).catch((err) => {
52650
52921
  this.deps.logger.warn("alexa lwa-gateway: proactive refresh failed", { meta: {
52651
52922
  userId: token.userId,
@@ -52654,61 +52925,106 @@ var LwaEventGateway = class {
52654
52925
  });
52655
52926
  }
52656
52927
  }
52928
+ /** A fresh access token for `userId`, or null. Never throws for broker errors. */
52657
52929
  async refreshAccessToken(userId) {
52658
- const creds = this.deps.getLwaCredentials();
52659
- if (!creds) {
52660
- this.deps.logger.warn("alexa lwa-gateway: LWA client credentials not configured", { meta: { userId } });
52661
- return null;
52662
- }
52663
52930
  const existing = this.findToken(userId);
52664
52931
  if (!existing) return null;
52665
- const params = new URLSearchParams();
52666
- params.set("grant_type", "refresh_token");
52667
- params.set("refresh_token", existing.refreshToken);
52668
- params.set("client_id", creds.clientId);
52669
- params.set("client_secret", creds.clientSecret);
52670
- const response = await fetch(LWA_TOKEN_URL, {
52671
- method: "POST",
52672
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
52673
- body: params.toString()
52674
- });
52675
- if (!response.ok) {
52676
- const text = await response.text().catch(() => "");
52677
- this.deps.logger.warn("alexa lwa-gateway: LWA refresh non-OK", { meta: {
52932
+ if (!isBrokerGrant(existing)) return this.migrateLegacyGrant(existing);
52933
+ try {
52934
+ const fresh = await fetchBrokerAccessToken(this.fetchImpl, existing.brokerUrl, existing.brokerCredential);
52935
+ await this.deps.upsertToken({
52678
52936
  userId,
52679
- status: response.status,
52680
- body: text.slice(0, 500)
52937
+ accessToken: fresh.accessToken,
52938
+ expiresAt: fresh.expiresAt,
52939
+ brokerUrl: existing.brokerUrl,
52940
+ brokerCredential: existing.brokerCredential
52941
+ });
52942
+ return fresh.accessToken;
52943
+ } catch (err) {
52944
+ if (err instanceof BrokerError && (err.kind === "unauthorized" || err.kind === "revoked")) {
52945
+ this.deps.logger.warn("alexa lwa-gateway: the skill broker no longer recognises this link — proactive events stop; re-link the skill in the Alexa app", { meta: {
52946
+ userId,
52947
+ kind: err.kind
52948
+ } });
52949
+ await this.deps.removeLinkedAccount(userId).catch(() => {});
52950
+ return null;
52951
+ }
52952
+ this.deps.logger.warn("alexa lwa-gateway: broker renewal failed — will retry", { meta: {
52953
+ userId,
52954
+ error: err instanceof Error ? err.message : String(err)
52681
52955
  } });
52682
52956
  return null;
52683
52957
  }
52684
- const parsed = await response.json();
52685
- if (typeof parsed.access_token !== "string" || typeof parsed.expires_in !== "number") {
52686
- this.deps.logger.warn("alexa lwa-gateway: malformed LWA refresh response", { meta: { userId } });
52958
+ }
52959
+ /**
52960
+ * Hand a pre-D753 refresh token to the skill's broker and keep the link it
52961
+ * returns instead. Zero-touch: the broker URL arrives on the next verified
52962
+ * directive. On success the refresh token is gone from this hub.
52963
+ */
52964
+ async migrateLegacyGrant(entry) {
52965
+ const userId = entry.userId;
52966
+ if (!entry.refreshToken) return null;
52967
+ const brokerUrl = this.deps.getAdvertisedBrokerUrl();
52968
+ if (!brokerUrl) {
52969
+ if (!this.warnedLegacyWaiting.has(userId)) {
52970
+ this.warnedLegacyWaiting.add(userId);
52971
+ this.deps.logger.warn("alexa lwa-gateway: this Amazon push grant predates the skill broker and this hub holds no LWA secret to renew it; it migrates automatically once the skill Lambda advertises its broker (next Alexa directive). Until then events stop when the current token expires.", { meta: {
52972
+ userId,
52973
+ expiresInMs: entry.expiresAt - Date.now()
52974
+ } });
52975
+ }
52976
+ return null;
52977
+ }
52978
+ const retryAt = this.migrationRetryAt.get(userId) ?? 0;
52979
+ if (Date.now() < retryAt) return null;
52980
+ try {
52981
+ const migrated = await migrateLegacyGrant(this.fetchImpl, brokerUrl, {
52982
+ refreshToken: entry.refreshToken,
52983
+ ...this.deps.getPublicHubUrl ? { hubUrl: this.deps.getPublicHubUrl() } : {}
52984
+ });
52985
+ await this.deps.upsertToken({
52986
+ userId,
52987
+ accessToken: migrated.accessToken,
52988
+ expiresAt: migrated.expiresAt,
52989
+ brokerUrl: normaliseBrokerUrl(migrated.brokerUrl) ?? brokerUrl,
52990
+ brokerCredential: migrated.brokerCredential
52991
+ });
52992
+ this.migrationRetryAt.delete(userId);
52993
+ this.warnedLegacyWaiting.delete(userId);
52994
+ this.deps.logger.info("alexa lwa-gateway: migrated the Amazon push grant to the skill broker; the refresh token was deleted from this hub", { meta: { userId } });
52995
+ return migrated.accessToken;
52996
+ } catch (err) {
52997
+ this.migrationRetryAt.set(userId, Date.now() + MIGRATION_RETRY_MS);
52998
+ const kind = err instanceof BrokerError ? err.kind : "unavailable";
52999
+ const status = err instanceof BrokerError ? err.status : void 0;
53000
+ if (kind === "rejected" && status === 400) {
53001
+ this.deps.logger.warn("alexa lwa-gateway: Amazon no longer honours this push grant — re-link the skill in the Alexa app", { meta: { userId } });
53002
+ await this.deps.removeLinkedAccount(userId).catch(() => {});
53003
+ return null;
53004
+ }
53005
+ this.deps.logger.warn("alexa lwa-gateway: migrating the push grant to the broker failed", { meta: {
53006
+ userId,
53007
+ error: err instanceof Error ? err.message : String(err)
53008
+ } });
52687
53009
  return null;
52688
53010
  }
52689
- const refreshTokenNext = typeof parsed.refresh_token === "string" ? parsed.refresh_token : existing.refreshToken;
52690
- await this.deps.upsertToken({
52691
- userId,
52692
- accessToken: parsed.access_token,
52693
- refreshToken: refreshTokenNext,
52694
- expiresAt: Date.now() + parsed.expires_in * 1e3
52695
- });
52696
- return parsed.access_token;
52697
53011
  }
52698
53012
  findToken(userId) {
52699
53013
  return this.deps.listTokens().find((t) => t.userId === userId) ?? null;
52700
53014
  }
52701
53015
  /**
52702
- * Proactive push (doorbell/motion) needs the skill's LWA "Send Alexa Events"
52703
- * credentials to mint/refresh Event-Gateway tokens. When they're not
52704
- * configured, pushing is futile (Amazon returns INVALID_ACCESS_TOKEN and we
52705
- * cannot refresh) — skip it and warn ONCE instead of once per event.
53016
+ * Proactive push needs a stored grant for at least one account. With none,
53017
+ * pushing is futile — skip it and warn ONCE instead of once per event.
53018
+ * (Per-account absence is reported in `postEventBody`.)
52706
53019
  */
52707
53020
  hasProactiveCredentials() {
52708
- if (this.deps.getLwaCredentials() !== null) return true;
52709
- if (!this.warnedNoLwaCreds) {
52710
- this.warnedNoLwaCreds = true;
52711
- this.deps.logger.warn("alexa lwa-gateway: proactive events (doorbell/motion) disabled — set the LWA client id/secret in the Alexa addon settings (from the skill Permissions → \"Send Alexa Events\" panel)");
53021
+ if (this.deps.listTokens().length > 0) {
53022
+ this.warnedNoGrant = false;
53023
+ return true;
53024
+ }
53025
+ if (!this.warnedNoGrant) {
53026
+ this.warnedNoGrant = true;
53027
+ this.deps.logger.warn("alexa lwa-gateway: proactive events (doorbell/motion/discovery) skipped — no Amazon push grant is stored; link the skill in the Alexa app");
52712
53028
  }
52713
53029
  return false;
52714
53030
  }
@@ -52957,23 +53273,21 @@ function buildAlexaOauthIntegration(publicHubUrl) {
52957
53273
  //#endregion
52958
53274
  //#region src/public-skill.ts
52959
53275
  /**
52960
- * Built-in "public" Alexa skill credentials.
53276
+ * The built-in "public" CamStack Alexa skill.
52961
53277
  *
52962
- * These identify the single published CamStack Alexa skill that every hub
52963
- * shares in `skillMode: 'public'`. They are intentionally committed to source:
52964
- * a published Smart Home skill has ONE set of "Send Alexa Events" credentials,
52965
- * and each self-hosted hub pushes proactive events DIRECTLY to the Alexa Event
52966
- * Gateway using them. The real per-account gate is the OAuth access/refresh
52967
- * token minted during account-linking — the client secret alone cannot push
52968
- * events to an account without that account's grant. This is the standard
52969
- * published-skill model for self-hosted servers.
53278
+ * Only its skill id lives here — an identifier, not a credential. Until D753
53279
+ * this file also carried the skill's Login With Amazon "Send Alexa Events"
53280
+ * client id and SECRET, on the theory that a per-account refresh token was the
53281
+ * real gate. The package is public on npm, so that secret was published with
53282
+ * every version and has to be rotated. Since D753 the secret lives only in the
53283
+ * skill's Lambda (AWS Secrets Manager); the hub gets short-lived Event Gateway
53284
+ * tokens from the Lambda's broker (`broker-client.ts`) with a per-link
53285
+ * credential. `scripts/check-no-client-secrets.ts` keeps it that way.
52970
53286
  *
52971
- * Operators who run their OWN skill switch to `skillMode: 'custom'` and supply
52972
- * their own values via settings; these constants are then unused.
53287
+ * Operators who run their OWN skill switch to `skillMode: 'custom'` and deploy
53288
+ * the same Lambda (`lambda-template/`) with their own secrets.
52973
53289
  */
52974
53290
  var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
52975
- var PUBLIC_LWA_CLIENT_ID = "amzn1.application-oa2-client.0033e4a5b8614b11a609f5eae7cbda82";
52976
- var PUBLIC_LWA_CLIENT_SECRET = "amzn1.oa2-cs.v1.82f852bc5d4a7fb906d670d36f936abd3011ac8c229d066b22a57a9f38488f1e";
52977
53291
  //#endregion
52978
53292
  //#region src/endpoint-signature.ts
52979
53293
  /** One capability's identity as Alexa distinguishes it. */
@@ -53220,12 +53534,6 @@ var SyncEngine = class {
53220
53534
  }
53221
53535
  };
53222
53536
  //#endregion
53223
- //#region src/types.ts
53224
- var DEFAULT_DEVICE_SETTINGS = {
53225
- streamPreference: "auto",
53226
- sourceProfile: "high"
53227
- };
53228
- //#endregion
53229
53537
  //#region src/export-alexa.addon.ts
53230
53538
  /**
53231
53539
  * Alexa Smart Home Skill export addon — hub-side.
@@ -53294,6 +53602,7 @@ var DEFAULT_CONFIG = {
53294
53602
  expectedAudience: "",
53295
53603
  lwaClientId: "",
53296
53604
  lwaClientSecret: "",
53605
+ advertisedBrokerUrl: "",
53297
53606
  region: "NA",
53298
53607
  exposed: [],
53299
53608
  linkedAccounts: [],
@@ -53323,6 +53632,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53323
53632
  }
53324
53633
  async onInitialize() {
53325
53634
  if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
53635
+ if (this.config.lwaClientSecret) {
53636
+ await this.updateGlobalSettings({ lwaClientSecret: "" });
53637
+ this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
53638
+ }
53326
53639
  await this.backfillLinkedAccounts();
53327
53640
  this.autoDetectPublicHubUrl().catch((err) => {
53328
53641
  this.ctx.logger.warn("export-alexa: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
@@ -53393,7 +53706,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53393
53706
  upsertToken: (input) => this.upsertToken(input),
53394
53707
  recordLinkedAccount: (input) => this.recordLinkedAccount(input)
53395
53708
  },
53396
- redetectPublicHubUrl: () => this.redetectPublicHubUrl()
53709
+ redetectPublicHubUrl: () => this.redetectPublicHubUrl(),
53710
+ onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw)
53397
53711
  }));
53398
53712
  const oauthIntegrationProvider = { getDescriptor: async () => buildAlexaOauthIntegration(this.config.publicHubUrl) };
53399
53713
  this.lwaGateway = new LwaEventGateway({
@@ -53404,7 +53718,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53404
53718
  listTokens: () => this.config.tokens,
53405
53719
  upsertToken: (input) => this.upsertToken(input),
53406
53720
  removeLinkedAccount: (userId) => this.removeLinkedAccount(userId),
53407
- getLwaCredentials: () => this.lwaCredentials(),
53721
+ getAdvertisedBrokerUrl: () => this.config.advertisedBrokerUrl || null,
53722
+ getPublicHubUrl: () => this.config.publicHubUrl,
53408
53723
  getRegion: () => this.config.region,
53409
53724
  getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
53410
53725
  });
@@ -53413,7 +53728,7 @@ var ExportAlexaAddon = class extends BaseAddon {
53413
53728
  logger: this.ctx.logger,
53414
53729
  listExposed: () => this.config.exposed,
53415
53730
  listLinkedAccounts: () => this.config.linkedAccounts,
53416
- hasProactiveCredentials: () => this.lwaCredentials() !== null,
53731
+ hasProactiveCredentials: () => this.config.tokens.length > 0,
53417
53732
  buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
53418
53733
  postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
53419
53734
  postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
@@ -53448,7 +53763,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53448
53763
  exposedCount: this.config.exposed.length,
53449
53764
  skillMode: this.config.skillMode,
53450
53765
  skillIdSet: this.effectiveSettings().expectedAudience.length > 0,
53451
- lwaConfigured: this.lwaCredentials() !== null
53766
+ pushGrants: this.config.tokens.length,
53767
+ brokerGrants: this.config.tokens.filter(isBrokerGrant).length
53452
53768
  } });
53453
53769
  return {
53454
53770
  providers: [
@@ -53471,7 +53787,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53471
53787
  regenerateIngressToken: async () => this.regenerateIngressToken(),
53472
53788
  resyncDiscovery: async () => this.resyncDiscovery(),
53473
53789
  purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
53474
- unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts()
53790
+ unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
53791
+ rotateBrokerCredential: async () => this.rotateBrokerCredentials()
53475
53792
  }
53476
53793
  };
53477
53794
  }
@@ -53693,6 +54010,7 @@ var ExportAlexaAddon = class extends BaseAddon {
53693
54010
  }
53694
54011
  this.ctx.logger.warn("export-alexa: revokeOauthSession reported failure — the session may already be gone", { meta: { sessionId: id } });
53695
54012
  }
54013
+ await this.revokeAtBroker(this.config.tokens);
53696
54014
  const removedAccounts = this.config.linkedAccounts.length;
53697
54015
  await this.updateGlobalSettings({
53698
54016
  linkedAccounts: [],
@@ -53714,9 +54032,11 @@ var ExportAlexaAddon = class extends BaseAddon {
53714
54032
  const entry = {
53715
54033
  userId: input.userId,
53716
54034
  accessToken: input.accessToken,
53717
- refreshToken: input.refreshToken,
53718
54035
  expiresAt: input.expiresAt,
53719
- receivedAt: Date.now()
54036
+ receivedAt: Date.now(),
54037
+ ...input.refreshToken ? { refreshToken: input.refreshToken } : {},
54038
+ ...input.brokerUrl ? { brokerUrl: input.brokerUrl } : {},
54039
+ ...input.brokerCredential ? { brokerCredential: input.brokerCredential } : {}
53720
54040
  };
53721
54041
  const next = this.config.tokens.filter((t) => t.userId !== input.userId);
53722
54042
  next.push(entry);
@@ -53762,26 +54082,79 @@ var ExportAlexaAddon = class extends BaseAddon {
53762
54082
  }
53763
54083
  /**
53764
54084
  * Settings as the runtime should read them. In `skillMode: 'public'` the
53765
- * skill id + LWA credentials are swapped for the built-in CamStack values;
53766
- * the operator's own `expectedAudience`/`lwaClient*` stay untouched in the
53767
- * persisted config so switching back to `custom` restores them. Everything
53768
- * else (hub URL, ingress token, region, exposed list, …) passes through.
54085
+ * skill id is swapped for the built-in CamStack skill's; the operator's own
54086
+ * `expectedAudience` stays untouched in the persisted config so switching
54087
+ * back to `custom` restores it. Everything else passes through. There is no
54088
+ * credential to overlay any more (D753).
53769
54089
  */
53770
54090
  effectiveSettings() {
53771
54091
  if (this.config.skillMode !== "public") return this.config;
53772
54092
  return {
53773
54093
  ...this.config,
53774
- expectedAudience: PUBLIC_SKILL_ID,
53775
- lwaClientId: PUBLIC_LWA_CLIENT_ID,
53776
- lwaClientSecret: PUBLIC_LWA_CLIENT_SECRET
54094
+ expectedAudience: PUBLIC_SKILL_ID
53777
54095
  };
53778
54096
  }
53779
- lwaCredentials() {
53780
- const s = this.effectiveSettings();
53781
- if (!s.lwaClientId || !s.lwaClientSecret) return null;
54097
+ /** Persist the broker origin the Lambda advertised on a verified directive. */
54098
+ rememberAdvertisedBroker(raw) {
54099
+ const url = normaliseBrokerUrl(raw);
54100
+ if (!url) {
54101
+ this.ctx.logger.warn("export-alexa: ignored a broker advertisement that is not an https origin");
54102
+ return;
54103
+ }
54104
+ if (url === this.config.advertisedBrokerUrl) return;
54105
+ this.updateGlobalSettings({ advertisedBrokerUrl: url }).then(() => {
54106
+ this.ctx.logger.info("export-alexa: the skill Lambda advertised its token broker", { meta: { brokerUrl: url } });
54107
+ }).catch((err) => {
54108
+ this.ctx.logger.warn("export-alexa: could not store the advertised broker URL", { meta: { error: errMsg(err) } });
54109
+ });
54110
+ }
54111
+ async revokeAtBroker(tokens) {
54112
+ for (const t of tokens) {
54113
+ if (!isBrokerGrant(t)) continue;
54114
+ await revokeBrokerCredential(fetch, t.brokerUrl, t.brokerCredential).catch((err) => {
54115
+ this.ctx.logger.warn("export-alexa: could not delete the link at the skill broker — it expires there on its own", { meta: {
54116
+ userId: t.userId,
54117
+ error: errMsg(err)
54118
+ } });
54119
+ });
54120
+ }
54121
+ }
54122
+ /**
54123
+ * Swap every broker credential this hub holds for a fresh one. The old
54124
+ * credential stops working at the broker immediately. A credential the
54125
+ * broker no longer recognises is reported, not retried — re-link instead.
54126
+ */
54127
+ async rotateBrokerCredentials() {
54128
+ let rotated = 0;
54129
+ let failed = 0;
54130
+ for (const t of this.config.tokens) {
54131
+ if (!isBrokerGrant(t)) continue;
54132
+ try {
54133
+ const next = await rotateBrokerCredential(fetch, t.brokerUrl, t.brokerCredential);
54134
+ await this.upsertToken({
54135
+ userId: t.userId,
54136
+ accessToken: t.accessToken,
54137
+ expiresAt: t.expiresAt,
54138
+ brokerUrl: t.brokerUrl,
54139
+ brokerCredential: next
54140
+ });
54141
+ rotated += 1;
54142
+ } catch (err) {
54143
+ failed += 1;
54144
+ this.ctx.logger.warn("export-alexa: broker credential rotation failed", { meta: {
54145
+ userId: t.userId,
54146
+ kind: err instanceof BrokerError ? err.kind : "unavailable",
54147
+ error: errMsg(err)
54148
+ } });
54149
+ }
54150
+ }
54151
+ this.ctx.logger.info("export-alexa: broker credentials rotated", { meta: {
54152
+ rotated,
54153
+ failed
54154
+ } });
53782
54155
  return {
53783
- clientId: s.lwaClientId,
53784
- clientSecret: s.lwaClientSecret
54156
+ rotated,
54157
+ failed
53785
54158
  };
53786
54159
  }
53787
54160
  /**
@@ -53936,8 +54309,9 @@ var ExportAlexaAddon = class extends BaseAddon {
53936
54309
  label: "Setup overview",
53937
54310
  variant: "info",
53938
54311
  content: [
53939
- "Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account — the skill id and Send-Alexa-Events credentials are built in, so no Amazon developer setup is needed. A single published skill + hosted Lambda route to this hub using its public HTTPS URL (baked into every JWT).",
53940
- "Custom skill: switch \"Alexa skill\" below to Custom and supply your own skill id + LWA credentials (from your skill's Permissions → \"Send Alexa Events\" panel).",
54312
+ "Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account — no Amazon developer setup is needed. A single published skill + hosted Lambda route to this hub using its public HTTPS URL (baked into every JWT).",
54313
+ "The skill's Lambda keeps the Amazon credentials: this hub never holds an LWA client secret. It receives short-lived event tokens from the Lambda's token broker with a credential that is unique to this link.",
54314
+ "Custom skill: switch \"Alexa skill\" below to Custom, enter your skill id, and deploy the skill Lambda from this addon's lambda-template in your own AWS account (your LWA credentials go into AWS Secrets Manager there, not here).",
53941
54315
  "Either way this hub must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, …). The \"External address\" picker selects which detected endpoint the Lambda calls back on — press \"Refresh external addresses\" after setting up a new tunnel."
53942
54316
  ].join("\n")
53943
54317
  },
@@ -53962,7 +54336,7 @@ var ExportAlexaAddon = class extends BaseAddon {
53962
54336
  type: "select",
53963
54337
  key: "skillMode",
53964
54338
  label: "Alexa skill",
53965
- description: "Public: use the built-in CamStack skill — zero Amazon developer setup. Custom: bring your own skill and LWA credentials.",
54339
+ description: "Public: use the built-in CamStack skill — zero Amazon developer setup. Custom: bring your own skill and your own copy of the skill Lambda.",
53966
54340
  options: [{
53967
54341
  value: "public",
53968
54342
  label: "Public (built-in CamStack skill)"
@@ -53993,35 +54367,24 @@ var ExportAlexaAddon = class extends BaseAddon {
53993
54367
  ],
53994
54368
  default: DEFAULT_CONFIG.region
53995
54369
  }),
53996
- ...this.config.skillMode === "custom" ? [
53997
- this.field({
53998
- type: "text",
53999
- key: "expectedAudience",
54000
- label: "Alexa skill id",
54001
- description: "Your skill's id, format amzn1.ask.skill.xxxx. Found on the Alexa developer console; used to gate directives the next dispatch will start signing.",
54002
- default: DEFAULT_CONFIG.expectedAudience
54003
- }),
54004
- this.field({
54005
- type: "text",
54006
- key: "lwaClientId",
54007
- label: "LWA client id (proactive events)",
54008
- description: "From the skill's Permissions → \"Send Alexa Events\" panel. Required for everything the hub pushes to Alexa: doorbell presses, motion, battery level and device-list updates.",
54009
- default: DEFAULT_CONFIG.lwaClientId
54010
- }),
54011
- this.field({
54012
- type: "password",
54013
- key: "lwaClientSecret",
54014
- label: "LWA client secret (proactive events)",
54015
- description: "Paired with LWA client id. Treat as sensitive.",
54016
- showToggle: true,
54017
- default: DEFAULT_CONFIG.lwaClientSecret
54018
- })
54019
- ] : [{
54370
+ ...this.config.skillMode === "custom" ? [this.field({
54371
+ type: "text",
54372
+ key: "expectedAudience",
54373
+ label: "Alexa skill id",
54374
+ description: "Your skill's id, format amzn1.ask.skill.xxxx. Found on the Alexa developer console; used to gate directives the next dispatch will start signing.",
54375
+ default: DEFAULT_CONFIG.expectedAudience
54376
+ }), {
54377
+ type: "info",
54378
+ key: "__custom-skill-info",
54379
+ label: "Your skill Lambda",
54380
+ variant: "info",
54381
+ content: "Proactive events (doorbell, motion, battery, device-list updates) are authorised by YOUR skill Lambda: it keeps the \"Send Alexa Events\" LWA client id/secret in AWS Secrets Manager and hands this hub short-lived tokens. Nothing to paste here — see the custom-skill setup guide."
54382
+ }] : [{
54020
54383
  type: "info",
54021
54384
  key: "__public-skill-info",
54022
54385
  label: "Built-in skill",
54023
54386
  variant: "info",
54024
- content: "Using the built-in CamStack public skill — skill id and Send-Alexa-Events credentials are managed automatically. Pick your region above, then just enable \"CamStack\" in the Alexa app and link your account. Switch to \"Custom skill\" above to use your own."
54387
+ content: "Using the built-in CamStack public skill — its Amazon credentials stay in the skill's Lambda, never on this hub. Pick your region above, then just enable \"CamStack\" in the Alexa app and link your account. Switch to \"Custom skill\" above to use your own."
54025
54388
  }],
54026
54389
  {
54027
54390
  type: "info",
@@ -54031,7 +54394,6 @@ var ExportAlexaAddon = class extends BaseAddon {
54031
54394
  content: buildLinkStatusContent({
54032
54395
  linkedAccounts: this.config.linkedAccounts,
54033
54396
  tokens: this.config.tokens,
54034
- hasLwaCredentials: this.lwaCredentials() !== null,
54035
54397
  now: Date.now()
54036
54398
  })
54037
54399
  },
@@ -54045,6 +54407,15 @@ var ExportAlexaAddon = class extends BaseAddon {
54045
54407
  variant: "danger",
54046
54408
  confirmMessage: "Unlink the Amazon account? Alexa loses access to your cameras immediately and you must re-link from the Alexa app to restore it."
54047
54409
  },
54410
+ {
54411
+ type: "button",
54412
+ key: "__rotate-broker-credential",
54413
+ label: "Skill broker credential",
54414
+ description: "Replace the credential this hub uses at the skill's token broker with a fresh one. The old one stops working immediately. Use it if a backup of this hub's settings may have leaked. Re-linking from the Alexa app also issues a fresh one.",
54415
+ buttonLabel: "Rotate credential",
54416
+ action: "rotateBrokerCredential",
54417
+ variant: "default"
54418
+ },
54048
54419
  {
54049
54420
  type: "button",
54050
54421
  key: "__resync-discovery",