@camstack/addon-export-alexa 1.2.133 → 1.2.135

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.
@@ -22,20 +22,29 @@ The same code runs the built-in CamStack skill and any operator's own
22
22
  | `POST <FUNCTION_URL>/broker/rotate` | `Bearer <credential>` → `{ brokerCredential }`; the old one stops working. |
23
23
  | `POST <FUNCTION_URL>/broker/revoke` | `Bearer <credential>` → deletes the link (and the refresh token). |
24
24
 
25
+ Errors Alexa receives follow the Smart Home contract: every `Alexa.ErrorResponse`
26
+ carries the directive's `correlationToken` and `endpoint`; a hub that cannot be
27
+ reached (network error, or a tunnel's 502–504 / 52x page) is
28
+ `BRIDGE_UNREACHABLE`; a hub reply that is not an Alexa event becomes
29
+ `INTERNAL_ERROR`; AcceptGrant failures are `Alexa.Authorization` /
30
+ `ACCEPT_GRANT_FAILED`. The `/authorize` page accepts only an `https://` hub URL
31
+ and shows its error on the page.
32
+
25
33
  ### The two credential pairs
26
34
 
27
- | Pair | Secrets Manager id | Authenticates | Who else holds it |
35
+ | Pair | SSM parameter (SecureString) | Authenticates | Who else holds it |
28
36
  | --- | --- | --- | --- |
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 |
37
+ | **LWA "Send Alexa Events"** client (skill console → Permissions → Alexa Skill Messaging) | `/camstack/alexa/lwa` | this Lambda → `api.amazon.com` (code exchange, refresh) | nobody |
38
+ | **Account-linking** client (skill console → Account Linking) | `/camstack/alexa/skill` | Amazon → this Lambda's `/token` | Amazon |
31
39
 
32
- Each secret is a JSON string:
40
+ Each parameter is a **SecureString** (standard tier, AWS-managed key
41
+ `alias/aws/ssm`) whose value is a JSON string:
33
42
 
34
43
  ```json
35
44
  { "clientId": "…", "clientSecret": "…" }
36
45
  ```
37
46
 
38
- `camstack/alexa/skill` may also carry `"previousClientSecret"` — accepted on
47
+ `/camstack/alexa/skill` may also carry `"previousClientSecret"` — accepted on
39
48
  `/token` while you rotate it (change the value in the skill console, then remove
40
49
  the previous one).
41
50
 
@@ -69,10 +78,18 @@ your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-we
69
78
  call it). Note the URL.
70
79
  3. **Trigger**: **Alexa** → **Alexa Smart Home**, with your skill id
71
80
  (`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.
81
+ 4. **SSM Parameter Store** (free standard tier): in the Lambda's region,
82
+ Systems Manager → Parameter Store → **Create parameter**, twice:
83
+ - Name `/camstack/alexa/lwa`, then `/camstack/alexa/skill`
84
+ - Tier **Standard**, Type **SecureString**
85
+ - KMS key source **My current account**, KMS key ID `alias/aws/ssm`
86
+ - Value: the JSON above.
87
+
88
+ Enter the values in the console, or with `aws ssm put-parameter --type
89
+ SecureString --value file://…` from a file you delete afterwards — never as
90
+ a literal command-line argument. The Lambda reads them with `GetParameter`
91
+ and `WithDecryption: true`, caches them for 5 minutes, and re-reads the LWA
92
+ pair at once when LWA answers `invalid_client`.
76
93
  5. **DynamoDB**:
77
94
 
78
95
  ```bash
@@ -94,10 +111,22 @@ your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-we
94
111
  "Version": "2012-10-17",
95
112
  "Statement": [
96
113
  {
97
- "Sid": "ReadSkillSecrets",
114
+ "Sid": "ReadSkillParameters",
115
+ "Effect": "Allow",
116
+ "Action": "ssm:GetParameter",
117
+ "Resource": "arn:aws:ssm:REGION:ACCOUNT:parameter/camstack/alexa/*"
118
+ },
119
+ {
120
+ "Sid": "DecryptSkillParametersViaSsm",
98
121
  "Effect": "Allow",
99
- "Action": "secretsmanager:GetSecretValue",
100
- "Resource": "arn:aws:secretsmanager:REGION:ACCOUNT:secret:camstack/alexa/*"
122
+ "Action": "kms:Decrypt",
123
+ "Resource": "arn:aws:kms:REGION:ACCOUNT:key/AWS_SSM_KEY_ID",
124
+ "Condition": {
125
+ "StringEquals": { "kms:ViaService": "ssm.REGION.amazonaws.com" },
126
+ "StringLike": {
127
+ "kms:EncryptionContext:PARAMETER_ARN": "arn:aws:ssm:REGION:ACCOUNT:parameter/camstack/alexa/*"
128
+ }
129
+ }
101
130
  },
102
131
  {
103
132
  "Sid": "BrokerLinks",
@@ -109,25 +138,32 @@ your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-we
109
138
  }
110
139
  ```
111
140
 
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.
141
+ `AWS_SSM_KEY_ID` is the id of the AWS-managed `alias/aws/ssm` key (KMS
142
+ console → AWS managed keys, or `aws kms describe-key --key-id
143
+ alias/aws/ssm`). AWS's guidance on whether the AWS-managed key needs a
144
+ `kms:Decrypt` grant is inconsistent — its key policy already lets account
145
+ principals decrypt through SSM — so the statement is included, and scoped so
146
+ it can only decrypt these two parameters, and only through SSM. A
147
+ customer-managed key would need the same statement on that key instead. The
148
+ default `aws/dynamodb` key needs no extra permission.
114
149
 
115
150
  7. **Environment variables** (none is a secret):
116
151
 
117
152
  | Name | Value |
118
153
  | --- | --- |
119
- | `LWA_SECRET_ID` | `camstack/alexa/lwa` |
120
- | `SKILL_SECRET_ID` | `camstack/alexa/skill` |
154
+ | `LWA_PARAM` | `/camstack/alexa/lwa` |
155
+ | `SKILL_PARAM` | `/camstack/alexa/skill` |
121
156
  | `TOKENS_TABLE` | `camstack-alexa-tokens`. Unset = broker off: AcceptGrant hands the tokens to the hub as before D753. |
122
157
  | `LAMBDA_REDIRECT_URI` | Function URL + `/token`. AcceptGrant's `redirect_uri`; its origin is the broker URL advertised to hubs. |
123
158
  | `BROKER_PUBLIC_URL` | Optional override of that origin (https only). |
124
159
  | `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. |
160
+ | `DEBUG_DIRECTIVES` | `1` = one `directive_trace` log line per Smart Home directive: namespace / name / endpointId / payloadVersion / payload keys asked, and the answered event (ErrorResponse type and message, StateReport property names and value shapes). Never the bearer token or SDP bodies. Diagnostic; default off. |
125
161
  | `ALLOW_MIGRATION` | `0` = refuse `/broker/migrate`. Default on until every hub has migrated. |
126
162
  | `HUB_INGRESS_TOKEN` | Optional defense-in-depth header (`X-CamStack-Ingress`), copied from the addon's **Ingress token** setting. |
127
163
 
128
- Legacy, read only when the matching `*_SECRET_ID` is unset: `LWA_CLIENT_ID`,
164
+ Legacy, read only when the matching `*_PARAM` is unset: `LWA_CLIENT_ID`,
129
165
  `LWA_CLIENT_SECRET`, `SKILL_CLIENT_ID`, `SKILL_CLIENT_SECRET`. Remove them once
130
- the secrets are in Secrets Manager.
166
+ the parameters exist and `*_PARAM` is set.
131
167
 
132
168
  ## Skill manifest
133
169
 
@@ -137,10 +173,10 @@ the secrets are in Secrets Manager.
137
173
  | Account Linking | Authorization grant type | Auth Code Grant |
138
174
  | | Authorization URI | `<FUNCTION_URL>/authorize` |
139
175
  | | Access Token URI | `<FUNCTION_URL>/token` |
140
- | | Client ID / Secret | the `camstack/alexa/skill` values |
176
+ | | Client ID / Secret | the `/camstack/alexa/skill` values |
141
177
  | | Authentication scheme | Credentials in request body (**not** HTTP Basic — the Lambda reads them from the body) |
142
178
  | | Scope, Domain list | empty |
143
- | Permissions | Send Alexa Events | on; its Alexa Client Id/Secret go into `camstack/alexa/lwa` |
179
+ | Permissions | Send Alexa Events | on; its Alexa Client Id/Secret go into `/camstack/alexa/lwa` |
144
180
 
145
181
  ## Cut-over and compatibility
146
182
 
@@ -167,5 +203,5 @@ log line carries a token, code, credential or secret.
167
203
  ## Tests
168
204
 
169
205
  `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`
206
+ Lambda against fake LWA, hub, SSM Parameter Store and DynamoDB (`npx vitest run`
171
207
  in the addon).
@@ -1,37 +1,53 @@
1
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.
2
+ * Default AWS wiring. The Lambda Node.js 22 runtime ships AWS SDK v3
3
+ * (`@aws-sdk/client-ssm`, `@aws-sdk/client-dynamodb`), so the deployment stays
4
+ * a zip of plain files with no node_modules. Required lazily: tests inject a
5
+ * fake `loadSdk` and never load the SDK.
5
6
  *
6
7
  * 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
8
+ * ssm:GetParameter on parameter/camstack/alexa/*
9
+ * kms:Decrypt on the `aws/ssm` key, only via SSM and
10
+ * only for those parameters (D761)
11
+ * dynamodb:GetItem/PutItem/DeleteItem on the tokens table
9
12
  */
10
13
  'use strict'
11
14
 
12
- function createAwsClients() {
15
+ /**
16
+ * @param {object} [opts]
17
+ * @param {(name: string) => any} [opts.loadSdk] module loader (tests inject a fake)
18
+ */
19
+ function createAwsClients(opts) {
20
+ const loadSdk = (opts && opts.loadSdk) || require
13
21
  let ddbClient = null
14
- let smClient = null
22
+ let ssmClient = null
23
+ let ssmSdk = null
15
24
 
16
25
  async function send(commandName, input) {
17
- const ddb = require('@aws-sdk/client-dynamodb')
26
+ const ddb = loadSdk('@aws-sdk/client-dynamodb')
18
27
  if (!ddbClient) ddbClient = new ddb.DynamoDBClient({})
19
28
  const Command = ddb[`${commandName}Command`]
20
29
  if (typeof Command !== 'function') throw new Error(`unknown DynamoDB command ${commandName}`)
21
30
  return ddbClient.send(new Command(input))
22
31
  }
23
32
 
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`)
33
+ /**
34
+ * The decrypted value of one SecureString parameter. Errors name the
35
+ * parameter, never a value.
36
+ */
37
+ async function readParameter(name) {
38
+ if (!ssmSdk) ssmSdk = loadSdk('@aws-sdk/client-ssm')
39
+ if (!ssmClient) ssmClient = new ssmSdk.SSMClient({})
40
+ const res = await ssmClient.send(
41
+ new ssmSdk.GetParameterCommand({ Name: name, WithDecryption: true }),
42
+ )
43
+ const value = res && res.Parameter ? res.Parameter.Value : undefined
44
+ if (typeof value !== 'string' || value.length === 0) {
45
+ throw new Error(`parameter ${name} has no value`)
30
46
  }
31
- return res.SecretString
47
+ return value
32
48
  }
33
49
 
34
- return { send, fetchSecretString }
50
+ return { send, readParameter }
35
51
  }
36
52
 
37
53
  module.exports = { createAwsClients }
@@ -4,7 +4,7 @@
4
4
  * The skill's LWA client secret authenticates every grant exchange and every
5
5
  * refresh at api.amazon.com. Before D753 a copy of it lived in every hub — in
6
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
7
+ * an SSM SecureString parameter), together with each user's LWA refresh token, and a hub
8
8
  * gets exactly what it needs to push events: a short-lived access token.
9
9
  *
10
10
  * A hub authenticates to the broker with a PER-LINK credential
@@ -119,7 +119,7 @@ function lwaErrorCode(res) {
119
119
 
120
120
  /**
121
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
122
+ * `invalid_client` with a Parameter-Store-sourced secret, drops the cache and
123
123
  * retries once with a freshly read value — so a rotation takes effect on the
124
124
  * very next call instead of after the cache TTL.
125
125
  */
@@ -129,7 +129,7 @@ async function lwaTokenCall(deps, params) {
129
129
  throw new BrokerError(500, 'unconfigured', 'LWA client credentials are not configured')
130
130
  }
131
131
  let res = await postLwa(deps, params, creds)
132
- if (lwaErrorCode(res) === 'invalid_client' && creds.source === 'secrets-manager') {
132
+ if (lwaErrorCode(res) === 'invalid_client' && creds.source === 'ssm') {
133
133
  deps.secrets.invalidate('lwa')
134
134
  const fresh = await deps.secrets.lwa()
135
135
  if (fresh && fresh.clientSecret !== creds.clientSecret) {
@@ -0,0 +1,100 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * TEMPORARY diagnostic, on only with `DEBUG_DIRECTIVES=1`: one `directive_trace`
5
+ * line per Smart Home directive — what Alexa asked and what the skill answered.
6
+ *
7
+ * Built from an allow-list of fields, never by dumping the event: the
8
+ * directive's `scope.token` (the account's bearer) and the SDP bodies never
9
+ * reach the log. StateReport property values are summarised (a short scalar,
10
+ * or the object's keys and scalar values) — they are device states, not
11
+ * credentials.
12
+ */
13
+
14
+ const MAX_VALUE_CHARS = 60
15
+
16
+ function isEnabled(env) {
17
+ return Boolean(env) && env.DEBUG_DIRECTIVES === '1'
18
+ }
19
+
20
+ function str(value) {
21
+ return typeof value === 'string' ? value : undefined
22
+ }
23
+
24
+ function summariseValue(value) {
25
+ if (value === null || value === undefined) return String(value)
26
+ if (typeof value !== 'object') return String(value).slice(0, MAX_VALUE_CHARS)
27
+ if (Array.isArray(value)) return `[array:${value.length}]`
28
+ const parts = Object.keys(value).map((k) => {
29
+ const v = value[k]
30
+ return v !== null && typeof v === 'object' ? `${k}:{…}` : `${k}:${String(v)}`
31
+ })
32
+ return `{${parts.join(',')}}`.slice(0, MAX_VALUE_CHARS)
33
+ }
34
+
35
+ function payloadShape(payload) {
36
+ if (!payload || typeof payload !== 'object') return undefined
37
+ return Object.keys(payload)
38
+ .filter((k) => k !== 'scope')
39
+ .map((k) => {
40
+ const v = payload[k]
41
+ if (v && typeof v === 'object' && typeof v.value === 'string') {
42
+ // RTC offers/answers: { format, value: <SDP> } — length only.
43
+ return `${k}(${str(v.format) || 'object'},${v.value.length}b)`
44
+ }
45
+ return k
46
+ })
47
+ }
48
+
49
+ function summariseDirective(event) {
50
+ const d = (event && event.directive) || {}
51
+ const h = d.header || {}
52
+ const ep = d.endpoint || {}
53
+ return {
54
+ namespace: str(h.namespace),
55
+ name: str(h.name),
56
+ endpointId: str(ep.endpointId),
57
+ payloadVersion: str(h.payloadVersion),
58
+ payloadKeys: payloadShape(d.payload),
59
+ }
60
+ }
61
+
62
+ function summariseResponse(body) {
63
+ const ev = body && body.event
64
+ if (!ev || !ev.header) return { kind: 'non-event' }
65
+ const out = {
66
+ namespace: str(ev.header.namespace),
67
+ name: str(ev.header.name),
68
+ endpointId: str(ev.endpoint && ev.endpoint.endpointId),
69
+ }
70
+ const payload = ev.payload || {}
71
+ if (out.name === 'ErrorResponse') {
72
+ out.errorType = str(payload.type)
73
+ out.errorMessage = str(payload.message)
74
+ } else {
75
+ out.payloadKeys = payloadShape(payload)
76
+ }
77
+ const props = body.context && Array.isArray(body.context.properties) ? body.context.properties : null
78
+ if (props) {
79
+ out.properties = props.map(
80
+ (p) => `${str(p && p.namespace)}.${str(p && p.name)}=${summariseValue(p && p.value)}`,
81
+ )
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** Log one trace line; never throws into the directive path. */
87
+ function traceDirective(deps, event, response, startedAt) {
88
+ if (!isEnabled(deps.env)) return
89
+ try {
90
+ deps.log.info('directive_trace', {
91
+ directive: summariseDirective(event),
92
+ response: summariseResponse(response),
93
+ ms: deps.now() - startedAt,
94
+ })
95
+ } catch {
96
+ // diagnostics only
97
+ }
98
+ }
99
+
100
+ module.exports = { traceDirective, summariseDirective, summariseResponse }
@@ -19,10 +19,13 @@
19
19
  * - everything else → forward to the hub
20
20
  *
21
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.
22
+ * LWA_PARAM SSM SecureString parameter name (e.g.
23
+ * /camstack/alexa/lwa) holding {"clientId","clientSecret"}
24
+ * for the skill's "Send Alexa Events" LWA client.
25
+ * SKILL_PARAM SSM SecureString parameter name (e.g.
26
+ * /camstack/alexa/skill) holding {"clientId",
27
+ * "clientSecret"[,"previousClientSecret"]} for account
28
+ * linking.
26
29
  * TOKENS_TABLE DynamoDB table of broker links. Unset = broker off
27
30
  * (pre-D753 behaviour: tokens are handed to the hub).
28
31
  * LAMBDA_REDIRECT_URI Function URL + "/token"; the AcceptGrant redirect_uri.
@@ -31,7 +34,9 @@
31
34
  * instead of receiving the refresh token. Default on.
32
35
  * ALLOW_MIGRATION "0" = refuse /broker/migrate. Default on.
33
36
  * HUB_INGRESS_TOKEN Optional defense-in-depth header for hub calls.
34
- * Legacy (read only when the *_SECRET_ID is unset): LWA_CLIENT_ID,
37
+ * DEBUG_DIRECTIVES "1" = log one `directive_trace` line per directive
38
+ * (debug-trace.js; never the bearer). Default off.
39
+ * Legacy (read only when the *_PARAM is unset): LWA_CLIENT_ID,
35
40
  * LWA_CLIENT_SECRET, SKILL_CLIENT_ID, SKILL_CLIENT_SECRET.
36
41
  *
37
42
  * Ships as plain files — no node_modules, no transpile (`zip` the folder's
@@ -50,11 +55,14 @@ const {
50
55
  isBrokerPath,
51
56
  shortId,
52
57
  } = require('./broker.js')
58
+ const { traceDirective } = require('./debug-trace.js')
53
59
  const { timedFetch } = require('./http.js')
54
60
  const { createSecretSource } = require('./secrets.js')
55
61
  const { createDynamoTokenStore } = require('./token-store.js')
56
62
  const {
57
63
  decodeBody,
64
+ acceptGrantErrorEnvelope,
65
+ completeErrorResponse,
58
66
  decodeJwtPayload,
59
67
  describe,
60
68
  encodeFormUrlEncoded,
@@ -99,7 +107,7 @@ function resolveDeps(overrides) {
99
107
  o.secrets ||
100
108
  createSecretSource({
101
109
  env,
102
- fetchSecretString: (id) => awsClients().fetchSecretString(id),
110
+ readParameter: (name) => awsClients().readParameter(name),
103
111
  }),
104
112
  store,
105
113
  publicUrl: resolveBrokerPublicUrl(env),
@@ -110,18 +118,28 @@ function createHandler(overrides) {
110
118
  let deps = null
111
119
  return async (event) => {
112
120
  if (!deps) deps = resolveDeps(overrides)
121
+ const startedAt = deps.now()
113
122
  try {
114
123
  if (event && event.requestContext && event.requestContext.http) {
115
124
  return await handleLambdaUrl(event, deps)
116
125
  }
117
126
  if (event && event.directive && event.directive.header) {
118
- return await handleDirective(event, deps)
127
+ const res = await handleDirective(event, deps)
128
+ traceDirective(deps, event, res, startedAt)
129
+ return res
119
130
  }
120
131
  return jsonResponse(400, { error: 'unknown_event' })
121
132
  } catch (err) {
122
133
  deps.log.error('camstack-alexa: unhandled error', describe(err))
123
134
  if (event && event.directive && event.directive.header) {
124
- return errorEnvelope(event.directive.header, 'INTERNAL_ERROR', describe(err))
135
+ const res = errorEnvelope(
136
+ event.directive.header,
137
+ 'INTERNAL_ERROR',
138
+ describe(err),
139
+ event.directive.endpoint,
140
+ )
141
+ traceDirective(deps, event, res, startedAt)
142
+ return res
125
143
  }
126
144
  return jsonResponse(500, { error: 'internal_error', message: describe(err) })
127
145
  }
@@ -164,25 +182,36 @@ function handleAuthorizePage(event) {
164
182
  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
183
  button:hover { background: #1d4ed8; }
166
184
  .hint { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; }
185
+ .err { font-size: 0.85rem; color: #b91c1c; margin: 0.75rem 0 0; }
167
186
  </style>
168
187
  </head>
169
188
  <body>
170
189
  <div class="card">
171
190
  <h1>Link CamStack to Alexa</h1>
172
191
  <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">
192
+ <form id="f" novalidate>
174
193
  <label for="hubUrl">Hub URL</label>
175
194
  <input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
176
195
  <p class="hint">Must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, etc.).</p>
196
+ <p id="err" class="err" role="alert" hidden></p>
177
197
  <button type="submit">Continue</button>
178
198
  </form>
179
199
  </div>
180
200
  <script>
181
201
  document.getElementById('f').addEventListener('submit', function (ev) {
182
202
  ev.preventDefault()
183
- var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/$/, '')
184
- if (!hubUrl) return
185
- var qs = ${JSON.stringify(qs)}
203
+ var err = document.getElementById('err')
204
+ var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/+$/, '')
205
+ var parsed = null
206
+ try { parsed = new URL(hubUrl) } catch (e) { parsed = null }
207
+ if (!parsed || parsed.protocol !== 'https:' || !parsed.hostname) {
208
+ err.textContent = 'Enter the full address of your hub, starting with https:// (for example https://camstack.example.com).'
209
+ err.hidden = false
210
+ return
211
+ }
212
+ err.hidden = true
213
+ hubUrl = parsed.origin + parsed.pathname.replace(/\\/+$/, '')
214
+ var qs = ${jsonForScript(qs)}
186
215
  var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
187
216
  window.location.href = url
188
217
  })
@@ -196,6 +225,11 @@ function handleAuthorizePage(event) {
196
225
  }
197
226
  }
198
227
 
228
+ /** JSON for an inline <script>: `<` escaped so the value cannot close the tag. */
229
+ function jsonForScript(value) {
230
+ return JSON.stringify(value).replace(/</g, '\\u003c')
231
+ }
232
+
199
233
  /** Constant-time string equality that does not leak length via timing. */
200
234
  function safeEqual(a, b) {
201
235
  const ab = Buffer.from(String(a), 'utf8')
@@ -310,22 +344,22 @@ async function handleAcceptGrant(event, deps) {
310
344
  const grantCode = payload.grant && payload.grant.code
311
345
  const granteeToken = payload.grantee && payload.grantee.token
312
346
  if (!grantCode || !granteeToken) {
313
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'payload missing grant.code or grantee.token')
347
+ return acceptGrantErrorEnvelope(header, 'payload missing grant.code or grantee.token')
314
348
  }
315
349
  const claims = decodeJwtPayload(granteeToken)
316
350
  if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
317
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'grantee token has no hubUrl claim')
351
+ return acceptGrantErrorEnvelope(header, 'grantee token has no hubUrl claim')
318
352
  }
319
353
  const redirectUri = deps.env.LAMBDA_REDIRECT_URI
320
354
  if (!redirectUri) {
321
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'LAMBDA_REDIRECT_URI not set on the Lambda')
355
+ return acceptGrantErrorEnvelope(header, 'LAMBDA_REDIRECT_URI not set on the Lambda')
322
356
  }
323
357
 
324
358
  let tokens
325
359
  try {
326
360
  tokens = await exchangeGrantCode(deps, grantCode, redirectUri)
327
361
  } catch (err) {
328
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', describe(err))
362
+ return acceptGrantErrorEnvelope(header, describe(err))
329
363
  }
330
364
  const hubUrl = normaliseHubUrl(claims.hubUrl)
331
365
 
@@ -333,9 +367,11 @@ async function handleAcceptGrant(event, deps) {
333
367
  const outcome = await linkViaBroker(deps, { hubUrl, granteeToken, tokens })
334
368
  if (outcome.kind === 'linked') return acceptGrantResponse(header)
335
369
  if (outcome.kind !== 'hub-without-broker' || deps.env.LEGACY_HUB_FALLBACK === '0') {
336
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', outcome.message)
370
+ return acceptGrantErrorEnvelope(header, outcome.message)
337
371
  }
338
- deps.log.warn('accept_grant: hub has no broker route; falling back to the legacy token hand-off')
372
+ deps.log.warn(
373
+ 'accept_grant: hub has no broker route; falling back to the legacy token hand-off',
374
+ )
339
375
  }
340
376
  return deliverTokensToLegacyHub(deps, header, { hubUrl, granteeToken, tokens })
341
377
  }
@@ -392,43 +428,82 @@ async function deliverTokensToLegacyHub(deps, header, input) {
392
428
  })
393
429
  if (!callbackResp.ok) {
394
430
  const text = await callbackResp.text().catch(() => '')
395
- return errorEnvelope(
431
+ return acceptGrantErrorEnvelope(
396
432
  header,
397
- 'ACCEPT_GRANT_FAILED',
398
433
  `hub callback ${callbackResp.status}: ${text.slice(0, 200)}`,
399
434
  )
400
435
  }
401
436
  return acceptGrantResponse(header)
402
437
  }
403
438
 
439
+ /** HTTP statuses that mean the hub itself is down or unreachable behind its tunnel. */
440
+ function isBridgeDownStatus(status) {
441
+ return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530)
442
+ }
443
+
404
444
  async function forwardDirective(event, deps) {
405
- const header = event.directive.header
406
- const bearer = extractBearer(event.directive)
445
+ const directive = event.directive
446
+ const header = directive.header
447
+ const endpoint = directive.endpoint
448
+ const bearer = extractBearer(directive)
407
449
  if (!bearer) {
408
- return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'directive has no bearer token')
450
+ return errorEnvelope(
451
+ header,
452
+ 'INVALID_AUTHORIZATION_CREDENTIAL',
453
+ 'directive has no bearer token',
454
+ endpoint,
455
+ )
409
456
  }
410
457
  const claims = decodeJwtPayload(bearer)
411
458
  if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
412
- return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'bearer has no hubUrl claim')
459
+ return errorEnvelope(
460
+ header,
461
+ 'INVALID_AUTHORIZATION_CREDENTIAL',
462
+ 'bearer has no hubUrl claim',
463
+ endpoint,
464
+ )
413
465
  }
414
466
  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
- })
467
+ let resp
468
+ try {
469
+ resp = await deps.fetch(target, {
470
+ method: 'POST',
471
+ headers: {
472
+ ...hubHeaders(deps, bearer),
473
+ // Where this skill's broker lives. A hub linked before D753 uses it to
474
+ // migrate its refresh token here (zero-touch); the hub only believes it
475
+ // on a directive whose bearer it verified.
476
+ ...(brokerAvailable(deps) ? { [BROKER_HEADER]: deps.publicUrl } : {}),
477
+ },
478
+ body: JSON.stringify(event),
479
+ })
480
+ } catch (err) {
481
+ // The hub is the bridge every endpoint sits behind (alexa-errorresponse).
482
+ return errorEnvelope(
483
+ header,
484
+ 'BRIDGE_UNREACHABLE',
485
+ `hub unreachable: ${describe(err)}`,
486
+ endpoint,
487
+ )
488
+ }
426
489
  const text = await resp.text()
490
+ let parsed
427
491
  try {
428
- return JSON.parse(text)
492
+ parsed = JSON.parse(text)
429
493
  } catch {
430
- return errorEnvelope(header, 'INTERNAL_ERROR', `hub returned non-JSON: ${text.slice(0, 256)}`)
494
+ const type = isBridgeDownStatus(resp.status) ? 'BRIDGE_UNREACHABLE' : 'INTERNAL_ERROR'
495
+ return errorEnvelope(header, type, `hub returned ${resp.status}, non-JSON`, endpoint)
496
+ }
497
+ if (!parsed || typeof parsed !== 'object' || !parsed.event || !parsed.event.header) {
498
+ // e.g. `{ error: 'invalid_ingress_token' }` — never hand Alexa a non-event.
499
+ return errorEnvelope(
500
+ header,
501
+ 'INTERNAL_ERROR',
502
+ `hub returned ${resp.status} without an Alexa event`,
503
+ endpoint,
504
+ )
431
505
  }
506
+ return completeErrorResponse(parsed, directive)
432
507
  }
433
508
 
434
509
  function extractBearer(directive) {