@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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `timedFetch` — the Lambda's only outbound HTTP primitive.
3
+ *
4
+ * Uses https.request with a no-keep-alive Agent. Node's built-in fetch
5
+ * (undici-backed) reuses pooled sockets aggressively; in AWS Lambda the
6
+ * invocation-to-invocation gaps can leave pooled sockets stale, and the next
7
+ * fetch throws DOMException AbortError in <1s. https.request gives us explicit
8
+ * control.
9
+ *
10
+ * NEVER logs request or response bodies, headers or query strings: they carry
11
+ * OAuth codes, LWA tokens and broker credentials. The timing line records only
12
+ * scheme, host, path, status and sub-timings.
13
+ */
14
+ 'use strict'
15
+
16
+ const https = require('node:https')
17
+ const http = require('node:http')
18
+ const { URL } = require('node:url')
19
+
20
+ const FETCH_TIMEOUT_MS = 6000
21
+ const NO_KEEPALIVE_AGENT = new https.Agent({ keepAlive: false })
22
+
23
+ async function timedFetch(url, init = {}) {
24
+ // Returns a fetch-compatible response object: { ok, status, statusText,
25
+ // text(), json() }. The handler only uses .text() / status.
26
+ //
27
+ // Emits a single structured timing log line on completion (or failure),
28
+ // measuring DNS / TCP / TLS / request-sent / first-byte / last-byte sub-
29
+ // timings. Read these from CloudWatch with:
30
+ // filter @message like /timedFetch_timing/
31
+ // to see end-to-end where the wall-clock seconds go (DNS hang vs TCP RTT
32
+ // vs TLS handshake vs hub processing time vs body download).
33
+ return new Promise((resolve, reject) => {
34
+ let settled = false
35
+ const parsed = new URL(url)
36
+ const isHttps = parsed.protocol === 'https:'
37
+ const lib = isHttps ? https : http
38
+ const headers = { ...(init.headers || {}), connection: 'close' }
39
+ if (init.body !== undefined && headers['content-length'] === undefined) {
40
+ headers['content-length'] = Buffer.byteLength(init.body)
41
+ }
42
+ const T0 = process.hrtime.bigint()
43
+ const marks = {
44
+ dns: null,
45
+ tcpConnect: null,
46
+ tlsHandshake: null,
47
+ requestSent: null,
48
+ firstByte: null,
49
+ lastByte: null,
50
+ }
51
+ const ms = (a, b) => Number((b - a) / 1_000_000n)
52
+ const logTiming = (status, extras = {}) => {
53
+ const T_end = process.hrtime.bigint()
54
+ const total = ms(T0, T_end)
55
+ const stages = {
56
+ dns: marks.dns ? ms(T0, marks.dns) : null,
57
+ tcp: marks.tcpConnect ? ms(T0, marks.tcpConnect) : null,
58
+ tls: marks.tlsHandshake ? ms(T0, marks.tlsHandshake) : null,
59
+ reqSent: marks.requestSent ? ms(T0, marks.requestSent) : null,
60
+ firstByte: marks.firstByte ? ms(T0, marks.firstByte) : null,
61
+ lastByte: marks.lastByte ? ms(T0, marks.lastByte) : null,
62
+ }
63
+ // Stage durations between marks, easier to read at a glance:
64
+ const between = {
65
+ dns_ms: stages.dns,
66
+ tcp_handshake_ms:
67
+ stages.tcp != null && stages.dns != null ? stages.tcp - stages.dns : stages.tcp,
68
+ tls_handshake_ms: stages.tls != null && stages.tcp != null ? stages.tls - stages.tcp : null,
69
+ request_send_ms:
70
+ stages.reqSent != null && (stages.tls ?? stages.tcp) != null
71
+ ? stages.reqSent - (stages.tls ?? stages.tcp)
72
+ : null,
73
+ wait_first_byte_ms:
74
+ stages.firstByte != null && stages.reqSent != null
75
+ ? stages.firstByte - stages.reqSent
76
+ : null,
77
+ download_ms:
78
+ stages.lastByte != null && stages.firstByte != null
79
+ ? stages.lastByte - stages.firstByte
80
+ : null,
81
+ }
82
+ console.log(
83
+ 'timedFetch_timing',
84
+ JSON.stringify({
85
+ url: `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`,
86
+ method: (init.method || 'GET').toUpperCase(),
87
+ status,
88
+ total_ms: total,
89
+ stages_cum_ms: stages,
90
+ between_ms: between,
91
+ ...extras,
92
+ }),
93
+ )
94
+ }
95
+ const req = lib.request(
96
+ {
97
+ protocol: parsed.protocol,
98
+ hostname: parsed.hostname,
99
+ port: parsed.port || (isHttps ? 443 : 80),
100
+ path: `${parsed.pathname}${parsed.search}`,
101
+ method: (init.method || 'GET').toUpperCase(),
102
+ headers,
103
+ agent: isHttps ? NO_KEEPALIVE_AGENT : undefined,
104
+ },
105
+ (res) => {
106
+ marks.firstByte = process.hrtime.bigint()
107
+ const chunks = []
108
+ res.on('data', (c) => chunks.push(c))
109
+ res.on('end', () => {
110
+ if (settled) return
111
+ settled = true
112
+ clearTimeout(timer)
113
+ marks.lastByte = process.hrtime.bigint()
114
+ const body = Buffer.concat(chunks).toString('utf8')
115
+ const headersApi = {
116
+ get: (name) => {
117
+ const v = res.headers[String(name).toLowerCase()]
118
+ if (Array.isArray(v)) return v.join(', ')
119
+ return v == null ? null : String(v)
120
+ },
121
+ }
122
+ logTiming(res.statusCode, { responseBodyBytes: body.length })
123
+ resolve({
124
+ ok: res.statusCode >= 200 && res.statusCode < 300,
125
+ status: res.statusCode,
126
+ statusText: res.statusMessage || '',
127
+ headers: headersApi,
128
+ text: async () => body,
129
+ json: async () => JSON.parse(body),
130
+ })
131
+ })
132
+ res.on('error', (err) => {
133
+ if (settled) return
134
+ settled = true
135
+ clearTimeout(timer)
136
+ logTiming('res_error', { error: err && err.message })
137
+ reject(err)
138
+ })
139
+ },
140
+ )
141
+ req.on('socket', (socket) => {
142
+ socket.on('lookup', () => {
143
+ marks.dns = process.hrtime.bigint()
144
+ })
145
+ socket.on('connect', () => {
146
+ marks.tcpConnect = process.hrtime.bigint()
147
+ })
148
+ socket.on('secureConnect', () => {
149
+ marks.tlsHandshake = process.hrtime.bigint()
150
+ })
151
+ })
152
+ req.on('finish', () => {
153
+ marks.requestSent = process.hrtime.bigint()
154
+ })
155
+ req.on('error', (err) => {
156
+ if (settled) return
157
+ settled = true
158
+ clearTimeout(timer)
159
+ logTiming('req_error', { error: err && err.message, code: err && err.code })
160
+ reject(err)
161
+ })
162
+ const timer = setTimeout(() => {
163
+ if (settled) return
164
+ settled = true
165
+ req.destroy(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
166
+ logTiming('timeout', { timeout_ms: FETCH_TIMEOUT_MS })
167
+ reject(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
168
+ }, FETCH_TIMEOUT_MS)
169
+ if (init.body !== undefined) req.write(init.body)
170
+ req.end()
171
+ })
172
+ }
173
+
174
+ module.exports = { timedFetch, FETCH_TIMEOUT_MS }
@@ -0,0 +1,454 @@
1
+ /**
2
+ * CamStack Alexa Smart Home Lambda — one skill, many hubs, and the skill's
3
+ * token broker (D753).
4
+ *
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).
11
+ *
12
+ * Invocation shapes:
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
20
+ *
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.
36
+ *
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.
39
+ */
40
+ 'use strict'
41
+
42
+ const crypto = require('node:crypto')
43
+
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
+ decodeJwtPayload,
59
+ describe,
60
+ encodeFormUrlEncoded,
61
+ errorEnvelope,
62
+ jsonResponse,
63
+ normaliseHubUrl,
64
+ parseFormUrlEncoded,
65
+ resolveBrokerPublicUrl,
66
+ } = require('./util.js')
67
+
68
+ /** The header that tells a hub where its skill's broker lives (D753). */
69
+ const BROKER_HEADER = 'x-camstack-broker'
70
+
71
+ /**
72
+ * Build the dependency set once per container — the secret cache and the SDK
73
+ * clients must survive across invocations. Anything passed in wins (tests).
74
+ */
75
+ function resolveDeps(overrides) {
76
+ const o = overrides || {}
77
+ const env = o.env || process.env
78
+ let aws = null
79
+ const awsClients = () => {
80
+ if (!aws) aws = createAwsClients()
81
+ return aws
82
+ }
83
+ const store =
84
+ o.store !== undefined
85
+ ? o.store
86
+ : env.TOKENS_TABLE
87
+ ? createDynamoTokenStore({
88
+ send: (name, input) => awsClients().send(name, input),
89
+ tableName: env.TOKENS_TABLE,
90
+ })
91
+ : null
92
+ return {
93
+ env,
94
+ fetch: o.fetch || timedFetch,
95
+ now: o.now || Date.now,
96
+ randomBytes: o.randomBytes || crypto.randomBytes,
97
+ log: o.log || console,
98
+ secrets:
99
+ o.secrets ||
100
+ createSecretSource({
101
+ env,
102
+ fetchSecretString: (id) => awsClients().fetchSecretString(id),
103
+ }),
104
+ store,
105
+ publicUrl: resolveBrokerPublicUrl(env),
106
+ }
107
+ }
108
+
109
+ function createHandler(overrides) {
110
+ let deps = null
111
+ return async (event) => {
112
+ if (!deps) deps = resolveDeps(overrides)
113
+ try {
114
+ if (event && event.requestContext && event.requestContext.http) {
115
+ return await handleLambdaUrl(event, deps)
116
+ }
117
+ if (event && event.directive && event.directive.header) {
118
+ return await handleDirective(event, deps)
119
+ }
120
+ return jsonResponse(400, { error: 'unknown_event' })
121
+ } catch (err) {
122
+ deps.log.error('camstack-alexa: unhandled error', describe(err))
123
+ if (event && event.directive && event.directive.header) {
124
+ return errorEnvelope(event.directive.header, 'INTERNAL_ERROR', describe(err))
125
+ }
126
+ return jsonResponse(500, { error: 'internal_error', message: describe(err) })
127
+ }
128
+ }
129
+ }
130
+
131
+ // ── Lambda Function URL (HTTP) routes ────────────────────────────────
132
+
133
+ async function handleLambdaUrl(event, deps) {
134
+ const path = event.rawPath || '/'
135
+ if (path === '/authorize') return handleAuthorizePage(event)
136
+ if (path === '/token') return handleTokenProxy(event, deps)
137
+ if (isBrokerPath(path)) return handleBrokerRoute(event, deps)
138
+ return { statusCode: 404, headers: { 'content-type': 'text/plain' }, body: 'Not found' }
139
+ }
140
+
141
+ /**
142
+ * Render the "Enter your CamStack hub URL" form. Submitting the form
143
+ * forwards the original Alexa query params (response_type, client_id,
144
+ * redirect_uri, state, scope) — plus `integration=export-alexa` — to
145
+ * `${hubUrl}/api/oauth2/authorize`. From there the browser stays on the hub's
146
+ * origin until the OAuth code round-trips back to Alexa.
147
+ */
148
+ function handleAuthorizePage(event) {
149
+ const qs = event.rawQueryString || ''
150
+ const html = `<!doctype html>
151
+ <html lang="en">
152
+ <head>
153
+ <meta charset="utf-8">
154
+ <title>CamStack · Link your hub</title>
155
+ <meta name="viewport" content="width=device-width, initial-scale=1">
156
+ <style>
157
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 480px; margin: 4rem auto; padding: 0 1rem; color: #1f2937; background: #f9fafb; }
158
+ .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 0.75rem; padding: 2rem; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
159
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
160
+ p { font-size: 0.9rem; color: #4b5563; margin: 0 0 1.5rem; }
161
+ label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.5rem; }
162
+ input { width: 100%; padding: 0.6rem 0.75rem; font-size: 0.95rem; border: 1px solid #d1d5db; border-radius: 0.375rem; box-sizing: border-box; font-family: ui-monospace, Menlo, monospace; }
163
+ input:focus { outline: 2px solid #2563eb; outline-offset: 1px; border-color: #2563eb; }
164
+ 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; }
165
+ button:hover { background: #1d4ed8; }
166
+ .hint { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; }
167
+ </style>
168
+ </head>
169
+ <body>
170
+ <div class="card">
171
+ <h1>Link CamStack to Alexa</h1>
172
+ <p>Enter the public HTTPS URL of your CamStack hub. Alexa will then ask the hub to authorize this Echo.</p>
173
+ <form id="f">
174
+ <label for="hubUrl">Hub URL</label>
175
+ <input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
176
+ <p class="hint">Must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, etc.).</p>
177
+ <button type="submit">Continue</button>
178
+ </form>
179
+ </div>
180
+ <script>
181
+ document.getElementById('f').addEventListener('submit', function (ev) {
182
+ ev.preventDefault()
183
+ var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/$/, '')
184
+ if (!hubUrl) return
185
+ var qs = ${JSON.stringify(qs)}
186
+ var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
187
+ window.location.href = url
188
+ })
189
+ </script>
190
+ </body>
191
+ </html>`
192
+ return {
193
+ statusCode: 200,
194
+ headers: { 'content-type': 'text/html; charset=utf-8' },
195
+ body: html,
196
+ }
197
+ }
198
+
199
+ /** Constant-time string equality that does not leak length via timing. */
200
+ function safeEqual(a, b) {
201
+ const ab = Buffer.from(String(a), 'utf8')
202
+ const bb = Buffer.from(String(b), 'utf8')
203
+ if (ab.length !== bb.length) {
204
+ crypto.timingSafeEqual(ab, ab)
205
+ return false
206
+ }
207
+ return crypto.timingSafeEqual(ab, bb)
208
+ }
209
+
210
+ /**
211
+ * Proxy the OAuth token exchange. Alexa POSTs a form-urlencoded body with the
212
+ * account-linking client_id/client_secret; we verify those (current secret, or
213
+ * `previousClientSecret` mid-rotation), decode the code/refresh_token JWT
214
+ * payload to find the hub URL claim, then forward the body to the hub's own
215
+ * /api/oauth2/token endpoint.
216
+ */
217
+ async function handleTokenProxy(event, deps) {
218
+ if (event.requestContext.http.method !== 'POST') {
219
+ return jsonResponse(405, { error: 'method_not_allowed' })
220
+ }
221
+ const body = parseFormUrlEncoded(decodeBody(event))
222
+ const skill = await deps.secrets.skill()
223
+ if (!skill) {
224
+ return jsonResponse(500, {
225
+ error: 'unconfigured',
226
+ message: 'account-linking client credentials are not configured',
227
+ })
228
+ }
229
+ const secretOk =
230
+ typeof body.client_secret === 'string' &&
231
+ (safeEqual(body.client_secret, skill.clientSecret) ||
232
+ (skill.previousClientSecret !== undefined &&
233
+ safeEqual(body.client_secret, skill.previousClientSecret)))
234
+ if (body.client_id !== skill.clientId || !secretOk) {
235
+ return jsonResponse(401, { error: 'invalid_client', message: 'client credentials mismatch' })
236
+ }
237
+
238
+ const tokenInput = body.grant_type === 'refresh_token' ? body.refresh_token : body.code
239
+ if (!tokenInput) {
240
+ return jsonResponse(400, {
241
+ error: 'invalid_request',
242
+ message: 'code or refresh_token is required',
243
+ })
244
+ }
245
+ const claims = decodeJwtPayload(tokenInput)
246
+ if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
247
+ return jsonResponse(400, {
248
+ error: 'invalid_grant',
249
+ message: 'token has no hubUrl claim — was it minted by a hub that supports cloud-mode?',
250
+ })
251
+ }
252
+
253
+ const target = `${normaliseHubUrl(claims.hubUrl)}/api/oauth2/token`
254
+ const upstream = await deps.fetch(target, {
255
+ method: 'POST',
256
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
257
+ body: encodeFormUrlEncoded(body),
258
+ })
259
+ const text = await upstream.text()
260
+ return {
261
+ statusCode: upstream.status,
262
+ headers: { 'content-type': upstream.headers.get('content-type') || 'application/json' },
263
+ body: text,
264
+ }
265
+ }
266
+
267
+ // ── Smart Home directives ────────────────────────────────────────────
268
+
269
+ async function handleDirective(event, deps) {
270
+ const header = event.directive.header
271
+ const ns = `${header.namespace}/${header.name}`
272
+ if (ns === 'Alexa.Authorization/AcceptGrant') {
273
+ return handleAcceptGrant(event, deps)
274
+ }
275
+ return forwardDirective(event, deps)
276
+ }
277
+
278
+ function hubHeaders(deps, bearer) {
279
+ return {
280
+ 'content-type': 'application/json',
281
+ authorization: `Bearer ${bearer}`,
282
+ // Defense-in-depth shared secret — the export-alexa "Ingress token"
283
+ // setting. Sent only when configured; the hub enforces it when the
284
+ // operator turns on "Require ingress token".
285
+ ...(deps.env.HUB_INGRESS_TOKEN ? { 'x-camstack-ingress': deps.env.HUB_INGRESS_TOKEN } : {}),
286
+ }
287
+ }
288
+
289
+ function brokerAvailable(deps) {
290
+ return deps.store !== null && typeof deps.publicUrl === 'string'
291
+ }
292
+
293
+ function acceptGrantResponse(header) {
294
+ return {
295
+ event: {
296
+ header: {
297
+ namespace: 'Alexa.Authorization',
298
+ name: 'AcceptGrant.Response',
299
+ messageId: header.messageId,
300
+ payloadVersion: '3',
301
+ },
302
+ payload: {},
303
+ },
304
+ }
305
+ }
306
+
307
+ async function handleAcceptGrant(event, deps) {
308
+ const header = event.directive.header
309
+ const payload = event.directive.payload || {}
310
+ const grantCode = payload.grant && payload.grant.code
311
+ const granteeToken = payload.grantee && payload.grantee.token
312
+ if (!grantCode || !granteeToken) {
313
+ return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'payload missing grant.code or grantee.token')
314
+ }
315
+ const claims = decodeJwtPayload(granteeToken)
316
+ if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
317
+ return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'grantee token has no hubUrl claim')
318
+ }
319
+ const redirectUri = deps.env.LAMBDA_REDIRECT_URI
320
+ if (!redirectUri) {
321
+ return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'LAMBDA_REDIRECT_URI not set on the Lambda')
322
+ }
323
+
324
+ let tokens
325
+ try {
326
+ tokens = await exchangeGrantCode(deps, grantCode, redirectUri)
327
+ } catch (err) {
328
+ return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', describe(err))
329
+ }
330
+ const hubUrl = normaliseHubUrl(claims.hubUrl)
331
+
332
+ if (brokerAvailable(deps)) {
333
+ const outcome = await linkViaBroker(deps, { hubUrl, granteeToken, tokens })
334
+ if (outcome.kind === 'linked') return acceptGrantResponse(header)
335
+ if (outcome.kind !== 'hub-without-broker' || deps.env.LEGACY_HUB_FALLBACK === '0') {
336
+ return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', outcome.message)
337
+ }
338
+ deps.log.warn('accept_grant: hub has no broker route; falling back to the legacy token hand-off')
339
+ }
340
+ return deliverTokensToLegacyHub(deps, header, { hubUrl, granteeToken, tokens })
341
+ }
342
+
343
+ /**
344
+ * The D753 link: store the grant here, give the hub only a credential and a
345
+ * first access token. The item stays `pending` until the hub accepted it, so a
346
+ * hub that refuses (or does not have the route) leaves nothing behind.
347
+ */
348
+ async function linkViaBroker(deps, input) {
349
+ const { item, credential } = await createLink(deps, {
350
+ status: 'pending',
351
+ tokens: input.tokens,
352
+ hubUrl: input.hubUrl,
353
+ origin: 'accept-grant',
354
+ })
355
+ let resp
356
+ try {
357
+ resp = await deps.fetch(`${input.hubUrl}/addon/export-alexa/oauth/broker-link`, {
358
+ method: 'POST',
359
+ headers: hubHeaders(deps, input.granteeToken),
360
+ body: JSON.stringify({
361
+ brokerUrl: deps.publicUrl,
362
+ brokerCredential: credential,
363
+ lwaAccessToken: item.accessToken,
364
+ expiresAt: item.accessExpiresAt,
365
+ }),
366
+ })
367
+ } catch (err) {
368
+ await deps.store.remove(item.linkId)
369
+ return { kind: 'failed', message: `hub broker-link unreachable: ${describe(err)}` }
370
+ }
371
+ if (resp.ok) {
372
+ await activateLink(deps, item)
373
+ deps.log.info('accept_grant: linked via broker', { link: shortId(item.linkId) })
374
+ return { kind: 'linked' }
375
+ }
376
+ await deps.store.remove(item.linkId)
377
+ if (resp.status === 404) return { kind: 'hub-without-broker', message: 'hub broker-link 404' }
378
+ const text = await resp.text().catch(() => '')
379
+ return { kind: 'failed', message: `hub broker-link ${resp.status}: ${text.slice(0, 200)}` }
380
+ }
381
+
382
+ /** Pre-D753 hand-off: the hub receives the refresh token itself. */
383
+ async function deliverTokensToLegacyHub(deps, header, input) {
384
+ const callbackResp = await deps.fetch(`${input.hubUrl}/addon/export-alexa/oauth/lwa-tokens`, {
385
+ method: 'POST',
386
+ headers: hubHeaders(deps, input.granteeToken),
387
+ body: JSON.stringify({
388
+ lwaAccessToken: input.tokens.accessToken,
389
+ lwaRefreshToken: input.tokens.refreshToken,
390
+ expiresAt: deps.now() + input.tokens.expiresInSec * 1000,
391
+ }),
392
+ })
393
+ if (!callbackResp.ok) {
394
+ const text = await callbackResp.text().catch(() => '')
395
+ return errorEnvelope(
396
+ header,
397
+ 'ACCEPT_GRANT_FAILED',
398
+ `hub callback ${callbackResp.status}: ${text.slice(0, 200)}`,
399
+ )
400
+ }
401
+ return acceptGrantResponse(header)
402
+ }
403
+
404
+ async function forwardDirective(event, deps) {
405
+ const header = event.directive.header
406
+ const bearer = extractBearer(event.directive)
407
+ if (!bearer) {
408
+ return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'directive has no bearer token')
409
+ }
410
+ const claims = decodeJwtPayload(bearer)
411
+ if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
412
+ return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'bearer has no hubUrl claim')
413
+ }
414
+ const target = `${normaliseHubUrl(claims.hubUrl)}/addon/export-alexa/directive`
415
+ const resp = await deps.fetch(target, {
416
+ method: 'POST',
417
+ headers: {
418
+ ...hubHeaders(deps, bearer),
419
+ // Where this skill's broker lives. A hub linked before D753 uses it to
420
+ // migrate its refresh token here (zero-touch); the hub only believes it
421
+ // on a directive whose bearer it verified.
422
+ ...(brokerAvailable(deps) ? { [BROKER_HEADER]: deps.publicUrl } : {}),
423
+ },
424
+ body: JSON.stringify(event),
425
+ })
426
+ const text = await resp.text()
427
+ try {
428
+ return JSON.parse(text)
429
+ } catch {
430
+ return errorEnvelope(header, 'INTERNAL_ERROR', `hub returned non-JSON: ${text.slice(0, 256)}`)
431
+ }
432
+ }
433
+
434
+ function extractBearer(directive) {
435
+ if (
436
+ directive.endpoint &&
437
+ directive.endpoint.scope &&
438
+ typeof directive.endpoint.scope.token === 'string'
439
+ ) {
440
+ return directive.endpoint.scope.token
441
+ }
442
+ if (
443
+ directive.payload &&
444
+ directive.payload.scope &&
445
+ typeof directive.payload.scope.token === 'string'
446
+ ) {
447
+ return directive.payload.scope.token
448
+ }
449
+ return null
450
+ }
451
+
452
+ exports.handler = createHandler()
453
+ exports.createHandler = createHandler
454
+ exports.BROKER_HEADER = BROKER_HEADER