@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.
- package/dist/export-alexa.addon.js +236 -455
- package/dist/export-alexa.addon.mjs +237 -455
- package/lambda-template/README.md +57 -21
- package/lambda-template/aws.js +32 -16
- package/lambda-template/broker.js +3 -3
- package/lambda-template/debug-trace.js +100 -0
- package/lambda-template/index.js +112 -37
- package/lambda-template/secrets.js +24 -21
- package/lambda-template/util.js +54 -1
- package/package.json +1 -1
|
@@ -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 |
|
|
35
|
+
| Pair | SSM parameter (SecureString) | Authenticates | Who else holds it |
|
|
28
36
|
| --- | --- | --- | --- |
|
|
29
|
-
| **LWA "Send Alexa Events"** client (skill console → Permissions → Alexa Skill Messaging) |
|
|
30
|
-
| **Account-linking** client (skill console → Account Linking) |
|
|
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
|
|
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
|
-
|
|
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. **
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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": "
|
|
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": "
|
|
100
|
-
"Resource": "arn:aws:
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
| `
|
|
120
|
-
| `
|
|
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 `*
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
206
|
+
Lambda against fake LWA, hub, SSM Parameter Store and DynamoDB (`npx vitest run`
|
|
171
207
|
in the addon).
|
package/lambda-template/aws.js
CHANGED
|
@@ -1,37 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Default AWS wiring. The Lambda Node.js 22 runtime ships AWS SDK v3
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
22
|
+
let ssmClient = null
|
|
23
|
+
let ssmSdk = null
|
|
15
24
|
|
|
16
25
|
async function send(commandName, input) {
|
|
17
|
-
const ddb =
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
47
|
+
return value
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
return { send,
|
|
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
|
-
*
|
|
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
|
|
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 === '
|
|
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 }
|
package/lambda-template/index.js
CHANGED
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
var
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
370
|
+
return acceptGrantErrorEnvelope(header, outcome.message)
|
|
337
371
|
}
|
|
338
|
-
deps.log.warn(
|
|
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
|
|
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
|
|
406
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
492
|
+
parsed = JSON.parse(text)
|
|
429
493
|
} catch {
|
|
430
|
-
|
|
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) {
|