@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,171 @@
1
+ # CamStack Alexa Lambda — skill endpoint + token broker
2
+
3
+ One Lambda serves one Alexa Smart Home skill and every CamStack hub linked to
4
+ it. It routes each request to the right hub by the `hubUrl` claim the hub bakes
5
+ into its OAuth tokens, and — since D753 — it is the **only** place the skill's
6
+ Login With Amazon (LWA) client secret lives. Hubs never hold it: they receive
7
+ short-lived Event Gateway tokens from this Lambda's **token broker**.
8
+
9
+ The same code runs the built-in CamStack skill and any operator's own
10
+ ("custom") skill. Each deployment uses its own secrets.
11
+
12
+ ## What it does
13
+
14
+ | Invocation | Action |
15
+ | --- | --- |
16
+ | `GET <FUNCTION_URL>/authorize` | HTML form asking for the hub URL, then redirects to the hub's OAuth `/authorize`. |
17
+ | `POST <FUNCTION_URL>/token` | Checks the account-linking client id/secret, decodes the `hubUrl` claim, proxies the token request to the hub. |
18
+ | Smart Home directive | Forwards to `${hubUrl}/addon/export-alexa/directive`, adding `X-CamStack-Broker: <broker origin>` when the broker is on. |
19
+ | `Alexa.Authorization/AcceptGrant` | Exchanges the grant code at `api.amazon.com` **here**, stores the grant in DynamoDB, gives the hub a per-link broker credential (`POST /addon/export-alexa/oauth/broker-link`). |
20
+ | `POST <FUNCTION_URL>/broker/token` | `Bearer <credential>` → `{ accessToken, expiresAt }` (refreshed here when < 5 min left). |
21
+ | `POST <FUNCTION_URL>/broker/migrate` | `{ refreshToken }` from a hub linked before D753 → a broker link. |
22
+ | `POST <FUNCTION_URL>/broker/rotate` | `Bearer <credential>` → `{ brokerCredential }`; the old one stops working. |
23
+ | `POST <FUNCTION_URL>/broker/revoke` | `Bearer <credential>` → deletes the link (and the refresh token). |
24
+
25
+ ### The two credential pairs
26
+
27
+ | Pair | Secrets Manager id | Authenticates | Who else holds it |
28
+ | --- | --- | --- | --- |
29
+ | **LWA "Send Alexa Events"** client (skill console → Permissions → Alexa Skill Messaging) | `camstack/alexa/lwa` | this Lambda → `api.amazon.com` (code exchange, refresh) | nobody |
30
+ | **Account-linking** client (skill console → Account Linking) | `camstack/alexa/skill` | Amazon → this Lambda's `/token` | Amazon |
31
+
32
+ Each secret is a JSON string:
33
+
34
+ ```json
35
+ { "clientId": "…", "clientSecret": "…" }
36
+ ```
37
+
38
+ `camstack/alexa/skill` may also carry `"previousClientSecret"` — accepted on
39
+ `/token` while you rotate it (change the value in the skill console, then remove
40
+ the previous one).
41
+
42
+ ### The broker credential
43
+
44
+ `csb1.<linkId>.<secret>` — 128-bit id + 256-bit secret, minted per link at
45
+ AcceptGrant. The table stores only `sha256(secret)`; comparison is constant
46
+ time; an unknown link and a wrong secret get the same 401. It opens exactly one
47
+ link, is revoked by deleting the item, rotated by `/broker/rotate` or by any
48
+ re-link, and lapses after 180 days without a refresh (DynamoDB TTL).
49
+
50
+ ## Files
51
+
52
+ `index.js` (handler), `broker.js`, `secrets.js`, `token-store.js`, `util.js`,
53
+ `http.js`, `aws.js`. No `node_modules`: AWS SDK v3 ships with the nodejs22.x
54
+ runtime. Build:
55
+
56
+ ```bash
57
+ cd lambda-template && rm -f ../camstack-alexa-lambda.zip && zip -j ../camstack-alexa-lambda.zip *.js
58
+ ```
59
+
60
+ ## Deployment
61
+
62
+ It ships in the published `@camstack/addon-export-alexa` npm package (`npm pack`
63
+ it and take `package/lambda-template/`). Deploy it in the AWS region that serves
64
+ your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-west-2`.
65
+
66
+ 1. **Lambda**: Node.js 22.x, arm64, handler `index.handler`, timeout 8 s or
67
+ more, 128–256 MB. Upload the zip.
68
+ 2. **Function URL**: auth type `NONE` (Amazon's account linking and the hubs
69
+ call it). Note the URL.
70
+ 3. **Trigger**: **Alexa** → **Alexa Smart Home**, with your skill id
71
+ (`amzn1.ask.skill.…`). Create the skill first to get it.
72
+ 4. **Secrets Manager**: create `camstack/alexa/lwa` and `camstack/alexa/skill`
73
+ (JSON above) in the Lambda's region. Enter the values in the console, or
74
+ with `--secret-string file://…` from a file you delete afterwards — never as
75
+ a command-line argument.
76
+ 5. **DynamoDB**:
77
+
78
+ ```bash
79
+ aws dynamodb create-table --table-name camstack-alexa-tokens \
80
+ --attribute-definitions AttributeName=linkId,AttributeType=S \
81
+ --key-schema AttributeName=linkId,KeyType=HASH \
82
+ --billing-mode PAY_PER_REQUEST \
83
+ --sse-specification Enabled=true,SSEType=KMS \
84
+ --deletion-protection-enabled
85
+ aws dynamodb wait table-exists --table-name camstack-alexa-tokens
86
+ aws dynamodb update-time-to-live --table-name camstack-alexa-tokens \
87
+ --time-to-live-specification Enabled=true,AttributeName=ttl
88
+ ```
89
+
90
+ 6. **IAM** — add to the Lambda's execution role (replace region/account):
91
+
92
+ ```json
93
+ {
94
+ "Version": "2012-10-17",
95
+ "Statement": [
96
+ {
97
+ "Sid": "ReadSkillSecrets",
98
+ "Effect": "Allow",
99
+ "Action": "secretsmanager:GetSecretValue",
100
+ "Resource": "arn:aws:secretsmanager:REGION:ACCOUNT:secret:camstack/alexa/*"
101
+ },
102
+ {
103
+ "Sid": "BrokerLinks",
104
+ "Effect": "Allow",
105
+ "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
106
+ "Resource": "arn:aws:dynamodb:REGION:ACCOUNT:table/camstack-alexa-tokens"
107
+ }
108
+ ]
109
+ }
110
+ ```
111
+
112
+ The default `aws/secretsmanager` and `aws/dynamodb` keys need no extra KMS
113
+ permission. A customer-managed key would need `kms:Decrypt` on it.
114
+
115
+ 7. **Environment variables** (none is a secret):
116
+
117
+ | Name | Value |
118
+ | --- | --- |
119
+ | `LWA_SECRET_ID` | `camstack/alexa/lwa` |
120
+ | `SKILL_SECRET_ID` | `camstack/alexa/skill` |
121
+ | `TOKENS_TABLE` | `camstack-alexa-tokens`. Unset = broker off: AcceptGrant hands the tokens to the hub as before D753. |
122
+ | `LAMBDA_REDIRECT_URI` | Function URL + `/token`. AcceptGrant's `redirect_uri`; its origin is the broker URL advertised to hubs. |
123
+ | `BROKER_PUBLIC_URL` | Optional override of that origin (https only). |
124
+ | `LEGACY_HUB_FALLBACK` | `0` = a hub without `/oauth/broker-link` (pre-D753 addon) fails AcceptGrant instead of receiving the refresh token. Default on, for the cut-over. |
125
+ | `ALLOW_MIGRATION` | `0` = refuse `/broker/migrate`. Default on until every hub has migrated. |
126
+ | `HUB_INGRESS_TOKEN` | Optional defense-in-depth header (`X-CamStack-Ingress`), copied from the addon's **Ingress token** setting. |
127
+
128
+ Legacy, read only when the matching `*_SECRET_ID` is unset: `LWA_CLIENT_ID`,
129
+ `LWA_CLIENT_SECRET`, `SKILL_CLIENT_ID`, `SKILL_CLIENT_SECRET`. Remove them once
130
+ the secrets are in Secrets Manager.
131
+
132
+ ## Skill manifest
133
+
134
+ | Section | Field | Value |
135
+ | --- | --- | --- |
136
+ | Smart Home | Default endpoint | the Lambda ARN |
137
+ | Account Linking | Authorization grant type | Auth Code Grant |
138
+ | | Authorization URI | `<FUNCTION_URL>/authorize` |
139
+ | | Access Token URI | `<FUNCTION_URL>/token` |
140
+ | | Client ID / Secret | the `camstack/alexa/skill` values |
141
+ | | Authentication scheme | Credentials in request body (**not** HTTP Basic — the Lambda reads them from the body) |
142
+ | | Scope, Domain list | empty |
143
+ | Permissions | Send Alexa Events | on; its Alexa Client Id/Secret go into `camstack/alexa/lwa` |
144
+
145
+ ## Cut-over and compatibility
146
+
147
+ | Lambda | Hub addon | Result |
148
+ | --- | --- | --- |
149
+ | new, broker on | new | Broker links. Hub holds no secret. |
150
+ | new, broker on | old (pre-D753) | AcceptGrant falls back to the legacy hand-off (hub 404 on `/oauth/broker-link`) unless `LEGACY_HUB_FALLBACK=0`. |
151
+ | new, broker off (`TOKENS_TABLE` unset) | any | Exactly the pre-D753 behaviour. |
152
+ | old | new | Links, but the hub cannot renew the grant (no secret): events stop after ~1 h until the Lambda is upgraded. **Upgrade the Lambda first.** |
153
+
154
+ A hub linked before D753 migrates itself: the first directive after the Lambda
155
+ advertises `X-CamStack-Broker` lets it trade its refresh token for a broker
156
+ link (`/broker/migrate`, which proves the token by refreshing it with the
157
+ skill's secret) and delete its copy.
158
+
159
+ ## Logs
160
+
161
+ CloudWatch. `timedFetch_timing` (one per outbound call: host, path, status,
162
+ timings), `accept_grant: linked via broker`, `broker: migrated a pre-broker
163
+ grant`, `broker: credential rotated`, `broker: link revoked by its hub`,
164
+ `broker: grant revoked by Amazon; link removed`, `broker: request failed`. No
165
+ log line carries a token, code, credential or secret.
166
+
167
+ ## Tests
168
+
169
+ `packages/addon-export-alexa/src/__tests__/lambda-broker.spec.ts` — the whole
170
+ Lambda against fake LWA, hub, Secrets Manager and DynamoDB (`npx vitest run`
171
+ in the addon).
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Default AWS wiring. The Lambda Node.js 22 runtime ships AWS SDK v3, so the
3
+ * deployment stays a zip of plain files with no node_modules. Required lazily:
4
+ * tests inject fakes and never load the SDK.
5
+ *
6
+ * Execution-role permissions this needs (see README → IAM):
7
+ * secretsmanager:GetSecretValue on the two camstack/alexa/* secrets
8
+ * dynamodb:GetItem/PutItem/DeleteItem on the tokens table
9
+ */
10
+ 'use strict'
11
+
12
+ function createAwsClients() {
13
+ let ddbClient = null
14
+ let smClient = null
15
+
16
+ async function send(commandName, input) {
17
+ const ddb = require('@aws-sdk/client-dynamodb')
18
+ if (!ddbClient) ddbClient = new ddb.DynamoDBClient({})
19
+ const Command = ddb[`${commandName}Command`]
20
+ if (typeof Command !== 'function') throw new Error(`unknown DynamoDB command ${commandName}`)
21
+ return ddbClient.send(new Command(input))
22
+ }
23
+
24
+ async function fetchSecretString(secretId) {
25
+ const sm = require('@aws-sdk/client-secrets-manager')
26
+ if (!smClient) smClient = new sm.SecretsManagerClient({})
27
+ const res = await smClient.send(new sm.GetSecretValueCommand({ SecretId: secretId }))
28
+ if (typeof res.SecretString !== 'string') {
29
+ throw new Error(`secret ${secretId} has no SecretString`)
30
+ }
31
+ return res.SecretString
32
+ }
33
+
34
+ return { send, fetchSecretString }
35
+ }
36
+
37
+ module.exports = { createAwsClients }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * The Alexa token broker (D753).
3
+ *
4
+ * The skill's LWA client secret authenticates every grant exchange and every
5
+ * refresh at api.amazon.com. Before D753 a copy of it lived in every hub — in
6
+ * public source and in a public npm package. Now it lives ONLY here (read from
7
+ * Secrets Manager), together with each user's LWA refresh token, and a hub
8
+ * gets exactly what it needs to push events: a short-lived access token.
9
+ *
10
+ * A hub authenticates to the broker with a PER-LINK credential
11
+ * `csb1.<linkId>.<secret>` minted at link time:
12
+ * - no shared secret: each link has its own, so one leaked hub exposes one
13
+ * link, never the skill;
14
+ * - only sha256(secret) is stored, compared in constant time;
15
+ * - revocable: delete the item (`/broker/revoke`, or the item's TTL lapses);
16
+ * - rotatable: `/broker/rotate` swaps the secret atomically, and every
17
+ * re-link mints a fresh one (the old item expires on its idle TTL).
18
+ *
19
+ * HTTP routes (Function URL, POST only):
20
+ * /broker/token Bearer <credential> → { accessToken, expiresAt }
21
+ * /broker/migrate { refreshToken } → { brokerCredential, brokerUrl, accessToken, expiresAt }
22
+ * /broker/rotate Bearer <credential> → { brokerCredential }
23
+ * /broker/revoke Bearer <credential> → { ok: true }
24
+ *
25
+ * `/broker/migrate` is the zero-touch path for a hub linked before D753: it
26
+ * still holds a refresh token (and, until it updates, the leaked secret). The
27
+ * broker proves the token is a live grant OF THIS SKILL by refreshing it with
28
+ * the skill's secret — possession of that token is already the full power of
29
+ * the grant, so the broker hands back nothing the caller did not have. The hub
30
+ * then deletes its copy. Disable with `ALLOW_MIGRATION=0` once every hub moved.
31
+ *
32
+ * Nothing here logs a token, a code, a credential or a secret.
33
+ */
34
+ 'use strict'
35
+
36
+ const crypto = require('node:crypto')
37
+ const { encodeFormUrlEncoded, jsonResponse, parseJsonBody, readBearer } = require('./util.js')
38
+
39
+ const LWA_TOKEN_URL = 'https://api.amazon.com/auth/o2/token'
40
+ const CREDENTIAL_PREFIX = 'csb1'
41
+ /** Hand out a cached access token only while it has at least this much life left. */
42
+ const ACCESS_TOKEN_MIN_LIFE_MS = 5 * 60 * 1000
43
+ /** A pending (mid-AcceptGrant) item that never activated is gone after this. */
44
+ const PENDING_TTL_SEC = 60 * 60
45
+ /** An active link nobody refreshed for this long is purged by DynamoDB TTL. */
46
+ const IDLE_TTL_SEC = 180 * 24 * 60 * 60
47
+
48
+ class BrokerError extends Error {
49
+ constructor(status, code, message) {
50
+ super(message)
51
+ this.status = status
52
+ this.code = code
53
+ }
54
+ }
55
+
56
+ // ── Credentials ──────────────────────────────────────────────────────
57
+
58
+ function sha256Hex(value) {
59
+ return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
60
+ }
61
+
62
+ /** @param {(n: number) => Buffer} randomBytes */
63
+ function mintCredential(randomBytes, linkId) {
64
+ const id = linkId || randomBytes(16).toString('base64url')
65
+ const secret = randomBytes(32).toString('base64url')
66
+ return {
67
+ linkId: id,
68
+ secretHash: sha256Hex(secret),
69
+ credential: `${CREDENTIAL_PREFIX}.${id}.${secret}`,
70
+ }
71
+ }
72
+
73
+ const B64URL = /^[A-Za-z0-9_-]+$/
74
+
75
+ function parseCredential(raw) {
76
+ if (typeof raw !== 'string') return null
77
+ const parts = raw.split('.')
78
+ if (parts.length !== 3 || parts[0] !== CREDENTIAL_PREFIX) return null
79
+ const [, linkId, secret] = parts
80
+ if (!B64URL.test(linkId) || !B64URL.test(secret)) return null
81
+ if (linkId.length < 16 || linkId.length > 64 || secret.length < 32 || secret.length > 128) {
82
+ return null
83
+ }
84
+ return { linkId, secret }
85
+ }
86
+
87
+ function secretMatches(secret, storedHash) {
88
+ if (typeof storedHash !== 'string' || storedHash.length !== 64) return false
89
+ const a = Buffer.from(sha256Hex(secret), 'hex')
90
+ const b = Buffer.from(storedHash, 'hex')
91
+ return a.length === b.length && crypto.timingSafeEqual(a, b)
92
+ }
93
+
94
+ // ── LWA ──────────────────────────────────────────────────────────────
95
+
96
+ async function postLwa(deps, params, creds) {
97
+ const resp = await deps.fetch(LWA_TOKEN_URL, {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
100
+ body: encodeFormUrlEncoded({
101
+ ...params,
102
+ client_id: creds.clientId,
103
+ client_secret: creds.clientSecret,
104
+ }),
105
+ })
106
+ const text = await resp.text().catch(() => '')
107
+ let body = null
108
+ try {
109
+ body = JSON.parse(text)
110
+ } catch {
111
+ body = null
112
+ }
113
+ return { ok: resp.ok, status: resp.status, body }
114
+ }
115
+
116
+ function lwaErrorCode(res) {
117
+ return res.body && typeof res.body.error === 'string' ? res.body.error : null
118
+ }
119
+
120
+ /**
121
+ * One call to the LWA token endpoint with the skill's credentials. On
122
+ * `invalid_client` with a Secrets-Manager-sourced secret, drops the cache and
123
+ * retries once with a freshly read value — so a rotation takes effect on the
124
+ * very next call instead of after the cache TTL.
125
+ */
126
+ async function lwaTokenCall(deps, params) {
127
+ const creds = await deps.secrets.lwa()
128
+ if (!creds) {
129
+ throw new BrokerError(500, 'unconfigured', 'LWA client credentials are not configured')
130
+ }
131
+ let res = await postLwa(deps, params, creds)
132
+ if (lwaErrorCode(res) === 'invalid_client' && creds.source === 'secrets-manager') {
133
+ deps.secrets.invalidate('lwa')
134
+ const fresh = await deps.secrets.lwa()
135
+ if (fresh && fresh.clientSecret !== creds.clientSecret) {
136
+ deps.log.warn('broker: LWA rejected the cached client secret; retried with a fresh read')
137
+ res = await postLwa(deps, params, fresh)
138
+ }
139
+ }
140
+ return res
141
+ }
142
+
143
+ function readLwaTokens(res, fallbackRefreshToken) {
144
+ const b = res.body || {}
145
+ if (typeof b.access_token !== 'string' || b.access_token.length === 0) return null
146
+ const expiresIn = typeof b.expires_in === 'number' && b.expires_in > 0 ? b.expires_in : 3600
147
+ const refreshToken =
148
+ typeof b.refresh_token === 'string' && b.refresh_token.length > 0
149
+ ? b.refresh_token
150
+ : fallbackRefreshToken
151
+ if (!refreshToken) return null
152
+ return { accessToken: b.access_token, refreshToken, expiresInSec: expiresIn }
153
+ }
154
+
155
+ /** Exchange an AcceptGrant code. Throws BrokerError on any failure. */
156
+ async function exchangeGrantCode(deps, code, redirectUri) {
157
+ const res = await lwaTokenCall(deps, {
158
+ grant_type: 'authorization_code',
159
+ code,
160
+ redirect_uri: redirectUri,
161
+ })
162
+ const tokens = res.ok ? readLwaTokens(res, null) : null
163
+ if (!tokens) {
164
+ throw new BrokerError(502, 'lwa_exchange_failed', `LWA exchange ${res.status} ${lwaErrorCode(res) || ''}`.trim())
165
+ }
166
+ return tokens
167
+ }
168
+
169
+ /** Refresh a grant. `invalid_grant` → 410 (the user disabled the skill / Amazon revoked it). */
170
+ async function refreshGrant(deps, refreshToken) {
171
+ const res = await lwaTokenCall(deps, { grant_type: 'refresh_token', refresh_token: refreshToken })
172
+ if (!res.ok) {
173
+ if (lwaErrorCode(res) === 'invalid_grant') {
174
+ throw new BrokerError(410, 'grant_revoked', 'Amazon no longer honours this grant; re-link the skill')
175
+ }
176
+ throw new BrokerError(502, 'lwa_unavailable', `LWA refresh ${res.status} ${lwaErrorCode(res) || ''}`.trim())
177
+ }
178
+ const tokens = readLwaTokens(res, refreshToken)
179
+ if (!tokens) throw new BrokerError(502, 'lwa_malformed', 'LWA refresh answered without an access token')
180
+ return tokens
181
+ }
182
+
183
+ // ── Link lifecycle ───────────────────────────────────────────────────
184
+
185
+ function nowSec(deps) {
186
+ return Math.floor(deps.now() / 1000)
187
+ }
188
+
189
+ /**
190
+ * Create a link item for freshly obtained tokens. `status` is `pending` for an
191
+ * AcceptGrant (activated only after the hub accepted the credential) and
192
+ * `active` for a migration (the caller already proved possession).
193
+ */
194
+ async function createLink(deps, opts) {
195
+ const minted = mintCredential(deps.randomBytes)
196
+ const now = deps.now()
197
+ const item = {
198
+ linkId: minted.linkId,
199
+ secretHash: minted.secretHash,
200
+ status: opts.status,
201
+ refreshToken: opts.tokens.refreshToken,
202
+ accessToken: opts.tokens.accessToken,
203
+ accessExpiresAt: now + opts.tokens.expiresInSec * 1000,
204
+ ...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
205
+ origin: opts.origin,
206
+ createdAt: now,
207
+ lastUsedAt: now,
208
+ ttl: nowSec(deps) + (opts.status === 'pending' ? PENDING_TTL_SEC : IDLE_TTL_SEC),
209
+ version: 1,
210
+ }
211
+ await deps.store.create(item)
212
+ return { item, credential: minted.credential }
213
+ }
214
+
215
+ async function activateLink(deps, item) {
216
+ const next = { ...item, status: 'active', ttl: nowSec(deps) + IDLE_TTL_SEC, version: item.version + 1 }
217
+ const ok = await deps.store.replace(next, item.version)
218
+ if (!ok) throw new BrokerError(409, 'conflict', 'link changed while activating')
219
+ return next
220
+ }
221
+
222
+ /** Resolve the request's credential to an ACTIVE item, or throw 401. */
223
+ async function authenticate(deps, event) {
224
+ const parsed = parseCredential(readBearer(event))
225
+ if (!parsed) throw new BrokerError(401, 'invalid_credential', 'broker credential missing or malformed')
226
+ const item = await deps.store.get(parsed.linkId)
227
+ // One answer for "no such link", "not active" and "wrong secret" — the
228
+ // response must not tell a prober which linkIds exist.
229
+ if (!item || item.status !== 'active' || !secretMatches(parsed.secret, item.secretHash)) {
230
+ throw new BrokerError(401, 'invalid_credential', 'broker credential not recognised')
231
+ }
232
+ return item
233
+ }
234
+
235
+ /** A valid access token for the item, refreshing (and persisting) when needed. */
236
+ async function currentAccessToken(deps, item) {
237
+ const now = deps.now()
238
+ if (item.accessToken && item.accessExpiresAt - now > ACCESS_TOKEN_MIN_LIFE_MS) return item
239
+ let tokens
240
+ try {
241
+ tokens = await refreshGrant(deps, item.refreshToken)
242
+ } catch (err) {
243
+ if (err instanceof BrokerError && err.code === 'grant_revoked') {
244
+ await deps.store.remove(item.linkId)
245
+ deps.log.warn('broker: grant revoked by Amazon; link removed', { link: shortId(item.linkId) })
246
+ }
247
+ throw err
248
+ }
249
+ const next = {
250
+ ...item,
251
+ accessToken: tokens.accessToken,
252
+ refreshToken: tokens.refreshToken,
253
+ accessExpiresAt: now + tokens.expiresInSec * 1000,
254
+ lastUsedAt: now,
255
+ ttl: nowSec(deps) + IDLE_TTL_SEC,
256
+ version: item.version + 1,
257
+ }
258
+ if (await deps.store.replace(next, item.version)) return next
259
+ // Someone refreshed concurrently. Their write carries the newest refresh
260
+ // token; serve what they stored rather than overwrite it.
261
+ const fresh = await deps.store.get(item.linkId)
262
+ if (fresh && fresh.status === 'active' && fresh.accessExpiresAt > now) return fresh
263
+ throw new BrokerError(503, 'conflict', 'concurrent refresh; retry')
264
+ }
265
+
266
+ function shortId(linkId) {
267
+ return typeof linkId === 'string' ? linkId.slice(0, 6) : '?'
268
+ }
269
+
270
+ // ── HTTP routes ──────────────────────────────────────────────────────
271
+
272
+ const ROUTES = {
273
+ '/broker/token': routeToken,
274
+ '/broker/migrate': routeMigrate,
275
+ '/broker/rotate': routeRotate,
276
+ '/broker/revoke': routeRevoke,
277
+ }
278
+
279
+ function isBrokerPath(path) {
280
+ return Object.prototype.hasOwnProperty.call(ROUTES, path)
281
+ }
282
+
283
+ async function handleBrokerRoute(event, deps) {
284
+ if (event.requestContext.http.method !== 'POST') {
285
+ return jsonResponse(405, { error: 'method_not_allowed' })
286
+ }
287
+ if (!deps.store) {
288
+ return jsonResponse(503, { error: 'broker_unconfigured', message: 'TOKENS_TABLE is not set' })
289
+ }
290
+ try {
291
+ return await ROUTES[event.rawPath](event, deps)
292
+ } catch (err) {
293
+ if (err instanceof BrokerError) {
294
+ if (err.status >= 500) deps.log.warn('broker: request failed', { route: event.rawPath, code: err.code })
295
+ return jsonResponse(err.status, { error: err.code, message: err.message })
296
+ }
297
+ throw err
298
+ }
299
+ }
300
+
301
+ async function routeToken(event, deps) {
302
+ const item = await authenticate(deps, event)
303
+ const current = await currentAccessToken(deps, item)
304
+ return jsonResponse(200, { accessToken: current.accessToken, expiresAt: current.accessExpiresAt })
305
+ }
306
+
307
+ async function routeMigrate(event, deps) {
308
+ if (deps.env.ALLOW_MIGRATION === '0') {
309
+ return jsonResponse(403, { error: 'migration_disabled', message: 're-link the skill instead' })
310
+ }
311
+ const brokerUrl = deps.publicUrl
312
+ if (!brokerUrl) {
313
+ return jsonResponse(503, { error: 'broker_unconfigured', message: 'broker public URL unknown' })
314
+ }
315
+ const body = parseJsonBody(event)
316
+ const refreshToken = body && typeof body.refreshToken === 'string' ? body.refreshToken : ''
317
+ if (refreshToken.length === 0 || refreshToken.length > 4096) {
318
+ return jsonResponse(400, { error: 'invalid_request', message: 'refreshToken is required' })
319
+ }
320
+ let tokens
321
+ try {
322
+ tokens = await refreshGrant(deps, refreshToken)
323
+ } catch (err) {
324
+ if (err instanceof BrokerError && err.code === 'grant_revoked') {
325
+ throw new BrokerError(400, 'invalid_grant', 'not a live grant of this skill')
326
+ }
327
+ throw err
328
+ }
329
+ const hubUrl = body && typeof body.hubUrl === 'string' ? body.hubUrl.slice(0, 512) : undefined
330
+ const { item, credential } = await createLink(deps, {
331
+ status: 'active',
332
+ tokens,
333
+ hubUrl,
334
+ origin: 'migration',
335
+ })
336
+ deps.log.info('broker: migrated a pre-broker grant', { link: shortId(item.linkId) })
337
+ return jsonResponse(200, {
338
+ brokerCredential: credential,
339
+ brokerUrl,
340
+ accessToken: item.accessToken,
341
+ expiresAt: item.accessExpiresAt,
342
+ })
343
+ }
344
+
345
+ async function routeRotate(event, deps) {
346
+ const item = await authenticate(deps, event)
347
+ const minted = mintCredential(deps.randomBytes, item.linkId)
348
+ const next = { ...item, secretHash: minted.secretHash, version: item.version + 1 }
349
+ if (!(await deps.store.replace(next, item.version))) {
350
+ throw new BrokerError(409, 'conflict', 'link changed while rotating; retry')
351
+ }
352
+ deps.log.info('broker: credential rotated', { link: shortId(item.linkId) })
353
+ return jsonResponse(200, { brokerCredential: minted.credential })
354
+ }
355
+
356
+ async function routeRevoke(event, deps) {
357
+ const item = await authenticate(deps, event)
358
+ await deps.store.remove(item.linkId)
359
+ deps.log.info('broker: link revoked by its hub', { link: shortId(item.linkId) })
360
+ return jsonResponse(200, { ok: true })
361
+ }
362
+
363
+ module.exports = {
364
+ BrokerError,
365
+ LWA_TOKEN_URL,
366
+ ACCESS_TOKEN_MIN_LIFE_MS,
367
+ PENDING_TTL_SEC,
368
+ IDLE_TTL_SEC,
369
+ mintCredential,
370
+ parseCredential,
371
+ secretMatches,
372
+ sha256Hex,
373
+ lwaTokenCall,
374
+ exchangeGrantCode,
375
+ refreshGrant,
376
+ createLink,
377
+ activateLink,
378
+ currentAccessToken,
379
+ isBrokerPath,
380
+ handleBrokerRoute,
381
+ shortId,
382
+ }