@camstack/addon-export-alexa 1.2.132 → 1.2.134

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.
@@ -1,75 +1,147 @@
1
1
  /**
2
- * CamStack Alexa Smart Home Lambda — Option 2 (single hosted skill).
2
+ * CamStack Alexa Smart Home Lambda — one skill, many hubs, and the skill's
3
+ * token broker (D753).
3
4
  *
4
- * One Lambda + one skill on developer.amazon.com serves ALL camstack
5
- * users. The Lambda is stateless: it inspects an inbound event, decodes
6
- * the bearer JWT payload to extract the operator's hub URL, then proxies
7
- * the request to that hub's core `/api/oauth2/*` endpoints.
5
+ * One Lambda + one skill serves every CamStack hub. Directives are routed by
6
+ * the `hubUrl` claim each hub bakes into the OAuth tokens it mints. Since D753
7
+ * the Lambda is also the ONLY holder of the skill's LWA client secret: it
8
+ * exchanges AcceptGrant codes itself, keeps each user's LWA refresh token in
9
+ * DynamoDB, and hands hubs short-lived Event Gateway access tokens against a
10
+ * per-link credential (see broker.js).
8
11
  *
9
12
  * Invocation shapes:
10
- * 1. Lambda URL HTTP GET /authorize → render hub-URL entry form
11
- * 2. Lambda URL HTTP POST /token → proxy OAuth token exchange to hub
12
- * 3. Smart Home directive (event.directive.header):
13
- * - Alexa.Authorization/AcceptGrant → exchange LWA code at Amazon,
14
- * forward tokens to hub
15
- * - everything else → forward directive to hub
13
+ * 1. Function URL GET /authorize → hub-URL entry form
14
+ * 2. Function URL POST /token → OAuth token exchange, proxied to the hub
15
+ * 3. Function URL POST /broker/* → token broker (broker.js)
16
+ * 4. Smart Home directive:
17
+ * - Alexa.Authorization/AcceptGrant → exchange the LWA code here, store the
18
+ * grant, give the hub a broker credential (legacy: give it the tokens)
19
+ * - everything else → forward to the hub
16
20
  *
17
- * Required environment variables:
18
- * SKILL_CLIENT_ID OAuth client id Alexa sends on /token (skill
19
- * manifest "Client ID"). Free-form; we just
20
- * compare it.
21
- * SKILL_CLIENT_SECRET Paired client secret (skill manifest "Client
22
- * Secret"). Free-form; compared against the
23
- * inbound POST body.
24
- * LWA_CLIENT_ID Login With Amazon client id from the skill's
25
- * "Permissions → Send Alexa Events" panel. Used
26
- * in the AcceptGrant LWA token exchange.
27
- * LWA_CLIENT_SECRET Paired LWA client secret.
28
- * LAMBDA_REDIRECT_URI The Function URL + "/token" (e.g.
29
- * https://abc123.lambda-url.eu-west-1.on.aws/token).
30
- * Passed as `redirect_uri` to api.amazon.com.
21
+ * Configuration (env — none of these is a secret once D753 is deployed):
22
+ * LWA_SECRET_ID Secrets Manager id of {"clientId","clientSecret"} for
23
+ * the skill's "Send Alexa Events" LWA client.
24
+ * SKILL_SECRET_ID Secrets Manager id of {"clientId","clientSecret"
25
+ * [,"previousClientSecret"]} for account linking.
26
+ * TOKENS_TABLE DynamoDB table of broker links. Unset = broker off
27
+ * (pre-D753 behaviour: tokens are handed to the hub).
28
+ * LAMBDA_REDIRECT_URI Function URL + "/token"; the AcceptGrant redirect_uri.
29
+ * BROKER_PUBLIC_URL Optional; defaults to LAMBDA_REDIRECT_URI's origin.
30
+ * LEGACY_HUB_FALLBACK "0" = a hub without the broker route fails AcceptGrant
31
+ * instead of receiving the refresh token. Default on.
32
+ * ALLOW_MIGRATION "0" = refuse /broker/migrate. Default on.
33
+ * HUB_INGRESS_TOKEN Optional defense-in-depth header for hub calls.
34
+ * Legacy (read only when the *_SECRET_ID is unset): LWA_CLIENT_ID,
35
+ * LWA_CLIENT_SECRET, SKILL_CLIENT_ID, SKILL_CLIENT_SECRET.
31
36
  *
32
- * Ships as a single file — no node_modules, no transpile. Targets the
33
- * AWS Lambda Node 20.x runtime which exposes built-in `fetch`.
37
+ * Ships as plain files — no node_modules, no transpile (`zip` the folder's
38
+ * .js files). AWS SDK v3 comes with the nodejs22.x runtime.
34
39
  */
40
+ 'use strict'
35
41
 
36
- const FETCH_TIMEOUT_MS = 6000
42
+ const crypto = require('node:crypto')
37
43
 
38
- // Use https.request with a no-keep-alive Agent. Node's built-in fetch
39
- // (undici-backed) reuses pooled sockets aggressively; in AWS Lambda the
40
- // invocation-to-invocation gaps can leave pooled sockets stale, and the next
41
- // fetch throws DOMException AbortError in <1s. https.request gives us
42
- // explicit control. undici itself is not require-able in the Node 20 Lambda
43
- // runtime, so we use the standard https module directly.
44
- const https = require('node:https')
45
- const http = require('node:http')
46
- const { URL } = require('node:url')
47
- const NO_KEEPALIVE_AGENT = new https.Agent({ keepAlive: false })
44
+ const { createAwsClients } = require('./aws.js')
45
+ const {
46
+ activateLink,
47
+ createLink,
48
+ exchangeGrantCode,
49
+ handleBrokerRoute,
50
+ isBrokerPath,
51
+ shortId,
52
+ } = require('./broker.js')
53
+ const { timedFetch } = require('./http.js')
54
+ const { createSecretSource } = require('./secrets.js')
55
+ const { createDynamoTokenStore } = require('./token-store.js')
56
+ const {
57
+ decodeBody,
58
+ acceptGrantErrorEnvelope,
59
+ completeErrorResponse,
60
+ decodeJwtPayload,
61
+ describe,
62
+ encodeFormUrlEncoded,
63
+ errorEnvelope,
64
+ jsonResponse,
65
+ normaliseHubUrl,
66
+ parseFormUrlEncoded,
67
+ resolveBrokerPublicUrl,
68
+ } = require('./util.js')
48
69
 
49
- exports.handler = async (event) => {
50
- try {
51
- if (event && event.requestContext && event.requestContext.http) {
52
- return await handleLambdaUrl(event)
53
- }
54
- if (event && event.directive && event.directive.header) {
55
- return await handleDirective(event)
56
- }
57
- return jsonResponse(400, { error: 'unknown_event' })
58
- } catch (err) {
59
- console.error('camstack-alexa: unhandled error', err)
60
- if (event && event.directive && event.directive.header) {
61
- return errorEnvelope(event.directive.header, 'INTERNAL_ERROR', describe(err))
70
+ /** The header that tells a hub where its skill's broker lives (D753). */
71
+ const BROKER_HEADER = 'x-camstack-broker'
72
+
73
+ /**
74
+ * Build the dependency set once per container — the secret cache and the SDK
75
+ * clients must survive across invocations. Anything passed in wins (tests).
76
+ */
77
+ function resolveDeps(overrides) {
78
+ const o = overrides || {}
79
+ const env = o.env || process.env
80
+ let aws = null
81
+ const awsClients = () => {
82
+ if (!aws) aws = createAwsClients()
83
+ return aws
84
+ }
85
+ const store =
86
+ o.store !== undefined
87
+ ? o.store
88
+ : env.TOKENS_TABLE
89
+ ? createDynamoTokenStore({
90
+ send: (name, input) => awsClients().send(name, input),
91
+ tableName: env.TOKENS_TABLE,
92
+ })
93
+ : null
94
+ return {
95
+ env,
96
+ fetch: o.fetch || timedFetch,
97
+ now: o.now || Date.now,
98
+ randomBytes: o.randomBytes || crypto.randomBytes,
99
+ log: o.log || console,
100
+ secrets:
101
+ o.secrets ||
102
+ createSecretSource({
103
+ env,
104
+ fetchSecretString: (id) => awsClients().fetchSecretString(id),
105
+ }),
106
+ store,
107
+ publicUrl: resolveBrokerPublicUrl(env),
108
+ }
109
+ }
110
+
111
+ function createHandler(overrides) {
112
+ let deps = null
113
+ return async (event) => {
114
+ if (!deps) deps = resolveDeps(overrides)
115
+ try {
116
+ if (event && event.requestContext && event.requestContext.http) {
117
+ return await handleLambdaUrl(event, deps)
118
+ }
119
+ if (event && event.directive && event.directive.header) {
120
+ return await handleDirective(event, deps)
121
+ }
122
+ return jsonResponse(400, { error: 'unknown_event' })
123
+ } catch (err) {
124
+ deps.log.error('camstack-alexa: unhandled error', describe(err))
125
+ if (event && event.directive && event.directive.header) {
126
+ return errorEnvelope(
127
+ event.directive.header,
128
+ 'INTERNAL_ERROR',
129
+ describe(err),
130
+ event.directive.endpoint,
131
+ )
132
+ }
133
+ return jsonResponse(500, { error: 'internal_error', message: describe(err) })
62
134
  }
63
- return jsonResponse(500, { error: 'internal_error', message: describe(err) })
64
135
  }
65
136
  }
66
137
 
67
138
  // ── Lambda Function URL (HTTP) routes ────────────────────────────────
68
139
 
69
- async function handleLambdaUrl(event) {
140
+ async function handleLambdaUrl(event, deps) {
70
141
  const path = event.rawPath || '/'
71
142
  if (path === '/authorize') return handleAuthorizePage(event)
72
- if (path === '/token') return handleTokenProxy(event)
143
+ if (path === '/token') return handleTokenProxy(event, deps)
144
+ if (isBrokerPath(path)) return handleBrokerRoute(event, deps)
73
145
  return { statusCode: 404, headers: { 'content-type': 'text/plain' }, body: 'Not found' }
74
146
  }
75
147
 
@@ -99,25 +171,36 @@ function handleAuthorizePage(event) {
99
171
  button { margin-top: 1rem; width: 100%; padding: 0.7rem; font-size: 0.95rem; font-weight: 500; background: #2563eb; color: white; border: none; border-radius: 0.375rem; cursor: pointer; }
100
172
  button:hover { background: #1d4ed8; }
101
173
  .hint { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; }
174
+ .err { font-size: 0.85rem; color: #b91c1c; margin: 0.75rem 0 0; }
102
175
  </style>
103
176
  </head>
104
177
  <body>
105
178
  <div class="card">
106
179
  <h1>Link CamStack to Alexa</h1>
107
180
  <p>Enter the public HTTPS URL of your CamStack hub. Alexa will then ask the hub to authorize this Echo.</p>
108
- <form id="f">
181
+ <form id="f" novalidate>
109
182
  <label for="hubUrl">Hub URL</label>
110
183
  <input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
111
184
  <p class="hint">Must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, etc.).</p>
185
+ <p id="err" class="err" role="alert" hidden></p>
112
186
  <button type="submit">Continue</button>
113
187
  </form>
114
188
  </div>
115
189
  <script>
116
190
  document.getElementById('f').addEventListener('submit', function (ev) {
117
191
  ev.preventDefault()
118
- var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/$/, '')
119
- if (!hubUrl) return
120
- var qs = ${JSON.stringify(qs)}
192
+ var err = document.getElementById('err')
193
+ var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/+$/, '')
194
+ var parsed = null
195
+ try { parsed = new URL(hubUrl) } catch (e) { parsed = null }
196
+ if (!parsed || parsed.protocol !== 'https:' || !parsed.hostname) {
197
+ err.textContent = 'Enter the full address of your hub, starting with https:// (for example https://camstack.example.com).'
198
+ err.hidden = false
199
+ return
200
+ }
201
+ err.hidden = true
202
+ hubUrl = parsed.origin + parsed.pathname.replace(/\\/+$/, '')
203
+ var qs = ${jsonForScript(qs)}
121
204
  var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
122
205
  window.location.href = url
123
206
  })
@@ -131,27 +214,47 @@ function handleAuthorizePage(event) {
131
214
  }
132
215
  }
133
216
 
217
+ /** JSON for an inline <script>: `<` escaped so the value cannot close the tag. */
218
+ function jsonForScript(value) {
219
+ return JSON.stringify(value).replace(/</g, '\\u003c')
220
+ }
221
+
222
+ /** Constant-time string equality that does not leak length via timing. */
223
+ function safeEqual(a, b) {
224
+ const ab = Buffer.from(String(a), 'utf8')
225
+ const bb = Buffer.from(String(b), 'utf8')
226
+ if (ab.length !== bb.length) {
227
+ crypto.timingSafeEqual(ab, ab)
228
+ return false
229
+ }
230
+ return crypto.timingSafeEqual(ab, bb)
231
+ }
232
+
134
233
  /**
135
- * Proxy the OAuth token exchange. Alexa POSTs form-urlencoded body to
136
- * this Function URL with the client_id/client_secret from the skill
137
- * manifest; we verify those, decode the code/refresh_token JWT payload
138
- * to find the hub URL claim, then forward the same body to the hub's
139
- * own /api/oauth2/token endpoint.
234
+ * Proxy the OAuth token exchange. Alexa POSTs a form-urlencoded body with the
235
+ * account-linking client_id/client_secret; we verify those (current secret, or
236
+ * `previousClientSecret` mid-rotation), decode the code/refresh_token JWT
237
+ * payload to find the hub URL claim, then forward the body to the hub's own
238
+ * /api/oauth2/token endpoint.
140
239
  */
141
- async function handleTokenProxy(event) {
240
+ async function handleTokenProxy(event, deps) {
142
241
  if (event.requestContext.http.method !== 'POST') {
143
242
  return jsonResponse(405, { error: 'method_not_allowed' })
144
243
  }
145
244
  const body = parseFormUrlEncoded(decodeBody(event))
146
- const skillId = process.env.SKILL_CLIENT_ID
147
- const skillSecret = process.env.SKILL_CLIENT_SECRET
148
- if (!skillId || !skillSecret) {
245
+ const skill = await deps.secrets.skill()
246
+ if (!skill) {
149
247
  return jsonResponse(500, {
150
248
  error: 'unconfigured',
151
- message: 'SKILL_CLIENT_ID / SKILL_CLIENT_SECRET not set',
249
+ message: 'account-linking client credentials are not configured',
152
250
  })
153
251
  }
154
- if (body.client_id !== skillId || body.client_secret !== skillSecret) {
252
+ const secretOk =
253
+ typeof body.client_secret === 'string' &&
254
+ (safeEqual(body.client_secret, skill.clientSecret) ||
255
+ (skill.previousClientSecret !== undefined &&
256
+ safeEqual(body.client_secret, skill.previousClientSecret)))
257
+ if (body.client_id !== skill.clientId || !secretOk) {
155
258
  return jsonResponse(401, { error: 'invalid_client', message: 'client credentials mismatch' })
156
259
  }
157
260
 
@@ -171,7 +274,7 @@ async function handleTokenProxy(event) {
171
274
  }
172
275
 
173
276
  const target = `${normaliseHubUrl(claims.hubUrl)}/api/oauth2/token`
174
- const upstream = await timedFetch(target, {
277
+ const upstream = await deps.fetch(target, {
175
278
  method: 'POST',
176
279
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
177
280
  body: encodeFormUrlEncoded(body),
@@ -186,141 +289,210 @@ async function handleTokenProxy(event) {
186
289
 
187
290
  // ── Smart Home directives ────────────────────────────────────────────
188
291
 
189
- async function handleDirective(event) {
292
+ async function handleDirective(event, deps) {
190
293
  const header = event.directive.header
191
294
  const ns = `${header.namespace}/${header.name}`
192
295
  if (ns === 'Alexa.Authorization/AcceptGrant') {
193
- return handleAcceptGrant(event)
296
+ return handleAcceptGrant(event, deps)
194
297
  }
195
- return forwardDirective(event)
298
+ return forwardDirective(event, deps)
196
299
  }
197
300
 
198
- async function handleAcceptGrant(event) {
301
+ function hubHeaders(deps, bearer) {
302
+ return {
303
+ 'content-type': 'application/json',
304
+ authorization: `Bearer ${bearer}`,
305
+ // Defense-in-depth shared secret — the export-alexa "Ingress token"
306
+ // setting. Sent only when configured; the hub enforces it when the
307
+ // operator turns on "Require ingress token".
308
+ ...(deps.env.HUB_INGRESS_TOKEN ? { 'x-camstack-ingress': deps.env.HUB_INGRESS_TOKEN } : {}),
309
+ }
310
+ }
311
+
312
+ function brokerAvailable(deps) {
313
+ return deps.store !== null && typeof deps.publicUrl === 'string'
314
+ }
315
+
316
+ function acceptGrantResponse(header) {
317
+ return {
318
+ event: {
319
+ header: {
320
+ namespace: 'Alexa.Authorization',
321
+ name: 'AcceptGrant.Response',
322
+ messageId: header.messageId,
323
+ payloadVersion: '3',
324
+ },
325
+ payload: {},
326
+ },
327
+ }
328
+ }
329
+
330
+ async function handleAcceptGrant(event, deps) {
199
331
  const header = event.directive.header
200
332
  const payload = event.directive.payload || {}
201
333
  const grantCode = payload.grant && payload.grant.code
202
334
  const granteeToken = payload.grantee && payload.grantee.token
203
335
  if (!grantCode || !granteeToken) {
204
- return errorEnvelope(
205
- header,
206
- 'ACCEPT_GRANT_FAILED',
207
- 'payload missing grant.code or grantee.token',
208
- )
336
+ return acceptGrantErrorEnvelope(header, 'payload missing grant.code or grantee.token')
209
337
  }
210
338
  const claims = decodeJwtPayload(granteeToken)
211
339
  if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
212
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'grantee token has no hubUrl claim')
340
+ return acceptGrantErrorEnvelope(header, 'grantee token has no hubUrl claim')
341
+ }
342
+ const redirectUri = deps.env.LAMBDA_REDIRECT_URI
343
+ if (!redirectUri) {
344
+ return acceptGrantErrorEnvelope(header, 'LAMBDA_REDIRECT_URI not set on the Lambda')
213
345
  }
214
346
 
215
- const lwaClientId = process.env.LWA_CLIENT_ID
216
- const lwaClientSecret = process.env.LWA_CLIENT_SECRET
217
- const redirectUri = process.env.LAMBDA_REDIRECT_URI
218
- if (!lwaClientId || !lwaClientSecret || !redirectUri) {
219
- return errorEnvelope(
220
- header,
221
- 'ACCEPT_GRANT_FAILED',
222
- 'LWA_CLIENT_ID / LWA_CLIENT_SECRET / LAMBDA_REDIRECT_URI not set on the Lambda',
347
+ let tokens
348
+ try {
349
+ tokens = await exchangeGrantCode(deps, grantCode, redirectUri)
350
+ } catch (err) {
351
+ return acceptGrantErrorEnvelope(header, describe(err))
352
+ }
353
+ const hubUrl = normaliseHubUrl(claims.hubUrl)
354
+
355
+ if (brokerAvailable(deps)) {
356
+ const outcome = await linkViaBroker(deps, { hubUrl, granteeToken, tokens })
357
+ if (outcome.kind === 'linked') return acceptGrantResponse(header)
358
+ if (outcome.kind !== 'hub-without-broker' || deps.env.LEGACY_HUB_FALLBACK === '0') {
359
+ return acceptGrantErrorEnvelope(header, outcome.message)
360
+ }
361
+ deps.log.warn(
362
+ 'accept_grant: hub has no broker route; falling back to the legacy token hand-off',
223
363
  )
224
364
  }
365
+ return deliverTokensToLegacyHub(deps, header, { hubUrl, granteeToken, tokens })
366
+ }
225
367
 
226
- // Exchange Amazon's grant code for LWA tokens we can hand to the hub
227
- // so it can hit the Event Gateway later (proactive doorbell push).
228
- const lwaResp = await timedFetch('https://api.amazon.com/auth/o2/token', {
229
- method: 'POST',
230
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
231
- body: encodeFormUrlEncoded({
232
- grant_type: 'authorization_code',
233
- code: grantCode,
234
- client_id: lwaClientId,
235
- client_secret: lwaClientSecret,
236
- redirect_uri: redirectUri,
237
- }),
368
+ /**
369
+ * The D753 link: store the grant here, give the hub only a credential and a
370
+ * first access token. The item stays `pending` until the hub accepted it, so a
371
+ * hub that refuses (or does not have the route) leaves nothing behind.
372
+ */
373
+ async function linkViaBroker(deps, input) {
374
+ const { item, credential } = await createLink(deps, {
375
+ status: 'pending',
376
+ tokens: input.tokens,
377
+ hubUrl: input.hubUrl,
378
+ origin: 'accept-grant',
238
379
  })
239
- if (!lwaResp.ok) {
240
- const text = await lwaResp.text().catch(() => '')
241
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', `LWA exchange ${lwaResp.status}: ${text}`)
380
+ let resp
381
+ try {
382
+ resp = await deps.fetch(`${input.hubUrl}/addon/export-alexa/oauth/broker-link`, {
383
+ method: 'POST',
384
+ headers: hubHeaders(deps, input.granteeToken),
385
+ body: JSON.stringify({
386
+ brokerUrl: deps.publicUrl,
387
+ brokerCredential: credential,
388
+ lwaAccessToken: item.accessToken,
389
+ expiresAt: item.accessExpiresAt,
390
+ }),
391
+ })
392
+ } catch (err) {
393
+ await deps.store.remove(item.linkId)
394
+ return { kind: 'failed', message: `hub broker-link unreachable: ${describe(err)}` }
395
+ }
396
+ if (resp.ok) {
397
+ await activateLink(deps, item)
398
+ deps.log.info('accept_grant: linked via broker', { link: shortId(item.linkId) })
399
+ return { kind: 'linked' }
242
400
  }
243
- const lwa = await lwaResp.json()
244
- const expiresInSec = typeof lwa.expires_in === 'number' ? lwa.expires_in : 3600
401
+ await deps.store.remove(item.linkId)
402
+ if (resp.status === 404) return { kind: 'hub-without-broker', message: 'hub broker-link 404' }
403
+ const text = await resp.text().catch(() => '')
404
+ return { kind: 'failed', message: `hub broker-link ${resp.status}: ${text.slice(0, 200)}` }
405
+ }
245
406
 
246
- // Push the tokens back to the hub. The hub authenticates this request
247
- // via the bearer (= the same grantee token Alexa just gave us), so it
248
- // can attribute the LWA tokens to the right operator user.
249
- const hubUrl = normaliseHubUrl(claims.hubUrl)
250
- const callback = `${hubUrl}/addon/export-alexa/oauth/lwa-tokens`
251
- const callbackResp = await timedFetch(callback, {
407
+ /** Pre-D753 hand-off: the hub receives the refresh token itself. */
408
+ async function deliverTokensToLegacyHub(deps, header, input) {
409
+ const callbackResp = await deps.fetch(`${input.hubUrl}/addon/export-alexa/oauth/lwa-tokens`, {
252
410
  method: 'POST',
253
- headers: {
254
- 'content-type': 'application/json',
255
- authorization: `Bearer ${granteeToken}`,
256
- ...(process.env.HUB_INGRESS_TOKEN
257
- ? { 'x-camstack-ingress': process.env.HUB_INGRESS_TOKEN }
258
- : {}),
259
- },
411
+ headers: hubHeaders(deps, input.granteeToken),
260
412
  body: JSON.stringify({
261
- lwaAccessToken: lwa.access_token,
262
- lwaRefreshToken: lwa.refresh_token,
263
- expiresAt: Date.now() + expiresInSec * 1000,
413
+ lwaAccessToken: input.tokens.accessToken,
414
+ lwaRefreshToken: input.tokens.refreshToken,
415
+ expiresAt: deps.now() + input.tokens.expiresInSec * 1000,
264
416
  }),
265
417
  })
266
418
  if (!callbackResp.ok) {
267
419
  const text = await callbackResp.text().catch(() => '')
268
- return errorEnvelope(
420
+ return acceptGrantErrorEnvelope(
269
421
  header,
270
- 'ACCEPT_GRANT_FAILED',
271
- `hub callback ${callbackResp.status}: ${text}`,
422
+ `hub callback ${callbackResp.status}: ${text.slice(0, 200)}`,
272
423
  )
273
424
  }
425
+ return acceptGrantResponse(header)
426
+ }
274
427
 
275
- return {
276
- event: {
277
- header: {
278
- namespace: 'Alexa.Authorization',
279
- name: 'AcceptGrant.Response',
280
- messageId: header.messageId,
281
- payloadVersion: '3',
282
- },
283
- payload: {},
284
- },
285
- }
428
+ /** HTTP statuses that mean the hub itself is down or unreachable behind its tunnel. */
429
+ function isBridgeDownStatus(status) {
430
+ return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530)
286
431
  }
287
432
 
288
- async function forwardDirective(event) {
289
- const header = event.directive.header
290
- const bearer = extractBearer(event.directive)
433
+ async function forwardDirective(event, deps) {
434
+ const directive = event.directive
435
+ const header = directive.header
436
+ const endpoint = directive.endpoint
437
+ const bearer = extractBearer(directive)
291
438
  if (!bearer) {
292
439
  return errorEnvelope(
293
440
  header,
294
441
  'INVALID_AUTHORIZATION_CREDENTIAL',
295
442
  'directive has no bearer token',
443
+ endpoint,
296
444
  )
297
445
  }
298
446
  const claims = decodeJwtPayload(bearer)
299
447
  if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
300
- return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'bearer has no hubUrl claim')
448
+ return errorEnvelope(
449
+ header,
450
+ 'INVALID_AUTHORIZATION_CREDENTIAL',
451
+ 'bearer has no hubUrl claim',
452
+ endpoint,
453
+ )
301
454
  }
302
455
  const target = `${normaliseHubUrl(claims.hubUrl)}/addon/export-alexa/directive`
303
- const directiveHeaders = {
304
- 'content-type': 'application/json',
305
- authorization: `Bearer ${bearer}`,
306
- // Defense-in-depth shared secret — the export-alexa "Ingress token"
307
- // setting. Sent only when configured; the hub enforces it when the
308
- // operator turns on "Require ingress token".
309
- ...(process.env.HUB_INGRESS_TOKEN
310
- ? { 'x-camstack-ingress': process.env.HUB_INGRESS_TOKEN }
311
- : {}),
456
+ let resp
457
+ try {
458
+ resp = await deps.fetch(target, {
459
+ method: 'POST',
460
+ headers: {
461
+ ...hubHeaders(deps, bearer),
462
+ // Where this skill's broker lives. A hub linked before D753 uses it to
463
+ // migrate its refresh token here (zero-touch); the hub only believes it
464
+ // on a directive whose bearer it verified.
465
+ ...(brokerAvailable(deps) ? { [BROKER_HEADER]: deps.publicUrl } : {}),
466
+ },
467
+ body: JSON.stringify(event),
468
+ })
469
+ } catch (err) {
470
+ // The hub is the bridge every endpoint sits behind (alexa-errorresponse).
471
+ return errorEnvelope(
472
+ header,
473
+ 'BRIDGE_UNREACHABLE',
474
+ `hub unreachable: ${describe(err)}`,
475
+ endpoint,
476
+ )
312
477
  }
313
- const resp = await timedFetch(target, {
314
- method: 'POST',
315
- headers: directiveHeaders,
316
- body: JSON.stringify(event),
317
- })
318
478
  const text = await resp.text()
479
+ let parsed
319
480
  try {
320
- return JSON.parse(text)
481
+ parsed = JSON.parse(text)
321
482
  } catch {
322
- return errorEnvelope(header, 'INTERNAL_ERROR', `hub returned non-JSON: ${text.slice(0, 256)}`)
483
+ const type = isBridgeDownStatus(resp.status) ? 'BRIDGE_UNREACHABLE' : 'INTERNAL_ERROR'
484
+ return errorEnvelope(header, type, `hub returned ${resp.status}, non-JSON`, endpoint)
323
485
  }
486
+ if (!parsed || typeof parsed !== 'object' || !parsed.event || !parsed.event.header) {
487
+ // e.g. `{ error: 'invalid_ingress_token' }` — never hand Alexa a non-event.
488
+ return errorEnvelope(
489
+ header,
490
+ 'INTERNAL_ERROR',
491
+ `hub returned ${resp.status} without an Alexa event`,
492
+ endpoint,
493
+ )
494
+ }
495
+ return completeErrorResponse(parsed, directive)
324
496
  }
325
497
 
326
498
  function extractBearer(directive) {
@@ -341,227 +513,6 @@ function extractBearer(directive) {
341
513
  return null
342
514
  }
343
515
 
344
- // ── Helpers ──────────────────────────────────────────────────────────
345
-
346
- function decodeJwtPayload(token) {
347
- if (typeof token !== 'string') return null
348
- const parts = token.split('.')
349
- if (parts.length < 2) return null
350
- try {
351
- const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/')
352
- const json = Buffer.from(padded, 'base64').toString('utf8')
353
- return JSON.parse(json)
354
- } catch {
355
- return null
356
- }
357
- }
358
-
359
- function normaliseHubUrl(raw) {
360
- return raw.replace(/\/+$/, '')
361
- }
362
-
363
- function decodeBody(event) {
364
- if (!event.body) return ''
365
- if (event.isBase64Encoded) {
366
- return Buffer.from(event.body, 'base64').toString('utf8')
367
- }
368
- return event.body
369
- }
370
-
371
- function parseFormUrlEncoded(body) {
372
- const out = {}
373
- if (!body) return out
374
- for (const pair of body.split('&')) {
375
- if (!pair) continue
376
- const eq = pair.indexOf('=')
377
- const key = decodeURIComponent(eq >= 0 ? pair.slice(0, eq) : pair).replace(/\+/g, ' ')
378
- const value = decodeURIComponent(eq >= 0 ? pair.slice(eq + 1) : '').replace(/\+/g, ' ')
379
- out[key] = value
380
- }
381
- return out
382
- }
383
-
384
- function encodeFormUrlEncoded(obj) {
385
- return Object.keys(obj)
386
- .filter((k) => obj[k] !== undefined && obj[k] !== null)
387
- .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(obj[k]))}`)
388
- .join('&')
389
- }
390
-
391
- async function timedFetch(url, init = {}) {
392
- // Returns a fetch-compatible response object: { ok, status, statusText,
393
- // text(), json() }. The handler only uses .text() / status.
394
- //
395
- // Emits a single structured timing log line on completion (or failure),
396
- // measuring DNS / TCP / TLS / request-sent / first-byte / last-byte sub-
397
- // timings. Read these from CloudWatch with:
398
- // filter @message like /timedFetch_timing/
399
- // to see end-to-end where the wall-clock seconds go (DNS hang vs TCP RTT
400
- // vs TLS handshake vs hub processing time vs body download).
401
- return new Promise((resolve, reject) => {
402
- let settled = false
403
- const parsed = new URL(url)
404
- const isHttps = parsed.protocol === 'https:'
405
- const lib = isHttps ? https : http
406
- const headers = { ...(init.headers || {}), connection: 'close' }
407
- if (init.body !== undefined && headers['content-length'] === undefined) {
408
- headers['content-length'] = Buffer.byteLength(init.body)
409
- }
410
- const T0 = process.hrtime.bigint()
411
- const marks = {
412
- dns: null,
413
- tcpConnect: null,
414
- tlsHandshake: null,
415
- requestSent: null,
416
- firstByte: null,
417
- lastByte: null,
418
- }
419
- const ms = (a, b) => Number((b - a) / 1_000_000n)
420
- const logTiming = (status, extras = {}) => {
421
- const T_end = process.hrtime.bigint()
422
- const total = ms(T0, T_end)
423
- const stages = {
424
- dns: marks.dns ? ms(T0, marks.dns) : null,
425
- tcp: marks.tcpConnect ? ms(T0, marks.tcpConnect) : null,
426
- tls: marks.tlsHandshake ? ms(T0, marks.tlsHandshake) : null,
427
- reqSent: marks.requestSent ? ms(T0, marks.requestSent) : null,
428
- firstByte: marks.firstByte ? ms(T0, marks.firstByte) : null,
429
- lastByte: marks.lastByte ? ms(T0, marks.lastByte) : null,
430
- }
431
- // Stage durations between marks, easier to read at a glance:
432
- const between = {
433
- dns_ms: stages.dns,
434
- tcp_handshake_ms:
435
- stages.tcp != null && stages.dns != null ? stages.tcp - stages.dns : stages.tcp,
436
- tls_handshake_ms: stages.tls != null && stages.tcp != null ? stages.tls - stages.tcp : null,
437
- request_send_ms:
438
- stages.reqSent != null && (stages.tls ?? stages.tcp) != null
439
- ? stages.reqSent - (stages.tls ?? stages.tcp)
440
- : null,
441
- wait_first_byte_ms:
442
- stages.firstByte != null && stages.reqSent != null
443
- ? stages.firstByte - stages.reqSent
444
- : null,
445
- download_ms:
446
- stages.lastByte != null && stages.firstByte != null
447
- ? stages.lastByte - stages.firstByte
448
- : null,
449
- }
450
- console.log(
451
- 'timedFetch_timing',
452
- JSON.stringify({
453
- url: `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`,
454
- method: (init.method || 'GET').toUpperCase(),
455
- status,
456
- total_ms: total,
457
- stages_cum_ms: stages,
458
- between_ms: between,
459
- ...extras,
460
- }),
461
- )
462
- }
463
- const req = lib.request(
464
- {
465
- protocol: parsed.protocol,
466
- hostname: parsed.hostname,
467
- port: parsed.port || (isHttps ? 443 : 80),
468
- path: `${parsed.pathname}${parsed.search}`,
469
- method: (init.method || 'GET').toUpperCase(),
470
- headers,
471
- agent: isHttps ? NO_KEEPALIVE_AGENT : undefined,
472
- },
473
- (res) => {
474
- marks.firstByte = process.hrtime.bigint()
475
- const chunks = []
476
- res.on('data', (c) => chunks.push(c))
477
- res.on('end', () => {
478
- if (settled) return
479
- settled = true
480
- clearTimeout(timer)
481
- marks.lastByte = process.hrtime.bigint()
482
- const body = Buffer.concat(chunks).toString('utf8')
483
- const headersApi = {
484
- get: (name) => {
485
- const v = res.headers[String(name).toLowerCase()]
486
- if (Array.isArray(v)) return v.join(', ')
487
- return v == null ? null : String(v)
488
- },
489
- }
490
- logTiming(res.statusCode, { responseBodyBytes: body.length })
491
- resolve({
492
- ok: res.statusCode >= 200 && res.statusCode < 300,
493
- status: res.statusCode,
494
- statusText: res.statusMessage || '',
495
- headers: headersApi,
496
- text: async () => body,
497
- json: async () => JSON.parse(body),
498
- })
499
- })
500
- res.on('error', (err) => {
501
- if (settled) return
502
- settled = true
503
- clearTimeout(timer)
504
- logTiming('res_error', { error: err && err.message })
505
- reject(err)
506
- })
507
- },
508
- )
509
- req.on('socket', (socket) => {
510
- socket.on('lookup', () => {
511
- marks.dns = process.hrtime.bigint()
512
- })
513
- socket.on('connect', () => {
514
- marks.tcpConnect = process.hrtime.bigint()
515
- })
516
- socket.on('secureConnect', () => {
517
- marks.tlsHandshake = process.hrtime.bigint()
518
- })
519
- })
520
- req.on('finish', () => {
521
- marks.requestSent = process.hrtime.bigint()
522
- })
523
- req.on('error', (err) => {
524
- if (settled) return
525
- settled = true
526
- clearTimeout(timer)
527
- logTiming('req_error', { error: err && err.message, code: err && err.code })
528
- reject(err)
529
- })
530
- const timer = setTimeout(() => {
531
- if (settled) return
532
- settled = true
533
- req.destroy(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
534
- logTiming('timeout', { timeout_ms: FETCH_TIMEOUT_MS })
535
- reject(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
536
- }, FETCH_TIMEOUT_MS)
537
- if (init.body !== undefined) req.write(init.body)
538
- req.end()
539
- })
540
- }
541
-
542
- function jsonResponse(statusCode, body) {
543
- return {
544
- statusCode,
545
- headers: { 'content-type': 'application/json' },
546
- body: JSON.stringify(body),
547
- }
548
- }
549
-
550
- function errorEnvelope(header, type, message) {
551
- return {
552
- event: {
553
- header: {
554
- namespace: 'Alexa',
555
- name: 'ErrorResponse',
556
- messageId: header.messageId,
557
- payloadVersion: '3',
558
- },
559
- payload: { type, message },
560
- },
561
- }
562
- }
563
-
564
- function describe(err) {
565
- if (err instanceof Error) return err.message
566
- return String(err)
567
- }
516
+ exports.handler = createHandler()
517
+ exports.createHandler = createHandler
518
+ exports.BROKER_HEADER = BROKER_HEADER