@camstack/addon-export-alexa 1.2.131 → 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.
@@ -5406,7 +5406,7 @@ var ZodIssueCode = {
5406
5406
  var ZodFirstPartyTypeKind;
5407
5407
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5408
5408
  //#endregion
5409
- //#region ../types/dist/sleep-C16NCZR2.mjs
5409
+ //#region ../types/dist/sleep-DAJ9Er57.mjs
5410
5410
  /**
5411
5411
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5412
5412
  * window to float samples (D455).
@@ -24023,148 +24023,17 @@ method(_void(), array(PipelineEngineChoiceSchema)), method(_void(), PipelineEngi
24023
24023
  settings: record(string(), unknown()).optional()
24024
24024
  }), AudioTestResultSchema, { kind: "mutation" }), method(_void(), ConfigUISchemaNullableBridge);
24025
24025
  /**
24026
- * Wire shapes of the Load balancing surface (D748): the session placement
24027
- * policy, the atomic share write, the observed placement statistics, the
24028
- * hardware share suggestion and the stale device pins.
24029
- */
24030
- /**
24031
- * How a new detection session picks its node and device.
24032
- * - `least-loaded` — the historical rule: lowest `attached / weight`, a
24033
- * constant node order at a tie (D37). An idle node always ties at 0, so a
24034
- * weight only bites once a node already holds a session.
24035
- * - `share` — the share of PARALLEL sessions is held to the weights, and a
24036
- * full or drowning node yields to a free one (D748).
24037
- */
24038
- var SessionPlacementPolicySchema = _enum(["least-loaded", "share"]);
24039
- var LoadBalancingSharesInputSchema = object({
24040
- /** nodeId → share (> 0). A node left out keeps its weight. */
24041
- nodes: record(string(), number().positive()).default({}),
24042
- /** nodeId → deviceKey → share (> 0). A device left out keeps its weight. */
24043
- devices: record(string(), record(string(), number().positive())).default({})
24044
- });
24045
- var PlacementStatsInputSchema = object({
24046
- /** Window the observed shares are averaged over (default 1 h, max 24 h). */
24047
- windowMs: number().int().min(6e4).max(1440 * 60 * 1e3).default(3600 * 1e3) });
24048
- var PlacementDeviceStatsSchema = object({
24049
- deviceKey: string(),
24050
- /** Sessions running on this device now. */
24051
- sessionsNow: number(),
24052
- /** Sessions started on this device inside the window. */
24053
- sessionsStarted: number(),
24054
- /** Time-weighted share of the node's parallel sessions over the window (0–1); null = no session ran. */
24055
- observedShare: number().nullable()
24056
- });
24057
- var PlacementNodeStatsSchema = object({
24058
- nodeId: string(),
24059
- sessionsNow: number(),
24060
- sessionsStarted: number(),
24061
- /** Time-weighted share of the cluster's parallel sessions over the window (0–1); null = none ran. */
24062
- observedShare: number().nullable(),
24063
- /** Most sessions this node ran at once inside the window. */
24064
- peakSessions: number(),
24065
- /** Sessions a share election handed elsewhere because this node was full or drowning. */
24066
- yieldsAway: number(),
24067
- devices: array(PlacementDeviceStatsSchema).readonly()
24068
- });
24069
- /** A camera's device pin that the session path cannot honour. */
24070
- var StaleDevicePinSchema = object({
24071
- deviceId: number(),
24072
- deviceKey: string(),
24073
- /** `disabled` — the key exists on some node but is not enabled anywhere; `absent` — no node has it. */
24074
- reason: _enum(["disabled", "absent"])
24075
- });
24076
- var PlacementStatsSchema = object({
24077
- policy: SessionPlacementPolicySchema,
24078
- windowMs: number(),
24079
- /** How much of the window was actually observed (the hub keeps it in memory). */
24080
- observedForMs: number(),
24081
- sessionsNow: number(),
24082
- /** Most parallel sessions across the cluster inside the window. */
24083
- clusterPeakSessions: number(),
24084
- nodes: array(PlacementNodeStatsSchema).readonly(),
24085
- stalePins: array(StaleDevicePinSchema).readonly()
24086
- });
24087
- var SuggestSharesInputSchema = object({ headroom: number().min(.3).max(.5).default(.4) });
24088
- var SuggestedDeviceShareSchema = object({
24089
- deviceKey: string(),
24090
- /** Share of the node (0–100, rounded). */
24091
- share: number(),
24092
- modelId: string().nullable(),
24093
- /** Inferences per second at that model, from the measured table. */
24094
- fps: number().nullable(),
24095
- measured: boolean(),
24096
- /** Advisory session cap: ⌊fps × (1 − headroom) / camera fps⌋. Never enforced. */
24097
- suggestedMaxSessions: number().nullable()
24098
- });
24099
- var SuggestedNodeShareSchema = object({
24100
- nodeId: string(),
24101
- /** Share of the cluster (0–100, rounded); null = node skipped (offline, unknown hardware). */
24102
- share: number().nullable(),
24103
- /** Parallel sessions the node sustains at the suggested models with the headroom kept free. */
24104
- capacitySessions: number(),
24105
- devices: array(SuggestedDeviceShareSchema).readonly(),
24106
- skippedReason: string().nullable()
24107
- });
24108
- var SuggestSharesResultSchema = object({
24109
- headroom: number(),
24110
- /** Camera detection rate the capacity is counted in (the fastest camera's fps). */
24111
- cameraFps: number(),
24112
- /** How many share → model → capacity rounds ran before the shares stopped moving (≤ 3). */
24113
- iterations: number(),
24114
- nodes: array(SuggestedNodeShareSchema).readonly()
24115
- });
24116
- /**
24117
- * Where the node and device shares come from (D750).
24118
- * - `auto` — computed by the system from the cluster's measured capacity at
24119
- * the Auto tune models, and recomputed whenever a node or device joins,
24120
- * leaves, is switched on or off, or changes model.
24121
- * - `manual` — the operator's stored sliders. A node or device that cannot
24122
- * take sessions gives its share to the others in proportion; the stored
24123
- * values are never rewritten for that.
24124
- */
24125
- var ShareSourceSchema = _enum(["auto", "manual"]);
24126
- var SetShareSourceInputSchema = object({ source: ShareSourceSchema });
24127
- object({
24128
- nodes: record(string(), number()),
24129
- devices: record(string(), record(string(), number()))
24130
- });
24131
- var EffectiveDeviceShareSchema = object({
24132
- deviceKey: string(),
24133
- /** Can take sessions now (enabled, available, can run the root). */
24134
- eligible: boolean(),
24135
- /** The share the source assigns, as a percentage of the node's devices. */
24136
- configuredPct: number(),
24137
- /** The share actually used, after an ineligible device's share went to the others. */
24138
- effectivePct: number()
24139
- });
24140
- var EffectiveNodeShareSchema = object({
24141
- nodeId: string(),
24142
- eligible: boolean(),
24143
- configuredPct: number(),
24144
- effectivePct: number(),
24145
- devices: array(EffectiveDeviceShareSchema).readonly()
24146
- });
24147
- var EffectiveSharesSchema = object({
24148
- source: ShareSourceSchema,
24149
- policy: SessionPlacementPolicySchema,
24150
- /** `auto` with no computed answer yet: the stored sliders are used meanwhile. */
24151
- autoPending: boolean(),
24152
- /** When the automatic shares were last computed (ms epoch); null = never. */
24153
- computedAt: number().nullable(),
24154
- /** What made the system recompute them last. */
24155
- trigger: string().nullable(),
24156
- nodes: array(EffectiveNodeShareSchema).readonly(),
24157
- /** The relative weights the election reads (before eligibility). */
24158
- nodeWeights: record(string(), number()),
24159
- deviceWeights: record(string(), record(string(), number()))
24160
- });
24161
- /**
24162
24026
  * The wire shapes of the Auto tune plan and apply (D745), used by the
24163
24027
  * `pipeline-orchestrator` cap. The recommender's own types
24164
- * (`recommend.ts`, `demand.ts`) are the TS face of the same data; these
24028
+ * (`recommend.ts`) are the TS face of the same data; these
24165
24029
  * schemas are what crosses the cap boundary.
24166
24030
  */
24167
24031
  var ModelFormatSchema$1 = _enum(MODEL_FORMATS);
24032
+ /** The operator's range for it. */
24033
+ var MIN_SESSIONS_PER_DEVICE_RANGE = {
24034
+ min: 1,
24035
+ max: 8
24036
+ };
24168
24037
  var HardwareClassIdSchema = _enum([
24169
24038
  "intel-core-ultra",
24170
24039
  "intel-n-series",
@@ -24182,8 +24051,9 @@ var AcceleratorKindSchema = _enum([
24182
24051
  ]);
24183
24052
  var RecommendationReasonSchema = discriminatedUnion("kind", [
24184
24053
  object({
24185
- kind: literal("fits"),
24186
- utilisation: number()
24054
+ kind: literal("capacity"),
24055
+ sessions: number(),
24056
+ minSessions: number()
24187
24057
  }),
24188
24058
  object({
24189
24059
  kind: literal("tie-faster"),
@@ -24198,16 +24068,13 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
24198
24068
  }),
24199
24069
  object({
24200
24070
  kind: literal("no-fit-lightest"),
24201
- utilisation: number()
24071
+ sessions: number(),
24072
+ minSessions: number()
24202
24073
  }),
24203
24074
  object({
24204
24075
  kind: literal("derived-latency"),
24205
24076
  modelId: string()
24206
24077
  }),
24207
- object({
24208
- kind: literal("estimated-load"),
24209
- observedForMs: number().nullable()
24210
- }),
24211
24078
  object({ kind: literal("cpu-device") }),
24212
24079
  object({ kind: literal("coral-untouched") }),
24213
24080
  object({ kind: literal("device-disabled") }),
@@ -24223,28 +24090,13 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
24223
24090
  format: ModelFormatSchema$1
24224
24091
  })
24225
24092
  ]);
24226
- var DeviceDemandSchema = object({
24227
- nodeId: string(),
24228
- deviceKey: string(),
24229
- demandFps: number(),
24230
- maxCameraFps: number(),
24231
- alwaysOnCameras: number(),
24232
- onMotionSessions: number(),
24233
- onMotionSource: _enum([
24234
- "observed-24h",
24235
- "estimate",
24236
- "none"
24237
- ]),
24238
- observedForMs: number().nullable(),
24239
- /** How on-motion sessions were charged: per-device observation, or split by share (D748). */
24240
- placement: SessionPlacementPolicySchema
24241
- });
24242
24093
  var DeviceModelChoiceSchema = object({
24243
24094
  modelId: string(),
24244
24095
  p50Ms: number(),
24245
24096
  fps: number(),
24246
24097
  measured: boolean(),
24247
- utilisation: number(),
24098
+ /** Parallel sessions at the camera rate with the headroom kept free: ⌊fps × (1 − headroom) / camera fps⌋. */
24099
+ capacitySessions: number(),
24248
24100
  personRecall: number()
24249
24101
  });
24250
24102
  var DeviceRecommendationSchema = object({
@@ -24252,7 +24104,6 @@ var DeviceRecommendationSchema = object({
24252
24104
  accelerator: AcceleratorKindSchema.nullable(),
24253
24105
  enabled: boolean(),
24254
24106
  available: boolean(),
24255
- demand: DeviceDemandSchema.nullable(),
24256
24107
  chosen: DeviceModelChoiceSchema.nullable(),
24257
24108
  reasons: array(RecommendationReasonSchema).readonly()
24258
24109
  });
@@ -24313,20 +24164,20 @@ var AutoTuneOptionsSchema = object({
24313
24164
  /** Share of each accelerator's throughput kept free (0.3–0.5). */
24314
24165
  headroom: number().min(.3).max(.5).default(.4),
24315
24166
  /**
24316
- * What-if shares (D748): plan as if these node/device shares were stored
24317
- * and the policy were `share`. Absent = the stored shares under the stored
24318
- * policy. A what-if plan is for display; apply always re-plans from what
24319
- * is stored.
24167
+ * The least number of parallel sessions every device must sustain at the
24168
+ * camera rate with the headroom kept free (D755). The most accurate model
24169
+ * that reaches it wins.
24320
24170
  */
24321
- shares: LoadBalancingSharesInputSchema.optional()
24171
+ minSessionsPerDevice: number().int().min(MIN_SESSIONS_PER_DEVICE_RANGE.min).max(MIN_SESSIONS_PER_DEVICE_RANGE.max).default(2)
24322
24172
  });
24323
24173
  var AutoTunePlanSchema = object({
24324
24174
  /** Hash of everything the apply will write — the apply refuses a different one. */
24325
24175
  planHash: string(),
24326
24176
  family: literal("yolov9"),
24327
24177
  headroom: number(),
24328
- /** Planned under what-if shares (D748): for display — apply refuses it as stale. */
24329
- whatIf: boolean(),
24178
+ minSessionsPerDevice: number(),
24179
+ /** The detection rate a session is counted at (the fastest camera running the pipeline root). */
24180
+ cameraFps: number(),
24330
24181
  generatedAt: number(),
24331
24182
  nodes: array(NodeRecommendationSchema).readonly(),
24332
24183
  nodeChanges: array(NodeDeviceChangeSchema).readonly(),
@@ -24336,7 +24187,8 @@ var AutoTunePlanSchema = object({
24336
24187
  });
24337
24188
  var AutoTuneApplyInputSchema = object({
24338
24189
  planHash: string(),
24339
- headroom: number().min(.3).max(.5).default(.4)
24190
+ headroom: number().min(.3).max(.5).default(.4),
24191
+ minSessionsPerDevice: number().int().min(MIN_SESSIONS_PER_DEVICE_RANGE.min).max(MIN_SESSIONS_PER_DEVICE_RANGE.max).default(2)
24340
24192
  });
24341
24193
  var AutoTunePhaseResultSchema = object({
24342
24194
  phase: _enum([
@@ -24368,6 +24220,177 @@ var AutoTuneApplyResultSchema = discriminatedUnion("kind", [
24368
24220
  reason: _enum(["apply-in-progress"])
24369
24221
  })
24370
24222
  ]);
24223
+ /**
24224
+ * Wire shapes of the Load balancing surface (D748): the session placement
24225
+ * policy, the atomic share write, the observed placement statistics, the
24226
+ * hardware share suggestion and the stale device pins.
24227
+ */
24228
+ /**
24229
+ * How a new detection session picks its node and device.
24230
+ * - `least-loaded` — the historical rule: lowest `attached / weight`, a
24231
+ * constant node order at a tie (D37). An idle node always ties at 0, so a
24232
+ * weight only bites once a node already holds a session.
24233
+ * - `share` — the share of PARALLEL sessions is held to the weights, and a
24234
+ * full or drowning node yields to a free one (D748).
24235
+ */
24236
+ var SessionPlacementPolicySchema = _enum(["least-loaded", "share"]);
24237
+ var LoadBalancingSharesInputSchema = object({
24238
+ /** nodeId → share (> 0). A node left out keeps its weight. */
24239
+ nodes: record(string(), number().positive()).default({}),
24240
+ /** nodeId → deviceKey → share (> 0). A device left out keeps its weight. */
24241
+ devices: record(string(), record(string(), number().positive())).default({})
24242
+ });
24243
+ var PlacementStatsInputSchema = object({
24244
+ /** Window the observed shares are averaged over (default 1 h, max 24 h). */
24245
+ windowMs: number().int().min(6e4).max(1440 * 60 * 1e3).default(3600 * 1e3) });
24246
+ var PlacementDeviceStatsSchema = object({
24247
+ deviceKey: string(),
24248
+ /** Sessions running on this device now. */
24249
+ sessionsNow: number(),
24250
+ /** Sessions started on this device inside the window. */
24251
+ sessionsStarted: number(),
24252
+ /** Time-weighted share of the node's parallel sessions over the window (0–1); null = no session ran. */
24253
+ observedShare: number().nullable()
24254
+ });
24255
+ var PlacementNodeStatsSchema = object({
24256
+ nodeId: string(),
24257
+ sessionsNow: number(),
24258
+ sessionsStarted: number(),
24259
+ /** Time-weighted share of the cluster's parallel sessions over the window (0–1); null = none ran. */
24260
+ observedShare: number().nullable(),
24261
+ /** Most sessions this node ran at once inside the window. */
24262
+ peakSessions: number(),
24263
+ /** Sessions a share election handed elsewhere because this node was full or drowning. */
24264
+ yieldsAway: number(),
24265
+ devices: array(PlacementDeviceStatsSchema).readonly()
24266
+ });
24267
+ /** A camera's device pin that the session path cannot honour. */
24268
+ var StaleDevicePinSchema = object({
24269
+ deviceId: number(),
24270
+ deviceKey: string(),
24271
+ /** `disabled` — the key exists on some node but is not enabled anywhere; `absent` — no node has it. */
24272
+ reason: _enum(["disabled", "absent"])
24273
+ });
24274
+ var PlacementStatsSchema = object({
24275
+ policy: SessionPlacementPolicySchema,
24276
+ windowMs: number(),
24277
+ /** How much of the window was actually observed (the hub keeps it in memory). */
24278
+ observedForMs: number(),
24279
+ sessionsNow: number(),
24280
+ /** Most parallel sessions across the cluster inside the window. */
24281
+ clusterPeakSessions: number(),
24282
+ nodes: array(PlacementNodeStatsSchema).readonly(),
24283
+ stalePins: array(StaleDevicePinSchema).readonly()
24284
+ });
24285
+ /**
24286
+ * Where an automatic share's capacity comes from (D755).
24287
+ * - `hardware` — the static capacity of each device at its Auto tune model:
24288
+ * the measured latency table for the node's hardware class.
24289
+ * - `benchmark` — the node's own measured capacity (node benchmark results).
24290
+ * A node with no results falls back to `hardware`, and says so.
24291
+ */
24292
+ var CapacitySourceSchema = _enum(["hardware", "benchmark"]);
24293
+ /** Why a node under the `benchmark` source was sized from hardware instead. */
24294
+ var CapacityFallbackSchema = _enum(["no-benchmark-results", "partial-benchmark-results"]);
24295
+ var SuggestSharesInputSchema = object({
24296
+ headroom: number().min(.3).max(.5).default(.4),
24297
+ /** Default `hardware`. */
24298
+ source: CapacitySourceSchema.default("hardware")
24299
+ });
24300
+ var SuggestedDeviceShareSchema = object({
24301
+ deviceKey: string(),
24302
+ /** Share of the node (0–100, rounded). */
24303
+ share: number(),
24304
+ modelId: string().nullable(),
24305
+ /** Inferences per second at that model, from the measured table; null when sized by a benchmark. */
24306
+ fps: number().nullable(),
24307
+ /** False for a table cell derived from another chip; true for a measured cell or a benchmark. */
24308
+ measured: boolean(),
24309
+ /**
24310
+ * Advisory session cap: ⌊fps × (1 − headroom) / camera fps⌋ from the table,
24311
+ * or the benchmark's measured max sessions. Never enforced.
24312
+ */
24313
+ suggestedMaxSessions: number().nullable()
24314
+ });
24315
+ var SuggestedNodeShareSchema = object({
24316
+ nodeId: string(),
24317
+ /** Share of the cluster (0–100, rounded); null = node skipped (offline, unknown hardware). */
24318
+ share: number().nullable(),
24319
+ /** Parallel sessions the node sustains at the suggested models with the headroom kept free. */
24320
+ capacitySessions: number(),
24321
+ devices: array(SuggestedDeviceShareSchema).readonly(),
24322
+ skippedReason: string().nullable(),
24323
+ /** What this node's capacity was read from; null when the node is skipped. */
24324
+ capacitySource: CapacitySourceSchema.nullable(),
24325
+ /** Set when the `benchmark` source was asked for and this node used `hardware`. */
24326
+ capacityFallback: CapacityFallbackSchema.nullable()
24327
+ });
24328
+ var SuggestSharesResultSchema = object({
24329
+ headroom: number(),
24330
+ /** The source asked for; each node says what it actually used. */
24331
+ source: CapacitySourceSchema,
24332
+ /** Camera detection rate the capacity is counted in (the fastest camera's fps). */
24333
+ cameraFps: number(),
24334
+ /** The Auto tune capacity floor the models were picked at. */
24335
+ minSessionsPerDevice: number(),
24336
+ nodes: array(SuggestedNodeShareSchema).readonly()
24337
+ });
24338
+ /**
24339
+ * Where the node and device shares come from (D750, D755).
24340
+ * - `hardware` (default) — computed by the system from each node's static
24341
+ * capacity (its accelerators at their Auto tune models, from the measured
24342
+ * latency table), recomputed whenever a node or device joins, leaves, is
24343
+ * switched on or off, or changes model. No load input.
24344
+ * - `benchmark` — the same, from each node's measured capacity (node
24345
+ * benchmark results); a node without results falls back to `hardware`.
24346
+ * - `manual` — the operator's stored sliders.
24347
+ * Either way a node or device that cannot take sessions gives its share to
24348
+ * the others in proportion; the stored values are never rewritten for that.
24349
+ */
24350
+ var ShareSourceSchema = _enum([
24351
+ "hardware",
24352
+ "benchmark",
24353
+ "manual"
24354
+ ]);
24355
+ var SetShareSourceInputSchema = object({ source: ShareSourceSchema });
24356
+ object({
24357
+ nodes: record(string(), number()),
24358
+ devices: record(string(), record(string(), number()))
24359
+ });
24360
+ var EffectiveDeviceShareSchema = object({
24361
+ deviceKey: string(),
24362
+ /** Can take sessions now (enabled, available, can run the root). */
24363
+ eligible: boolean(),
24364
+ /** The share the source assigns, as a percentage of the node's devices. */
24365
+ configuredPct: number(),
24366
+ /** The share actually used, after an ineligible device's share went to the others. */
24367
+ effectivePct: number()
24368
+ });
24369
+ var EffectiveNodeShareSchema = object({
24370
+ nodeId: string(),
24371
+ eligible: boolean(),
24372
+ configuredPct: number(),
24373
+ effectivePct: number(),
24374
+ /** Automatic sources: what this node's share was computed from; null under `manual` or before the first answer. */
24375
+ capacitySource: CapacitySourceSchema.nullable(),
24376
+ /** Automatic `benchmark`: why this node used `hardware` instead. */
24377
+ capacityFallback: CapacityFallbackSchema.nullable(),
24378
+ devices: array(EffectiveDeviceShareSchema).readonly()
24379
+ });
24380
+ var EffectiveSharesSchema = object({
24381
+ source: ShareSourceSchema,
24382
+ policy: SessionPlacementPolicySchema,
24383
+ /** An automatic source with no computed answer yet: the stored sliders are used meanwhile. */
24384
+ autoPending: boolean(),
24385
+ /** When the automatic shares were last computed (ms epoch); null = never. */
24386
+ computedAt: number().nullable(),
24387
+ /** What made the system recompute them last. */
24388
+ trigger: string().nullable(),
24389
+ nodes: array(EffectiveNodeShareSchema).readonly(),
24390
+ /** The relative weights the election reads (before eligibility). */
24391
+ nodeWeights: record(string(), number()),
24392
+ deviceWeights: record(string(), record(string(), number()))
24393
+ });
24371
24394
  object({
24372
24395
  activeCameras: number(),
24373
24396
  throttledCameras: number(),
@@ -25547,8 +25570,10 @@ var CameraStepOverridePatchSchema = object({
25547
25570
  modelId: string().optional(),
25548
25571
  settings: record(string(), unknown()).readonly().optional()
25549
25572
  });
25573
+ var CameraStepSettingsSchema = record(string(), record(string(), unknown()).readonly());
25550
25574
  var CameraPipelineSettingsSchema = object({
25551
25575
  stepToggles: record(string(), boolean()).optional(),
25576
+ stepSettings: CameraStepSettingsSchema.optional(),
25552
25577
  stepOverridesByDevice: record(string(), record(string(), record(string(), CameraStepOverridePatchSchema))).optional(),
25553
25578
  pipelineByAgent: record(string(), CameraPipelineForAgentSchema).optional()
25554
25579
  });
@@ -26112,6 +26137,13 @@ method(object({
26112
26137
  }), object({ success: literal(true) }), {
26113
26138
  kind: "mutation",
26114
26139
  auth: "admin"
26140
+ }), method(object({
26141
+ deviceId: number(),
26142
+ addonId: string(),
26143
+ settings: record(string(), unknown()).nullable()
26144
+ }), object({ success: literal(true) }), {
26145
+ kind: "mutation",
26146
+ auth: "admin"
26115
26147
  }), method(object({
26116
26148
  deviceId: number(),
26117
26149
  agentNodeId: string(),
@@ -42187,6 +42219,12 @@ Object.freeze({
42187
42219
  addonId: null,
42188
42220
  access: "create"
42189
42221
  },
42222
+ "pipelineOrchestrator.setCameraStepSettings": {
42223
+ capName: "pipeline-orchestrator",
42224
+ capScope: "system",
42225
+ addonId: null,
42226
+ access: "create"
42227
+ },
42190
42228
  "pipelineOrchestrator.setCameraStepToggle": {
42191
42229
  capName: "pipeline-orchestrator",
42192
42230
  capScope: "system",
@@ -47635,6 +47673,11 @@ Object.freeze({
47635
47673
  form: "single",
47636
47674
  optional: false
47637
47675
  }],
47676
+ "pipelineOrchestrator.setCameraStepSettings": [{
47677
+ name: "deviceId",
47678
+ form: "single",
47679
+ optional: false
47680
+ }],
47638
47681
  "pipelineOrchestrator.setCameraStepToggle": [{
47639
47682
  name: "deviceId",
47640
47683
  form: "single",
@@ -49595,6 +49638,15 @@ var exportAlexaActions = defineCustomActions({
49595
49638
  unlinkAlexaAccounts: customAction(object({}).optional(), object({
49596
49639
  revokedSessions: number(),
49597
49640
  removedAccounts: number()
49641
+ }), { kind: "mutation" }),
49642
+ /**
49643
+ * Swap the per-link credential this hub presents to the skill Lambda's
49644
+ * token broker (D753) for a fresh one; the old one dies at once. Rotation
49645
+ * also happens implicitly on every re-link.
49646
+ */
49647
+ rotateBrokerCredential: customAction(object({}).optional(), object({
49648
+ rotated: number(),
49649
+ failed: number()
49598
49650
  }), { kind: "mutation" })
49599
49651
  });
49600
49652
  //#endregion
@@ -50186,6 +50238,110 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
50186
50238
  } };
50187
50239
  }
50188
50240
  //#endregion
50241
+ //#region src/broker-client.ts
50242
+ /**
50243
+ * The hub's side of the skill's token broker (D753).
50244
+ *
50245
+ * The hub holds NO Login With Amazon client secret. To push a proactive event
50246
+ * it needs an LWA access token for the linked Amazon account, and it gets one
50247
+ * from the skill's Lambda (the broker) by presenting the per-link credential
50248
+ * the Lambda minted at link time (`csb1.<linkId>.<secret>`). The Lambda keeps
50249
+ * the refresh token and the client secret; the hub keeps a credential that
50250
+ * opens exactly one link and can be revoked or rotated.
50251
+ *
50252
+ * Every function here is a single HTTPS call with a timeout and a Zod-parsed
50253
+ * answer. Errors carry a `kind` so the gateway can tell "re-link needed" from
50254
+ * "try again later"; their messages never contain a token or a credential.
50255
+ */
50256
+ var BROKER_TIMEOUT_MS = 8e3;
50257
+ /** Origin-only, HTTPS-only. Anything else is refused before a credential is sent. */
50258
+ function normaliseBrokerUrl(raw) {
50259
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 512) return null;
50260
+ try {
50261
+ const u = new URL(raw);
50262
+ if (u.protocol !== "https:" || u.username || u.password) return null;
50263
+ return u.origin;
50264
+ } catch {
50265
+ return null;
50266
+ }
50267
+ }
50268
+ var BrokerError = class extends Error {
50269
+ kind;
50270
+ status;
50271
+ constructor(kind, message, status) {
50272
+ super(message);
50273
+ this.kind = kind;
50274
+ this.status = status;
50275
+ this.name = "BrokerError";
50276
+ }
50277
+ };
50278
+ var AccessTokenSchema = object({
50279
+ accessToken: string().min(1),
50280
+ expiresAt: number().int().positive()
50281
+ });
50282
+ var MigrationSchema = AccessTokenSchema.extend({
50283
+ brokerCredential: string().min(1),
50284
+ brokerUrl: string().min(1)
50285
+ });
50286
+ var RotationSchema = object({ brokerCredential: string().min(1) });
50287
+ function kindForStatus(status) {
50288
+ if (status === 401) return "unauthorized";
50289
+ if (status === 410) return "revoked";
50290
+ if (status === 400 || status === 403 || status === 404 || status === 405) return "rejected";
50291
+ return "unavailable";
50292
+ }
50293
+ async function call(fetchImpl, brokerUrl, path, init, schema) {
50294
+ const origin = normaliseBrokerUrl(brokerUrl);
50295
+ if (!origin) throw new BrokerError("rejected", "broker URL is not an https origin");
50296
+ let response;
50297
+ try {
50298
+ response = await fetchImpl(`${origin}${path}`, {
50299
+ method: "POST",
50300
+ headers: {
50301
+ "content-type": "application/json",
50302
+ ...init.bearer ? { authorization: `Bearer ${init.bearer}` } : {}
50303
+ },
50304
+ body: JSON.stringify(init.body ?? {}),
50305
+ signal: AbortSignal.timeout(BROKER_TIMEOUT_MS)
50306
+ });
50307
+ } catch (err) {
50308
+ throw new BrokerError("unavailable", `broker ${path} unreachable: ${err instanceof Error ? err.message : String(err)}`);
50309
+ }
50310
+ const text = await response.text().catch(() => "");
50311
+ let json = null;
50312
+ try {
50313
+ json = text ? JSON.parse(text) : null;
50314
+ } catch {
50315
+ json = null;
50316
+ }
50317
+ if (!response.ok) {
50318
+ const code = json && typeof json === "object" && "error" in json && typeof json.error === "string" ? json.error : "error";
50319
+ throw new BrokerError(kindForStatus(response.status), `broker ${path} ${response.status} ${code}`, response.status);
50320
+ }
50321
+ const parsed = schema.safeParse(json);
50322
+ if (!parsed.success) throw new BrokerError("unavailable", `broker ${path} answered an unexpected shape`);
50323
+ return parsed.data;
50324
+ }
50325
+ /** A fresh (or still-valid) Event Gateway access token for this link. */
50326
+ function fetchBrokerAccessToken(fetchImpl, brokerUrl, credential) {
50327
+ return call(fetchImpl, brokerUrl, "/broker/token", { bearer: credential }, AccessTokenSchema);
50328
+ }
50329
+ /**
50330
+ * Hand a pre-D753 refresh token to the broker in exchange for a link. The
50331
+ * caller deletes its own copy of the refresh token on success.
50332
+ */
50333
+ function migrateLegacyGrant(fetchImpl, brokerUrl, input) {
50334
+ return call(fetchImpl, brokerUrl, "/broker/migrate", { body: input }, MigrationSchema);
50335
+ }
50336
+ /** Swap the credential's secret; the old one stops working at once. */
50337
+ async function rotateBrokerCredential(fetchImpl, brokerUrl, credential) {
50338
+ return (await call(fetchImpl, brokerUrl, "/broker/rotate", { bearer: credential }, RotationSchema)).brokerCredential;
50339
+ }
50340
+ /** Delete the link at the broker (the refresh token goes with it). */
50341
+ async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
50342
+ await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
50343
+ }
50344
+ //#endregion
50189
50345
  //#region src/handlers/security-panel-arm-state.ts
50190
50346
  /**
50191
50347
  * The panel modes behind each Alexa arm state, PRIMARY first — the mirror of
@@ -51795,8 +51951,12 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
51795
51951
  //#endregion
51796
51952
  //#region src/handlers/lwa-tokens-callback.ts
51797
51953
  /**
51798
- * `POST /addon/export-alexa/oauth/lwa-tokens` — callback the Alexa
51799
- * Lambda invokes after it completes the LWA grant exchange.
51954
+ * `POST /addon/export-alexa/oauth/lwa-tokens` — the LEGACY (pre-D753)
51955
+ * callback a Lambda without the token broker invokes after the LWA grant
51956
+ * exchange. It hands this hub the refresh token itself. Kept so an old Lambda
51957
+ * keeps linking during the cut-over; the hub cannot renew such a grant (it
51958
+ * holds no client secret) and migrates it to the broker instead. The D753
51959
+ * Lambda calls `/oauth/broker-link` (`broker-link-callback.ts`).
51800
51960
  *
51801
51961
  * Auth: Bearer `<granteeToken>` where the grantee token is the OAuth
51802
51962
  * access token (kind=`oauth-access`) issued by the hub's core OAuth
@@ -51849,7 +52009,7 @@ async function handleLwaTokensCallback(request, reply, deps) {
51849
52009
  userId,
51850
52010
  username
51851
52011
  });
51852
- deps.logger.info("alexa lwa-tokens-callback: stored LWA tokens + recorded link", { meta: {
52012
+ 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: {
51853
52013
  userId,
51854
52014
  expiresInMs: body.expiresAt - Date.now()
51855
52015
  } });
@@ -51886,6 +52046,78 @@ function pickHeader(headers, name) {
51886
52046
  return null;
51887
52047
  }
51888
52048
  //#endregion
52049
+ //#region src/handlers/broker-link-callback.ts
52050
+ /**
52051
+ * `POST /addon/export-alexa/oauth/broker-link` — the skill Lambda tells this
52052
+ * hub that an Amazon account was linked (D753).
52053
+ *
52054
+ * The Lambda has already exchanged the AcceptGrant code with Amazon and kept
52055
+ * the refresh token. It gives this hub:
52056
+ * - `brokerUrl` where to ask for renewed access tokens,
52057
+ * - `brokerCredential` this link's credential at the broker,
52058
+ * - `lwaAccessToken` / `expiresAt` the first Event Gateway token.
52059
+ * No refresh token and no client secret reach the hub.
52060
+ *
52061
+ * Auth: identical to the legacy `/oauth/lwa-tokens` callback — Bearer
52062
+ * `<granteeToken>`, the hub's own `oauth-access` token Amazon presented with
52063
+ * the AcceptGrant, verified by `oauthVerifyAccessToken` (and the ingress-token
52064
+ * guard in `http-server.ts`). A Lambda that does not know this route gets 404
52065
+ * and falls back to the legacy callback, which is how the cut-over stays
52066
+ * zero-downtime in both directions.
52067
+ *
52068
+ * Errors: 400 `invalid_body` / `invalid_broker_url`, 401 `unauthorized`.
52069
+ */
52070
+ var BrokerLinkBodySchema = object({
52071
+ brokerUrl: string().min(1).max(512),
52072
+ brokerCredential: string().min(1).max(512),
52073
+ lwaAccessToken: string().min(1),
52074
+ expiresAt: number().int().nonnegative()
52075
+ });
52076
+ async function handleBrokerLinkCallback(request, reply, deps) {
52077
+ const parsed = BrokerLinkBodySchema.safeParse(request.body);
52078
+ if (!parsed.success) {
52079
+ reply.code(400).send({
52080
+ error: "invalid_body",
52081
+ message: parsed.error.message
52082
+ });
52083
+ return;
52084
+ }
52085
+ const body = parsed.data;
52086
+ const brokerUrl = normaliseBrokerUrl(body.brokerUrl);
52087
+ if (!brokerUrl) {
52088
+ reply.code(400).send({
52089
+ error: "invalid_broker_url",
52090
+ message: "brokerUrl must be an https origin"
52091
+ });
52092
+ return;
52093
+ }
52094
+ const caller = await resolveCaller(request, deps);
52095
+ if (!caller) {
52096
+ deps.logger.warn("alexa broker-link: unauthorized", { meta: { hasBearer: pickHeader(request.headers, "authorization") !== null } });
52097
+ reply.code(401).send({
52098
+ error: "unauthorized",
52099
+ message: "callback requires Bearer <granteeToken> (oauth-access token)"
52100
+ });
52101
+ return;
52102
+ }
52103
+ await deps.upsertToken({
52104
+ userId: caller.userId,
52105
+ accessToken: body.lwaAccessToken,
52106
+ expiresAt: body.expiresAt,
52107
+ brokerUrl,
52108
+ brokerCredential: body.brokerCredential
52109
+ });
52110
+ await deps.recordLinkedAccount({
52111
+ userId: caller.userId,
52112
+ username: caller.username
52113
+ });
52114
+ deps.logger.info("alexa broker-link: linked — push grant held by the skill broker", { meta: {
52115
+ userId: caller.userId,
52116
+ expiresInMs: body.expiresAt - Date.now()
52117
+ } });
52118
+ reply.code(200).send({ ok: true });
52119
+ }
52120
+ //#endregion
51889
52121
  //#region src/ingress-token.ts
51890
52122
  /**
51891
52123
  * Ingress token — a rotatable shared secret that the user-owned Alexa Lambda
@@ -51943,7 +52175,8 @@ function readIngressHeader(headers) {
51943
52175
  *
51944
52176
  * Public URLs (assuming the hub is reachable as `https://<hub>`):
51945
52177
  * POST /addon/export-alexa/directive
51946
- * POST /addon/export-alexa/oauth/lwa-tokens (Lambda → hub callback)
52178
+ * POST /addon/export-alexa/oauth/broker-link (Lambda → hub, D753 broker link)
52179
+ * POST /addon/export-alexa/oauth/lwa-tokens (Lambda → hub, legacy pre-D753)
51947
52180
  *
51948
52181
  * OAuth account-linking is handled by the core `/oauth2/*` server
51949
52182
  * (integration=export-alexa). This addon registers an `oauth-integration`
@@ -51954,6 +52187,8 @@ var OauthVerifiedResultSchema = object({
51954
52187
  username: string(),
51955
52188
  scopes: array(TokenScopeSchema)
51956
52189
  }).nullable();
52190
+ /** Header the D753 Lambda sends its broker origin on. */
52191
+ var BROKER_HEADER = "x-camstack-broker";
51957
52192
  function buildRoutes(deps) {
51958
52193
  return [
51959
52194
  {
@@ -51963,6 +52198,16 @@ function buildRoutes(deps) {
51963
52198
  description: "Alexa Smart Home directive ingress (forwarded by the user-owned Lambda)",
51964
52199
  handler: (req, reply) => handleDirective(req, reply, deps)
51965
52200
  },
52201
+ {
52202
+ method: "POST",
52203
+ path: "/oauth/broker-link",
52204
+ access: "public",
52205
+ description: "Lambda → hub broker link (D753): broker URL + per-link credential + first access token; bearer-authenticated via oauth-access token",
52206
+ handler: async (req, reply) => {
52207
+ if (ingressRejected(req, reply, deps, "alexa broker-link")) return;
52208
+ await handleBrokerLinkCallback(req, reply, deps.lwaTokensCallbackDeps);
52209
+ }
52210
+ },
51966
52211
  {
51967
52212
  method: "POST",
51968
52213
  path: "/oauth/lwa-tokens",
@@ -52073,6 +52318,8 @@ async function handleDirective(request, reply, deps) {
52073
52318
  reply.code(200).send(preParseErrorEnvelope("INVALID_AUTHORIZATION_CREDENTIAL", "token does not grant access to device capabilities"));
52074
52319
  return;
52075
52320
  }
52321
+ const advertised = readHeader(request.headers, BROKER_HEADER);
52322
+ if (advertised && deps.onBrokerAdvertised) deps.onBrokerAdvertised(advertised);
52076
52323
  const callerIdentity = {
52077
52324
  userId: verified.userId,
52078
52325
  username: verified.username,
@@ -52081,6 +52328,19 @@ async function handleDirective(request, reply, deps) {
52081
52328
  const result = await dispatchDirective(request.body, deps.directiveDeps, callerIdentity);
52082
52329
  reply.code(result.status).send(result.body);
52083
52330
  }
52331
+ function readHeader(headers, name) {
52332
+ for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === name) return value;
52333
+ }
52334
+ //#endregion
52335
+ //#region src/types.ts
52336
+ var DEFAULT_DEVICE_SETTINGS = {
52337
+ streamPreference: "auto",
52338
+ sourceProfile: "high"
52339
+ };
52340
+ /** A grant the hub can renew through the broker. */
52341
+ function isBrokerGrant(t) {
52342
+ return typeof t.brokerUrl === "string" && typeof t.brokerCredential === "string";
52343
+ }
52084
52344
  /** `listOauthSessions` returns every session for every integration. */
52085
52345
  var OauthSessionListSchema = array(object({
52086
52346
  id: string(),
@@ -52129,15 +52389,15 @@ function buildLinkStatusContent(input) {
52129
52389
  lines.push(`• ${acc.username} — linked ${stamp(acc.linkedAt)}`);
52130
52390
  lines.push(` ${describeGrant(acc, input)}`);
52131
52391
  }
52132
- 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.");
52133
52392
  return lines.join("\n");
52134
52393
  }
52135
52394
  function describeGrant(acc, input) {
52136
52395
  const token = input.tokens.find((t) => t.userId === acc.userId);
52137
52396
  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.";
52138
52397
  const renewedAgo = minutes(input.now - token.receivedAt);
52139
- 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".`;
52140
- return `Amazon push grant: held, valid for another ${minutes(token.expiresAt - input.now)} min (last renewed ${renewedAgo} min ago).`;
52398
+ 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.`;
52399
+ 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".`;
52400
+ 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).`;
52141
52401
  }
52142
52402
  //#endregion
52143
52403
  //#region src/motion-clear-hold.ts
@@ -52238,15 +52498,16 @@ var MotionClearHold = class {
52238
52498
  * exposed.
52239
52499
  * 3. For each linked Alexa account, POST a `DoorbellEventSource/
52240
52500
  * DoorbellPress` payload to `api.amazonalexa.com/v3/events`.
52241
- * 4. On 401, refresh the LWA token via
52242
- * `https://api.amazon.com/auth/o2/token` and retry once.
52501
+ * 4. On 401, ask the skill's token broker for a fresh access token and
52502
+ * retry once.
52243
52503
  *
52244
- * The actual LWA grant exchange (Lambda → Amazon → access+refresh) is
52245
- * owned by the operator-side Lambda, which POSTs the resulting tokens to
52246
- * this addon's `/oauth/lwa-tokens` callback. This client assumes
52247
- * `this.config.tokens[userId]` is already populated with valid
52248
- * `accessToken` / `refreshToken` values. When tokens are absent we
52249
- * log + skip without erroring.
52504
+ * This hub holds NO Login With Amazon client secret (D753). The skill's
52505
+ * Lambda exchanges the AcceptGrant code, keeps the refresh token and the
52506
+ * secret, and hands this hub short-lived access tokens against a per-link
52507
+ * credential (`broker-client.ts`). A grant delivered by a pre-D753 Lambda
52508
+ * (with a refresh token) cannot be renewed here; it is migrated to the broker
52509
+ * as soon as the broker's URL is known, and the refresh token is deleted.
52510
+ * When no grant is stored we log + skip without erroring.
52250
52511
  *
52251
52512
  * Throttling: Alexa requires at most one DoorbellPress per endpoint
52252
52513
  * every 30s. We enforce this per (userId, endpointId) tuple in-memory.
@@ -52256,10 +52517,11 @@ var EVENT_GATEWAY_URLS = {
52256
52517
  EU: "https://api.eu.amazonalexa.com/v3/events",
52257
52518
  FE: "https://api.fe.amazonalexa.com/v3/events"
52258
52519
  };
52259
- var LWA_TOKEN_URL = "https://api.amazon.com/auth/o2/token";
52260
52520
  var THROTTLE_WINDOW_MS = 3e4;
52261
52521
  var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
52262
52522
  var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
52523
+ /** After a failed legacy-grant migration, wait this long before trying again. */
52524
+ var MIGRATION_RETRY_MS = 300 * 1e3;
52263
52525
  /** The interface an endpoint must advertise for a `DoorbellPress` to survive
52264
52526
  * the Alexa cloud. */
52265
52527
  var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
@@ -52309,9 +52571,13 @@ var LwaEventGateway = class {
52309
52571
  * Alexa Smart Home rate-limits ChangeReports per property; dedupe
52310
52572
  * so only level *changes* (or step-grain ones) hit the gateway. */
52311
52573
  lastBatteryLevel = /* @__PURE__ */ new Map();
52312
- /** Latches the "no LWA credentials" warning so it's logged ONCE, not on
52313
- * every doorbell/motion event when proactive push is unconfigured. */
52314
- warnedNoLwaCreds = false;
52574
+ /** Latches the "no grant at all" warning so it's logged ONCE, not on every
52575
+ * doorbell/motion event while no account has a push grant. */
52576
+ warnedNoGrant = false;
52577
+ /** Users whose legacy grant is waiting for the broker URL (warned once). */
52578
+ warnedLegacyWaiting = /* @__PURE__ */ new Set();
52579
+ /** Per-user earliest next migration attempt after a failure. */
52580
+ migrationRetryAt = /* @__PURE__ */ new Map();
52315
52581
  /** Users we have already reported as having no stored Amazon push grant.
52316
52582
  * Cleared for a user the moment a grant is found again, so a re-link
52317
52583
  * re-arms the warning instead of muting it for the process's lifetime. */
@@ -52356,6 +52622,8 @@ var LwaEventGateway = class {
52356
52622
  this.smartVisionDropped.clear();
52357
52623
  this.lastBatteryLevel.clear();
52358
52624
  this.warnedNoToken.clear();
52625
+ this.warnedLegacyWaiting.clear();
52626
+ this.migrationRetryAt.clear();
52359
52627
  }
52360
52628
  async onDoorbellPress(data) {
52361
52629
  const childDevice = await this.deps.ctx.api.deviceManager.getDevice.query({ deviceId: data.deviceId }).catch(() => null);
@@ -52667,12 +52935,15 @@ var LwaEventGateway = class {
52667
52935
  } });
52668
52936
  return true;
52669
52937
  }
52938
+ get fetchImpl() {
52939
+ return this.deps.fetchImpl ?? ((url, init) => fetch(url, init));
52940
+ }
52670
52941
  async refreshExpiringTokens() {
52671
52942
  if (!this.hasProactiveCredentials()) return;
52672
52943
  const tokens = this.deps.listTokens();
52673
52944
  const now = Date.now();
52674
52945
  for (const token of tokens) {
52675
- if (token.expiresAt - now > REFRESH_PROACTIVE_WINDOW_MS) continue;
52946
+ if (!(!isBrokerGrant(token) || token.expiresAt - now <= REFRESH_PROACTIVE_WINDOW_MS)) continue;
52676
52947
  await this.refreshAccessToken(token.userId).catch((err) => {
52677
52948
  this.deps.logger.warn("alexa lwa-gateway: proactive refresh failed", { meta: {
52678
52949
  userId: token.userId,
@@ -52681,61 +52952,106 @@ var LwaEventGateway = class {
52681
52952
  });
52682
52953
  }
52683
52954
  }
52955
+ /** A fresh access token for `userId`, or null. Never throws for broker errors. */
52684
52956
  async refreshAccessToken(userId) {
52685
- const creds = this.deps.getLwaCredentials();
52686
- if (!creds) {
52687
- this.deps.logger.warn("alexa lwa-gateway: LWA client credentials not configured", { meta: { userId } });
52688
- return null;
52689
- }
52690
52957
  const existing = this.findToken(userId);
52691
52958
  if (!existing) return null;
52692
- const params = new URLSearchParams();
52693
- params.set("grant_type", "refresh_token");
52694
- params.set("refresh_token", existing.refreshToken);
52695
- params.set("client_id", creds.clientId);
52696
- params.set("client_secret", creds.clientSecret);
52697
- const response = await fetch(LWA_TOKEN_URL, {
52698
- method: "POST",
52699
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
52700
- body: params.toString()
52701
- });
52702
- if (!response.ok) {
52703
- const text = await response.text().catch(() => "");
52704
- this.deps.logger.warn("alexa lwa-gateway: LWA refresh non-OK", { meta: {
52959
+ if (!isBrokerGrant(existing)) return this.migrateLegacyGrant(existing);
52960
+ try {
52961
+ const fresh = await fetchBrokerAccessToken(this.fetchImpl, existing.brokerUrl, existing.brokerCredential);
52962
+ await this.deps.upsertToken({
52705
52963
  userId,
52706
- status: response.status,
52707
- body: text.slice(0, 500)
52964
+ accessToken: fresh.accessToken,
52965
+ expiresAt: fresh.expiresAt,
52966
+ brokerUrl: existing.brokerUrl,
52967
+ brokerCredential: existing.brokerCredential
52968
+ });
52969
+ return fresh.accessToken;
52970
+ } catch (err) {
52971
+ if (err instanceof BrokerError && (err.kind === "unauthorized" || err.kind === "revoked")) {
52972
+ 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: {
52973
+ userId,
52974
+ kind: err.kind
52975
+ } });
52976
+ await this.deps.removeLinkedAccount(userId).catch(() => {});
52977
+ return null;
52978
+ }
52979
+ this.deps.logger.warn("alexa lwa-gateway: broker renewal failed — will retry", { meta: {
52980
+ userId,
52981
+ error: err instanceof Error ? err.message : String(err)
52708
52982
  } });
52709
52983
  return null;
52710
52984
  }
52711
- const parsed = await response.json();
52712
- if (typeof parsed.access_token !== "string" || typeof parsed.expires_in !== "number") {
52713
- this.deps.logger.warn("alexa lwa-gateway: malformed LWA refresh response", { meta: { userId } });
52985
+ }
52986
+ /**
52987
+ * Hand a pre-D753 refresh token to the skill's broker and keep the link it
52988
+ * returns instead. Zero-touch: the broker URL arrives on the next verified
52989
+ * directive. On success the refresh token is gone from this hub.
52990
+ */
52991
+ async migrateLegacyGrant(entry) {
52992
+ const userId = entry.userId;
52993
+ if (!entry.refreshToken) return null;
52994
+ const brokerUrl = this.deps.getAdvertisedBrokerUrl();
52995
+ if (!brokerUrl) {
52996
+ if (!this.warnedLegacyWaiting.has(userId)) {
52997
+ this.warnedLegacyWaiting.add(userId);
52998
+ 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: {
52999
+ userId,
53000
+ expiresInMs: entry.expiresAt - Date.now()
53001
+ } });
53002
+ }
53003
+ return null;
53004
+ }
53005
+ const retryAt = this.migrationRetryAt.get(userId) ?? 0;
53006
+ if (Date.now() < retryAt) return null;
53007
+ try {
53008
+ const migrated = await migrateLegacyGrant(this.fetchImpl, brokerUrl, {
53009
+ refreshToken: entry.refreshToken,
53010
+ ...this.deps.getPublicHubUrl ? { hubUrl: this.deps.getPublicHubUrl() } : {}
53011
+ });
53012
+ await this.deps.upsertToken({
53013
+ userId,
53014
+ accessToken: migrated.accessToken,
53015
+ expiresAt: migrated.expiresAt,
53016
+ brokerUrl: normaliseBrokerUrl(migrated.brokerUrl) ?? brokerUrl,
53017
+ brokerCredential: migrated.brokerCredential
53018
+ });
53019
+ this.migrationRetryAt.delete(userId);
53020
+ this.warnedLegacyWaiting.delete(userId);
53021
+ 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 } });
53022
+ return migrated.accessToken;
53023
+ } catch (err) {
53024
+ this.migrationRetryAt.set(userId, Date.now() + MIGRATION_RETRY_MS);
53025
+ const kind = err instanceof BrokerError ? err.kind : "unavailable";
53026
+ const status = err instanceof BrokerError ? err.status : void 0;
53027
+ if (kind === "rejected" && status === 400) {
53028
+ this.deps.logger.warn("alexa lwa-gateway: Amazon no longer honours this push grant — re-link the skill in the Alexa app", { meta: { userId } });
53029
+ await this.deps.removeLinkedAccount(userId).catch(() => {});
53030
+ return null;
53031
+ }
53032
+ this.deps.logger.warn("alexa lwa-gateway: migrating the push grant to the broker failed", { meta: {
53033
+ userId,
53034
+ error: err instanceof Error ? err.message : String(err)
53035
+ } });
52714
53036
  return null;
52715
53037
  }
52716
- const refreshTokenNext = typeof parsed.refresh_token === "string" ? parsed.refresh_token : existing.refreshToken;
52717
- await this.deps.upsertToken({
52718
- userId,
52719
- accessToken: parsed.access_token,
52720
- refreshToken: refreshTokenNext,
52721
- expiresAt: Date.now() + parsed.expires_in * 1e3
52722
- });
52723
- return parsed.access_token;
52724
53038
  }
52725
53039
  findToken(userId) {
52726
53040
  return this.deps.listTokens().find((t) => t.userId === userId) ?? null;
52727
53041
  }
52728
53042
  /**
52729
- * Proactive push (doorbell/motion) needs the skill's LWA "Send Alexa Events"
52730
- * credentials to mint/refresh Event-Gateway tokens. When they're not
52731
- * configured, pushing is futile (Amazon returns INVALID_ACCESS_TOKEN and we
52732
- * cannot refresh) — skip it and warn ONCE instead of once per event.
53043
+ * Proactive push needs a stored grant for at least one account. With none,
53044
+ * pushing is futile — skip it and warn ONCE instead of once per event.
53045
+ * (Per-account absence is reported in `postEventBody`.)
52733
53046
  */
52734
53047
  hasProactiveCredentials() {
52735
- if (this.deps.getLwaCredentials() !== null) return true;
52736
- if (!this.warnedNoLwaCreds) {
52737
- this.warnedNoLwaCreds = true;
52738
- 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)");
53048
+ if (this.deps.listTokens().length > 0) {
53049
+ this.warnedNoGrant = false;
53050
+ return true;
53051
+ }
53052
+ if (!this.warnedNoGrant) {
53053
+ this.warnedNoGrant = true;
53054
+ 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");
52739
53055
  }
52740
53056
  return false;
52741
53057
  }
@@ -52984,23 +53300,21 @@ function buildAlexaOauthIntegration(publicHubUrl) {
52984
53300
  //#endregion
52985
53301
  //#region src/public-skill.ts
52986
53302
  /**
52987
- * Built-in "public" Alexa skill credentials.
53303
+ * The built-in "public" CamStack Alexa skill.
52988
53304
  *
52989
- * These identify the single published CamStack Alexa skill that every hub
52990
- * shares in `skillMode: 'public'`. They are intentionally committed to source:
52991
- * a published Smart Home skill has ONE set of "Send Alexa Events" credentials,
52992
- * and each self-hosted hub pushes proactive events DIRECTLY to the Alexa Event
52993
- * Gateway using them. The real per-account gate is the OAuth access/refresh
52994
- * token minted during account-linking — the client secret alone cannot push
52995
- * events to an account without that account's grant. This is the standard
52996
- * published-skill model for self-hosted servers.
53305
+ * Only its skill id lives here — an identifier, not a credential. Until D753
53306
+ * this file also carried the skill's Login With Amazon "Send Alexa Events"
53307
+ * client id and SECRET, on the theory that a per-account refresh token was the
53308
+ * real gate. The package is public on npm, so that secret was published with
53309
+ * every version and has to be rotated. Since D753 the secret lives only in the
53310
+ * skill's Lambda (AWS Secrets Manager); the hub gets short-lived Event Gateway
53311
+ * tokens from the Lambda's broker (`broker-client.ts`) with a per-link
53312
+ * credential. `scripts/check-no-client-secrets.ts` keeps it that way.
52997
53313
  *
52998
- * Operators who run their OWN skill switch to `skillMode: 'custom'` and supply
52999
- * their own values via settings; these constants are then unused.
53314
+ * Operators who run their OWN skill switch to `skillMode: 'custom'` and deploy
53315
+ * the same Lambda (`lambda-template/`) with their own secrets.
53000
53316
  */
53001
53317
  var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
53002
- var PUBLIC_LWA_CLIENT_ID = "amzn1.application-oa2-client.0033e4a5b8614b11a609f5eae7cbda82";
53003
- var PUBLIC_LWA_CLIENT_SECRET = "amzn1.oa2-cs.v1.82f852bc5d4a7fb906d670d36f936abd3011ac8c229d066b22a57a9f38488f1e";
53004
53318
  //#endregion
53005
53319
  //#region src/endpoint-signature.ts
53006
53320
  /** One capability's identity as Alexa distinguishes it. */
@@ -53247,12 +53561,6 @@ var SyncEngine = class {
53247
53561
  }
53248
53562
  };
53249
53563
  //#endregion
53250
- //#region src/types.ts
53251
- var DEFAULT_DEVICE_SETTINGS = {
53252
- streamPreference: "auto",
53253
- sourceProfile: "high"
53254
- };
53255
- //#endregion
53256
53564
  //#region src/export-alexa.addon.ts
53257
53565
  /**
53258
53566
  * Alexa Smart Home Skill export addon — hub-side.
@@ -53321,6 +53629,7 @@ var DEFAULT_CONFIG = {
53321
53629
  expectedAudience: "",
53322
53630
  lwaClientId: "",
53323
53631
  lwaClientSecret: "",
53632
+ advertisedBrokerUrl: "",
53324
53633
  region: "NA",
53325
53634
  exposed: [],
53326
53635
  linkedAccounts: [],
@@ -53350,6 +53659,10 @@ var ExportAlexaAddon = class extends BaseAddon {
53350
53659
  }
53351
53660
  async onInitialize() {
53352
53661
  if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
53662
+ if (this.config.lwaClientSecret) {
53663
+ await this.updateGlobalSettings({ lwaClientSecret: "" });
53664
+ this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
53665
+ }
53353
53666
  await this.backfillLinkedAccounts();
53354
53667
  this.autoDetectPublicHubUrl().catch((err) => {
53355
53668
  this.ctx.logger.warn("export-alexa: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
@@ -53420,7 +53733,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53420
53733
  upsertToken: (input) => this.upsertToken(input),
53421
53734
  recordLinkedAccount: (input) => this.recordLinkedAccount(input)
53422
53735
  },
53423
- redetectPublicHubUrl: () => this.redetectPublicHubUrl()
53736
+ redetectPublicHubUrl: () => this.redetectPublicHubUrl(),
53737
+ onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw)
53424
53738
  }));
53425
53739
  const oauthIntegrationProvider = { getDescriptor: async () => buildAlexaOauthIntegration(this.config.publicHubUrl) };
53426
53740
  this.lwaGateway = new LwaEventGateway({
@@ -53431,7 +53745,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53431
53745
  listTokens: () => this.config.tokens,
53432
53746
  upsertToken: (input) => this.upsertToken(input),
53433
53747
  removeLinkedAccount: (userId) => this.removeLinkedAccount(userId),
53434
- getLwaCredentials: () => this.lwaCredentials(),
53748
+ getAdvertisedBrokerUrl: () => this.config.advertisedBrokerUrl || null,
53749
+ getPublicHubUrl: () => this.config.publicHubUrl,
53435
53750
  getRegion: () => this.config.region,
53436
53751
  getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
53437
53752
  });
@@ -53440,7 +53755,7 @@ var ExportAlexaAddon = class extends BaseAddon {
53440
53755
  logger: this.ctx.logger,
53441
53756
  listExposed: () => this.config.exposed,
53442
53757
  listLinkedAccounts: () => this.config.linkedAccounts,
53443
- hasProactiveCredentials: () => this.lwaCredentials() !== null,
53758
+ hasProactiveCredentials: () => this.config.tokens.length > 0,
53444
53759
  buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
53445
53760
  postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
53446
53761
  postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
@@ -53475,7 +53790,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53475
53790
  exposedCount: this.config.exposed.length,
53476
53791
  skillMode: this.config.skillMode,
53477
53792
  skillIdSet: this.effectiveSettings().expectedAudience.length > 0,
53478
- lwaConfigured: this.lwaCredentials() !== null
53793
+ pushGrants: this.config.tokens.length,
53794
+ brokerGrants: this.config.tokens.filter(isBrokerGrant).length
53479
53795
  } });
53480
53796
  return {
53481
53797
  providers: [
@@ -53498,7 +53814,8 @@ var ExportAlexaAddon = class extends BaseAddon {
53498
53814
  regenerateIngressToken: async () => this.regenerateIngressToken(),
53499
53815
  resyncDiscovery: async () => this.resyncDiscovery(),
53500
53816
  purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
53501
- unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts()
53817
+ unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
53818
+ rotateBrokerCredential: async () => this.rotateBrokerCredentials()
53502
53819
  }
53503
53820
  };
53504
53821
  }
@@ -53720,6 +54037,7 @@ var ExportAlexaAddon = class extends BaseAddon {
53720
54037
  }
53721
54038
  this.ctx.logger.warn("export-alexa: revokeOauthSession reported failure — the session may already be gone", { meta: { sessionId: id } });
53722
54039
  }
54040
+ await this.revokeAtBroker(this.config.tokens);
53723
54041
  const removedAccounts = this.config.linkedAccounts.length;
53724
54042
  await this.updateGlobalSettings({
53725
54043
  linkedAccounts: [],
@@ -53741,9 +54059,11 @@ var ExportAlexaAddon = class extends BaseAddon {
53741
54059
  const entry = {
53742
54060
  userId: input.userId,
53743
54061
  accessToken: input.accessToken,
53744
- refreshToken: input.refreshToken,
53745
54062
  expiresAt: input.expiresAt,
53746
- receivedAt: Date.now()
54063
+ receivedAt: Date.now(),
54064
+ ...input.refreshToken ? { refreshToken: input.refreshToken } : {},
54065
+ ...input.brokerUrl ? { brokerUrl: input.brokerUrl } : {},
54066
+ ...input.brokerCredential ? { brokerCredential: input.brokerCredential } : {}
53747
54067
  };
53748
54068
  const next = this.config.tokens.filter((t) => t.userId !== input.userId);
53749
54069
  next.push(entry);
@@ -53789,26 +54109,79 @@ var ExportAlexaAddon = class extends BaseAddon {
53789
54109
  }
53790
54110
  /**
53791
54111
  * Settings as the runtime should read them. In `skillMode: 'public'` the
53792
- * skill id + LWA credentials are swapped for the built-in CamStack values;
53793
- * the operator's own `expectedAudience`/`lwaClient*` stay untouched in the
53794
- * persisted config so switching back to `custom` restores them. Everything
53795
- * else (hub URL, ingress token, region, exposed list, …) passes through.
54112
+ * skill id is swapped for the built-in CamStack skill's; the operator's own
54113
+ * `expectedAudience` stays untouched in the persisted config so switching
54114
+ * back to `custom` restores it. Everything else passes through. There is no
54115
+ * credential to overlay any more (D753).
53796
54116
  */
53797
54117
  effectiveSettings() {
53798
54118
  if (this.config.skillMode !== "public") return this.config;
53799
54119
  return {
53800
54120
  ...this.config,
53801
- expectedAudience: PUBLIC_SKILL_ID,
53802
- lwaClientId: PUBLIC_LWA_CLIENT_ID,
53803
- lwaClientSecret: PUBLIC_LWA_CLIENT_SECRET
54121
+ expectedAudience: PUBLIC_SKILL_ID
53804
54122
  };
53805
54123
  }
53806
- lwaCredentials() {
53807
- const s = this.effectiveSettings();
53808
- if (!s.lwaClientId || !s.lwaClientSecret) return null;
54124
+ /** Persist the broker origin the Lambda advertised on a verified directive. */
54125
+ rememberAdvertisedBroker(raw) {
54126
+ const url = normaliseBrokerUrl(raw);
54127
+ if (!url) {
54128
+ this.ctx.logger.warn("export-alexa: ignored a broker advertisement that is not an https origin");
54129
+ return;
54130
+ }
54131
+ if (url === this.config.advertisedBrokerUrl) return;
54132
+ this.updateGlobalSettings({ advertisedBrokerUrl: url }).then(() => {
54133
+ this.ctx.logger.info("export-alexa: the skill Lambda advertised its token broker", { meta: { brokerUrl: url } });
54134
+ }).catch((err) => {
54135
+ this.ctx.logger.warn("export-alexa: could not store the advertised broker URL", { meta: { error: errMsg(err) } });
54136
+ });
54137
+ }
54138
+ async revokeAtBroker(tokens) {
54139
+ for (const t of tokens) {
54140
+ if (!isBrokerGrant(t)) continue;
54141
+ await revokeBrokerCredential(fetch, t.brokerUrl, t.brokerCredential).catch((err) => {
54142
+ this.ctx.logger.warn("export-alexa: could not delete the link at the skill broker — it expires there on its own", { meta: {
54143
+ userId: t.userId,
54144
+ error: errMsg(err)
54145
+ } });
54146
+ });
54147
+ }
54148
+ }
54149
+ /**
54150
+ * Swap every broker credential this hub holds for a fresh one. The old
54151
+ * credential stops working at the broker immediately. A credential the
54152
+ * broker no longer recognises is reported, not retried — re-link instead.
54153
+ */
54154
+ async rotateBrokerCredentials() {
54155
+ let rotated = 0;
54156
+ let failed = 0;
54157
+ for (const t of this.config.tokens) {
54158
+ if (!isBrokerGrant(t)) continue;
54159
+ try {
54160
+ const next = await rotateBrokerCredential(fetch, t.brokerUrl, t.brokerCredential);
54161
+ await this.upsertToken({
54162
+ userId: t.userId,
54163
+ accessToken: t.accessToken,
54164
+ expiresAt: t.expiresAt,
54165
+ brokerUrl: t.brokerUrl,
54166
+ brokerCredential: next
54167
+ });
54168
+ rotated += 1;
54169
+ } catch (err) {
54170
+ failed += 1;
54171
+ this.ctx.logger.warn("export-alexa: broker credential rotation failed", { meta: {
54172
+ userId: t.userId,
54173
+ kind: err instanceof BrokerError ? err.kind : "unavailable",
54174
+ error: errMsg(err)
54175
+ } });
54176
+ }
54177
+ }
54178
+ this.ctx.logger.info("export-alexa: broker credentials rotated", { meta: {
54179
+ rotated,
54180
+ failed
54181
+ } });
53809
54182
  return {
53810
- clientId: s.lwaClientId,
53811
- clientSecret: s.lwaClientSecret
54183
+ rotated,
54184
+ failed
53812
54185
  };
53813
54186
  }
53814
54187
  /**
@@ -53963,8 +54336,9 @@ var ExportAlexaAddon = class extends BaseAddon {
53963
54336
  label: "Setup overview",
53964
54337
  variant: "info",
53965
54338
  content: [
53966
- "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).",
53967
- "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).",
54339
+ "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).",
54340
+ "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.",
54341
+ "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).",
53968
54342
  "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."
53969
54343
  ].join("\n")
53970
54344
  },
@@ -53989,7 +54363,7 @@ var ExportAlexaAddon = class extends BaseAddon {
53989
54363
  type: "select",
53990
54364
  key: "skillMode",
53991
54365
  label: "Alexa skill",
53992
- description: "Public: use the built-in CamStack skill — zero Amazon developer setup. Custom: bring your own skill and LWA credentials.",
54366
+ 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.",
53993
54367
  options: [{
53994
54368
  value: "public",
53995
54369
  label: "Public (built-in CamStack skill)"
@@ -54020,35 +54394,24 @@ var ExportAlexaAddon = class extends BaseAddon {
54020
54394
  ],
54021
54395
  default: DEFAULT_CONFIG.region
54022
54396
  }),
54023
- ...this.config.skillMode === "custom" ? [
54024
- this.field({
54025
- type: "text",
54026
- key: "expectedAudience",
54027
- label: "Alexa skill id",
54028
- 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.",
54029
- default: DEFAULT_CONFIG.expectedAudience
54030
- }),
54031
- this.field({
54032
- type: "text",
54033
- key: "lwaClientId",
54034
- label: "LWA client id (proactive events)",
54035
- 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.",
54036
- default: DEFAULT_CONFIG.lwaClientId
54037
- }),
54038
- this.field({
54039
- type: "password",
54040
- key: "lwaClientSecret",
54041
- label: "LWA client secret (proactive events)",
54042
- description: "Paired with LWA client id. Treat as sensitive.",
54043
- showToggle: true,
54044
- default: DEFAULT_CONFIG.lwaClientSecret
54045
- })
54046
- ] : [{
54397
+ ...this.config.skillMode === "custom" ? [this.field({
54398
+ type: "text",
54399
+ key: "expectedAudience",
54400
+ label: "Alexa skill id",
54401
+ 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.",
54402
+ default: DEFAULT_CONFIG.expectedAudience
54403
+ }), {
54404
+ type: "info",
54405
+ key: "__custom-skill-info",
54406
+ label: "Your skill Lambda",
54407
+ variant: "info",
54408
+ 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."
54409
+ }] : [{
54047
54410
  type: "info",
54048
54411
  key: "__public-skill-info",
54049
54412
  label: "Built-in skill",
54050
54413
  variant: "info",
54051
- 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."
54414
+ 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."
54052
54415
  }],
54053
54416
  {
54054
54417
  type: "info",
@@ -54058,7 +54421,6 @@ var ExportAlexaAddon = class extends BaseAddon {
54058
54421
  content: buildLinkStatusContent({
54059
54422
  linkedAccounts: this.config.linkedAccounts,
54060
54423
  tokens: this.config.tokens,
54061
- hasLwaCredentials: this.lwaCredentials() !== null,
54062
54424
  now: Date.now()
54063
54425
  })
54064
54426
  },
@@ -54072,6 +54434,15 @@ var ExportAlexaAddon = class extends BaseAddon {
54072
54434
  variant: "danger",
54073
54435
  confirmMessage: "Unlink the Amazon account? Alexa loses access to your cameras immediately and you must re-link from the Alexa app to restore it."
54074
54436
  },
54437
+ {
54438
+ type: "button",
54439
+ key: "__rotate-broker-credential",
54440
+ label: "Skill broker credential",
54441
+ 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.",
54442
+ buttonLabel: "Rotate credential",
54443
+ action: "rotateBrokerCredential",
54444
+ variant: "default"
54445
+ },
54075
54446
  {
54076
54447
  type: "button",
54077
54448
  key: "__resync-discovery",