@camstack/addon-export-alexa 1.2.146 → 1.2.148
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 +535 -22
- package/dist/export-alexa.addon.mjs +535 -22
- package/lambda-template/README.md +28 -2
- package/lambda-template/authorize-page.js +278 -0
- package/lambda-template/index.js +2 -75
- package/lambda-template/page-style.js +14 -0
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@ The same code runs the built-in CamStack skill and any operator's own
|
|
|
13
13
|
|
|
14
14
|
| Invocation | Action |
|
|
15
15
|
| --- | --- |
|
|
16
|
-
| `GET <FUNCTION_URL>/authorize` |
|
|
16
|
+
| `GET <FUNCTION_URL>/authorize` | The hub-address step of account linking (below): a form in the CamStack page style (en/it), then — after checking the hub — a redirect to the hub's OAuth `/authorize`. |
|
|
17
17
|
| `POST <FUNCTION_URL>/token` | Checks the account-linking client id/secret, decodes the `hubUrl` claim, proxies the token request to the hub. |
|
|
18
18
|
| Smart Home directive | Forwards to `${hubUrl}/addon/export-alexa/directive`, adding `X-CamStack-Broker: <broker origin>` when the broker is on. |
|
|
19
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`). |
|
|
@@ -30,6 +30,32 @@ reached (network error, or a tunnel's 502–504 / 52x page) is
|
|
|
30
30
|
`ACCEPT_GRANT_FAILED`. The `/authorize` page accepts only an `https://` hub URL
|
|
31
31
|
and shows its error on the page.
|
|
32
32
|
|
|
33
|
+
### The hub-address step (`/authorize`, `authorize-page.js`)
|
|
34
|
+
|
|
35
|
+
Alexa opens this URL in the user's browser; the Lambda cannot know the hub, so
|
|
36
|
+
the operator types its public HTTPS address (CamStack shows it under
|
|
37
|
+
Connectivity → Alexa → "Hub address to enter in the Alexa app"). The page is the
|
|
38
|
+
CamStack page family — `page-style.js` is generated from the hub's page shell
|
|
39
|
+
(`npx tsx scripts/gen-alexa-lambda-page-style.ts`; a spec fails when it drifts) —
|
|
40
|
+
and runs no script. The address is **checked before the browser leaves**:
|
|
41
|
+
|
|
42
|
+
| Reason code shown on the page | Meaning |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `invalid_url` / `not_https` | not a web address / not `https://` |
|
|
45
|
+
| `local_address` | `localhost`, a private or tailnet IP, `.local` … — the Lambda runs in the cloud |
|
|
46
|
+
| `unreachable` | no answer to `GET /api/oauth2/integrations` (6 s) |
|
|
47
|
+
| `tls_error` | answered, but the certificate is not trusted by the Lambda's CA store |
|
|
48
|
+
| `not_camstack` | answered, but not with a CamStack discovery document |
|
|
49
|
+
| `integration_missing` / `hub_starting` | the hub does not serve `export-alexa` / is still registering it |
|
|
50
|
+
|
|
51
|
+
A good hub gets a 302 to `<hub>/api/oauth2/authorize?…&integration=export-alexa`.
|
|
52
|
+
From there the hub answers: consent on its own page; a refused request lands on
|
|
53
|
+
the hub's `/sso-complete` page with a reason code. **Changing the destination
|
|
54
|
+
hub** is the same step again: unlink on the old hub (Alexa export → Account link
|
|
55
|
+
→ Unlink now), disable/enable the skill in the Alexa app, and enter the new
|
|
56
|
+
address when asked. Tokens carry their `hubUrl` claim, so an existing link keeps
|
|
57
|
+
its old hub until it is re-linked.
|
|
58
|
+
|
|
33
59
|
### The two credential pairs
|
|
34
60
|
|
|
35
61
|
| Pair | SSM parameter (SecureString) | Authenticates | Who else holds it |
|
|
@@ -63,7 +89,7 @@ re-link, and lapses after 180 days without a refresh (DynamoDB TTL).
|
|
|
63
89
|
runtime. Build:
|
|
64
90
|
|
|
65
91
|
```bash
|
|
66
|
-
cd lambda-template && rm -f ../camstack-alexa-lambda.zip && zip -j ../camstack-alexa-lambda.zip *.js
|
|
92
|
+
cd lambda-template && rm -f ../camstack-alexa-lambda.zip && zip -j ../camstack-alexa-lambda.zip *.js # includes authorize-page.js and the generated page-style.js
|
|
67
93
|
```
|
|
68
94
|
|
|
69
95
|
## Deployment
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /authorize` — the step of Alexa account linking where the operator says
|
|
3
|
+
* WHICH hub the skill talks to. Alexa sends the user's browser here (it is the
|
|
4
|
+
* skill's "authorization URI"); the hub is not known yet, so it has to be asked.
|
|
5
|
+
*
|
|
6
|
+
* Two parts, no script:
|
|
7
|
+
*
|
|
8
|
+
* 1. No `hubUrl` yet: the form. It is the CamStack page family — the hub's own
|
|
9
|
+
* stylesheet and brand mark (`page-style.js`, generated from the hub's page
|
|
10
|
+
* shell), en/it — not a hand-written page.
|
|
11
|
+
* 2. `hubUrl` given: the address is CHECKED before the browser is sent there.
|
|
12
|
+
* Syntax and `https` first, then not a local address (this runs in the
|
|
13
|
+
* cloud and cannot reach one), then one unauthenticated discovery request
|
|
14
|
+
* (`GET /api/oauth2/integrations`) that proves it is a CamStack hub, that
|
|
15
|
+
* this Lambda's TLS trust accepts its certificate, and that it serves
|
|
16
|
+
* Alexa linking. Any failure re-renders the form with a plain sentence and a
|
|
17
|
+
* reason CODE; success is a 302 to the hub's own consent page.
|
|
18
|
+
*
|
|
19
|
+
* The hub's address is always TYPED here: a cloud function cannot read the hub's
|
|
20
|
+
* `local-network` roles. The hub shows the value to enter (its public endpoint)
|
|
21
|
+
* in the Alexa export settings.
|
|
22
|
+
*
|
|
23
|
+
* Nothing sensitive passes through: the account-linking parameters are the
|
|
24
|
+
* ones Amazon sent, escaped into hidden fields and forwarded untouched.
|
|
25
|
+
*/
|
|
26
|
+
'use strict'
|
|
27
|
+
|
|
28
|
+
const crypto = require('node:crypto')
|
|
29
|
+
|
|
30
|
+
const { STYLE, LOGO_DARK, LOGO_LIGHT } = require('./page-style.js')
|
|
31
|
+
|
|
32
|
+
const INTEGRATION_ID = 'export-alexa'
|
|
33
|
+
|
|
34
|
+
/** Why a hub address was refused — the codes the page and the logs share. */
|
|
35
|
+
const REASONS = [
|
|
36
|
+
'invalid_url',
|
|
37
|
+
'not_https',
|
|
38
|
+
'local_address',
|
|
39
|
+
'unreachable',
|
|
40
|
+
'tls_error',
|
|
41
|
+
'not_camstack',
|
|
42
|
+
'integration_missing',
|
|
43
|
+
'hub_starting',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const STRINGS = {
|
|
47
|
+
en: {
|
|
48
|
+
title: 'CamStack · Link your hub',
|
|
49
|
+
heading: 'Link CamStack to Alexa',
|
|
50
|
+
intro:
|
|
51
|
+
'Enter the public HTTPS address of your CamStack hub. Alexa will then ask that hub to authorise this Echo.',
|
|
52
|
+
label: 'Hub address',
|
|
53
|
+
placeholder: 'https://camstack.example.com',
|
|
54
|
+
hint: 'The address shown in CamStack under Connectivity → Alexa. It must be reachable from the internet (a tunnel, a reverse proxy or a public address).',
|
|
55
|
+
submit: 'Continue',
|
|
56
|
+
code: 'Code: ',
|
|
57
|
+
reasons: {
|
|
58
|
+
invalid_url:
|
|
59
|
+
'That is not a web address. Enter it in full, for example https://camstack.example.com.',
|
|
60
|
+
not_https: 'The address must start with https://. Alexa only talks to hubs over HTTPS.',
|
|
61
|
+
local_address:
|
|
62
|
+
'That address is only reachable inside your network. Alexa connects from the internet, so use the hub’s public address.',
|
|
63
|
+
unreachable:
|
|
64
|
+
'Could not reach that address from the internet. Check the address, and that the hub is running and published.',
|
|
65
|
+
tls_error:
|
|
66
|
+
'The hub answered, but its HTTPS certificate is not trusted. Use an address with a certificate from a public authority.',
|
|
67
|
+
not_camstack: 'That address answers, but it is not a CamStack hub (or the hub is too old).',
|
|
68
|
+
integration_missing:
|
|
69
|
+
'This hub does not offer Alexa linking. Install and enable the Alexa export on it, then try again.',
|
|
70
|
+
hub_starting: 'The hub is still starting up. Wait a moment and try again.',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
it: {
|
|
74
|
+
title: 'CamStack · Collega il tuo hub',
|
|
75
|
+
heading: 'Collega CamStack ad Alexa',
|
|
76
|
+
intro:
|
|
77
|
+
'Inserisci l’indirizzo HTTPS pubblico del tuo hub CamStack. Alexa chiederà poi a quell’hub di autorizzare questo Echo.',
|
|
78
|
+
label: 'Indirizzo dell’hub',
|
|
79
|
+
placeholder: 'https://camstack.example.com',
|
|
80
|
+
hint: 'L’indirizzo mostrato in CamStack sotto Connettività → Alexa. Deve essere raggiungibile da internet (un tunnel, un reverse proxy o un indirizzo pubblico).',
|
|
81
|
+
submit: 'Continua',
|
|
82
|
+
code: 'Codice: ',
|
|
83
|
+
reasons: {
|
|
84
|
+
invalid_url:
|
|
85
|
+
'Non è un indirizzo web. Inseriscilo per intero, ad esempio https://camstack.example.com.',
|
|
86
|
+
not_https: 'L’indirizzo deve iniziare con https://. Alexa parla con gli hub solo via HTTPS.',
|
|
87
|
+
local_address:
|
|
88
|
+
'Quell’indirizzo è raggiungibile solo dentro la tua rete. Alexa si collega da internet: usa l’indirizzo pubblico dell’hub.',
|
|
89
|
+
unreachable:
|
|
90
|
+
'Impossibile raggiungere quell’indirizzo da internet. Controlla l’indirizzo e che l’hub sia acceso e pubblicato.',
|
|
91
|
+
tls_error:
|
|
92
|
+
'L’hub ha risposto, ma il suo certificato HTTPS non è attendibile. Usa un indirizzo con un certificato di un’autorità pubblica.',
|
|
93
|
+
not_camstack:
|
|
94
|
+
'Quell’indirizzo risponde, ma non è un hub CamStack (oppure l’hub è troppo vecchio).',
|
|
95
|
+
integration_missing:
|
|
96
|
+
'Questo hub non offre il collegamento ad Alexa. Installa e abilita l’export Alexa, poi riprova.',
|
|
97
|
+
hub_starting: 'L’hub si sta ancora avviando. Attendi un momento e riprova.',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function pickLang(params, headers) {
|
|
103
|
+
const asked = (params.get('lang') || '').slice(0, 2).toLowerCase()
|
|
104
|
+
if (asked === 'it' || asked === 'en') return asked
|
|
105
|
+
const accept = String(
|
|
106
|
+
(headers && (headers['accept-language'] || headers['Accept-Language'])) || '',
|
|
107
|
+
)
|
|
108
|
+
for (const part of accept.split(',')) {
|
|
109
|
+
const tag = part.trim().slice(0, 2).toLowerCase()
|
|
110
|
+
if (tag === 'it' || tag === 'en') return tag
|
|
111
|
+
}
|
|
112
|
+
return 'en'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Escapes text for an HTML text node or a double-quoted attribute (as the hub's page shell does). */
|
|
116
|
+
function escapeHtml(text) {
|
|
117
|
+
return String(text).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sha256Source(text) {
|
|
121
|
+
return `'sha256-${crypto.createHash('sha256').update(text).digest('base64')}'`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The CSP of the page: the one inline style pinned by hash, no script, no framing. */
|
|
125
|
+
function pageCsp() {
|
|
126
|
+
return [
|
|
127
|
+
"default-src 'none'",
|
|
128
|
+
`style-src ${sha256Source(STYLE)}`,
|
|
129
|
+
"base-uri 'none'",
|
|
130
|
+
// The form submits to this Lambda, which answers with a redirect to the
|
|
131
|
+
// operator's hub: browsers apply form-action to that redirect too.
|
|
132
|
+
"form-action 'self' https:",
|
|
133
|
+
"frame-ancestors 'none'",
|
|
134
|
+
].join('; ')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
|
|
138
|
+
|
|
139
|
+
function isLocalHost(hostname) {
|
|
140
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
141
|
+
if (host === 'localhost' || host.endsWith('.localhost')) return true
|
|
142
|
+
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.lan')) return true
|
|
143
|
+
if (host === '::1' || host === '::' || host.startsWith('fe80:')) return true
|
|
144
|
+
if (/^f[cd][0-9a-f]{2}:/.test(host)) return true
|
|
145
|
+
const m = IPV4.exec(host)
|
|
146
|
+
if (m) {
|
|
147
|
+
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
148
|
+
return (
|
|
149
|
+
a === 0 ||
|
|
150
|
+
a === 10 ||
|
|
151
|
+
a === 127 ||
|
|
152
|
+
(a === 169 && b === 254) ||
|
|
153
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
154
|
+
(a === 192 && b === 168) ||
|
|
155
|
+
(a === 100 && b >= 64 && b <= 127)
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `{ ok: true, base }` (origin + path, no trailing slash) or `{ ok: false, reason }`. */
|
|
162
|
+
function parseHubAddress(raw) {
|
|
163
|
+
let url
|
|
164
|
+
try {
|
|
165
|
+
url = new URL(String(raw).trim())
|
|
166
|
+
} catch {
|
|
167
|
+
return { ok: false, reason: 'invalid_url' }
|
|
168
|
+
}
|
|
169
|
+
if (url.protocol === 'http:') return { ok: false, reason: 'not_https' }
|
|
170
|
+
if (url.protocol !== 'https:' || !url.hostname) return { ok: false, reason: 'invalid_url' }
|
|
171
|
+
if (isLocalHost(url.hostname)) return { ok: false, reason: 'local_address' }
|
|
172
|
+
return { ok: true, base: url.origin + url.pathname.replace(/\/+$/, '') }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const TLS_CODES = new Set([
|
|
176
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
177
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
178
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
179
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
180
|
+
'CERT_HAS_EXPIRED',
|
|
181
|
+
'CERT_NOT_YET_VALID',
|
|
182
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
183
|
+
'HOSTNAME_MISMATCH',
|
|
184
|
+
])
|
|
185
|
+
|
|
186
|
+
function classifyNetworkError(err) {
|
|
187
|
+
const code = (err && (err.code || (err.cause && err.cause.code))) || ''
|
|
188
|
+
if (TLS_CODES.has(code) || /certificate/i.test(String((err && err.message) || ''))) {
|
|
189
|
+
return 'tls_error'
|
|
190
|
+
}
|
|
191
|
+
return 'unreachable'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Prove `base` is a CamStack hub that serves Alexa linking, as THIS Lambda sees it. */
|
|
195
|
+
async function probeHub(base, deps) {
|
|
196
|
+
let res
|
|
197
|
+
try {
|
|
198
|
+
res = await deps.fetch(`${base}/api/oauth2/integrations`, {
|
|
199
|
+
method: 'GET',
|
|
200
|
+
headers: { accept: 'application/json' },
|
|
201
|
+
})
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { ok: false, reason: classifyNetworkError(err) }
|
|
204
|
+
}
|
|
205
|
+
if (!res || !res.ok) return { ok: false, reason: 'not_camstack' }
|
|
206
|
+
let body
|
|
207
|
+
try {
|
|
208
|
+
body = JSON.parse(await res.text())
|
|
209
|
+
} catch {
|
|
210
|
+
return { ok: false, reason: 'not_camstack' }
|
|
211
|
+
}
|
|
212
|
+
if (!body || !Array.isArray(body.integrations)) return { ok: false, reason: 'not_camstack' }
|
|
213
|
+
const serves = body.integrations.some((i) => i && i.integrationId === INTEGRATION_ID)
|
|
214
|
+
if (serves) return { ok: true }
|
|
215
|
+
return { ok: false, reason: body.complete === false ? 'hub_starting' : 'integration_missing' }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderForm(lang, forwarded, hubUrl, reason) {
|
|
219
|
+
const t = STRINGS[lang]
|
|
220
|
+
const hidden = forwarded
|
|
221
|
+
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`)
|
|
222
|
+
.join('')
|
|
223
|
+
const error = reason
|
|
224
|
+
? `<p class="error" role="alert" data-reason="${escapeHtml(reason)}">${escapeHtml(t.reasons[reason])}</p><p class="hint">${escapeHtml(t.code)}<code>${escapeHtml(reason)}</code></p>`
|
|
225
|
+
: ''
|
|
226
|
+
const body =
|
|
227
|
+
`<p>${escapeHtml(t.intro)}</p>` +
|
|
228
|
+
`<form method="get" action="/authorize">${hidden}` +
|
|
229
|
+
`<div class="field"><label for="hubUrl">${escapeHtml(t.label)}</label>` +
|
|
230
|
+
`<input id="hubUrl" name="hubUrl" type="url" inputmode="url" autocomplete="url" spellcheck="false" autocapitalize="off" required placeholder="${escapeHtml(t.placeholder)}" value="${escapeHtml(hubUrl)}"></div>` +
|
|
231
|
+
`<p class="hint">${escapeHtml(t.hint)}</p>${error}` +
|
|
232
|
+
`<button class="btn" type="submit">${escapeHtml(t.submit)}</button></form>`
|
|
233
|
+
const html = `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><meta name="referrer" content="no-referrer"><title>${escapeHtml(t.title)}</title><style>${STYLE}</style></head><body><main><div class="brand">${LOGO_DARK}${LOGO_LIGHT}</div><div class="card"><h1>${escapeHtml(t.heading)}</h1>${body}</div></main></body></html>`
|
|
234
|
+
return {
|
|
235
|
+
statusCode: 200,
|
|
236
|
+
headers: {
|
|
237
|
+
'content-type': 'text/html; charset=utf-8',
|
|
238
|
+
'cache-control': 'no-store',
|
|
239
|
+
'content-security-policy': pageCsp(),
|
|
240
|
+
'referrer-policy': 'no-referrer',
|
|
241
|
+
'x-content-type-options': 'nosniff',
|
|
242
|
+
},
|
|
243
|
+
body: html,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function handleAuthorizePage(event, deps) {
|
|
248
|
+
const params = new URLSearchParams(event.rawQueryString || '')
|
|
249
|
+
const lang = pickLang(params, event.headers)
|
|
250
|
+
const hubUrl = params.get('hubUrl') || ''
|
|
251
|
+
// What Amazon sent, forwarded to the hub's own authorize untouched.
|
|
252
|
+
const forwarded = [...params.entries()].filter(([k]) => k !== 'hubUrl' && k !== 'integration')
|
|
253
|
+
|
|
254
|
+
if (hubUrl === '') return renderForm(lang, forwarded, '', null)
|
|
255
|
+
|
|
256
|
+
const parsed = parseHubAddress(hubUrl)
|
|
257
|
+
if (!parsed.ok) return renderForm(lang, forwarded, hubUrl, parsed.reason)
|
|
258
|
+
const probe = await probeHub(parsed.base, deps)
|
|
259
|
+
if (!probe.ok) {
|
|
260
|
+
deps.log.warn('authorize hub check refused', {
|
|
261
|
+
reason: probe.reason,
|
|
262
|
+
host: new URL(parsed.base).host,
|
|
263
|
+
})
|
|
264
|
+
return renderForm(lang, forwarded, hubUrl, probe.reason)
|
|
265
|
+
}
|
|
266
|
+
const query = new URLSearchParams(forwarded)
|
|
267
|
+
query.set('integration', INTEGRATION_ID)
|
|
268
|
+
return {
|
|
269
|
+
statusCode: 302,
|
|
270
|
+
headers: {
|
|
271
|
+
location: `${parsed.base}/api/oauth2/authorize?${query.toString()}`,
|
|
272
|
+
'cache-control': 'no-store',
|
|
273
|
+
},
|
|
274
|
+
body: '',
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = { handleAuthorizePage, parseHubAddress, REASONS, STRINGS }
|
package/lambda-template/index.js
CHANGED
|
@@ -55,6 +55,7 @@ const {
|
|
|
55
55
|
isBrokerPath,
|
|
56
56
|
shortId,
|
|
57
57
|
} = require('./broker.js')
|
|
58
|
+
const { handleAuthorizePage } = require('./authorize-page.js')
|
|
58
59
|
const { traceDirective } = require('./debug-trace.js')
|
|
59
60
|
const { timedFetch } = require('./http.js')
|
|
60
61
|
const { createSecretSource } = require('./secrets.js')
|
|
@@ -150,86 +151,12 @@ function createHandler(overrides) {
|
|
|
150
151
|
|
|
151
152
|
async function handleLambdaUrl(event, deps) {
|
|
152
153
|
const path = event.rawPath || '/'
|
|
153
|
-
if (path === '/authorize') return handleAuthorizePage(event)
|
|
154
|
+
if (path === '/authorize') return handleAuthorizePage(event, deps)
|
|
154
155
|
if (path === '/token') return handleTokenProxy(event, deps)
|
|
155
156
|
if (isBrokerPath(path)) return handleBrokerRoute(event, deps)
|
|
156
157
|
return { statusCode: 404, headers: { 'content-type': 'text/plain' }, body: 'Not found' }
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
/**
|
|
160
|
-
* Render the "Enter your CamStack hub URL" form. Submitting the form
|
|
161
|
-
* forwards the original Alexa query params (response_type, client_id,
|
|
162
|
-
* redirect_uri, state, scope) — plus `integration=export-alexa` — to
|
|
163
|
-
* `${hubUrl}/api/oauth2/authorize`. From there the browser stays on the hub's
|
|
164
|
-
* origin until the OAuth code round-trips back to Alexa.
|
|
165
|
-
*/
|
|
166
|
-
function handleAuthorizePage(event) {
|
|
167
|
-
const qs = event.rawQueryString || ''
|
|
168
|
-
const html = `<!doctype html>
|
|
169
|
-
<html lang="en">
|
|
170
|
-
<head>
|
|
171
|
-
<meta charset="utf-8">
|
|
172
|
-
<title>CamStack · Link your hub</title>
|
|
173
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
174
|
-
<style>
|
|
175
|
-
body { font-family: system-ui, -apple-system, sans-serif; max-width: 480px; margin: 4rem auto; padding: 0 1rem; color: #1f2937; background: #f9fafb; }
|
|
176
|
-
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 0.75rem; padding: 2rem; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
177
|
-
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
|
178
|
-
p { font-size: 0.9rem; color: #4b5563; margin: 0 0 1.5rem; }
|
|
179
|
-
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.5rem; }
|
|
180
|
-
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; }
|
|
181
|
-
input:focus { outline: 2px solid #2563eb; outline-offset: 1px; border-color: #2563eb; }
|
|
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; }
|
|
183
|
-
button:hover { background: #1d4ed8; }
|
|
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; }
|
|
186
|
-
</style>
|
|
187
|
-
</head>
|
|
188
|
-
<body>
|
|
189
|
-
<div class="card">
|
|
190
|
-
<h1>Link CamStack to Alexa</h1>
|
|
191
|
-
<p>Enter the public HTTPS URL of your CamStack hub. Alexa will then ask the hub to authorize this Echo.</p>
|
|
192
|
-
<form id="f" novalidate>
|
|
193
|
-
<label for="hubUrl">Hub URL</label>
|
|
194
|
-
<input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
|
|
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>
|
|
197
|
-
<button type="submit">Continue</button>
|
|
198
|
-
</form>
|
|
199
|
-
</div>
|
|
200
|
-
<script>
|
|
201
|
-
document.getElementById('f').addEventListener('submit', function (ev) {
|
|
202
|
-
ev.preventDefault()
|
|
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)}
|
|
215
|
-
var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
|
|
216
|
-
window.location.href = url
|
|
217
|
-
})
|
|
218
|
-
</script>
|
|
219
|
-
</body>
|
|
220
|
-
</html>`
|
|
221
|
-
return {
|
|
222
|
-
statusCode: 200,
|
|
223
|
-
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
224
|
-
body: html,
|
|
225
|
-
}
|
|
226
|
-
}
|
|
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
|
-
|
|
233
160
|
/** Constant-time string equality that does not leak length via timing. */
|
|
234
161
|
function safeEqual(a, b) {
|
|
235
162
|
const ab = Buffer.from(String(a), 'utf8')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// biome-ignore-all format: generated file
|
|
2
|
+
// biome-ignore-all lint: generated file
|
|
3
|
+
/**
|
|
4
|
+
* GENERATED by scripts/gen-alexa-lambda-page-style.ts from the hub's page shell
|
|
5
|
+
* (server/backend/src/auth/app-page-shell.ts). Do not edit: change the shell and
|
|
6
|
+
* regenerate. The Lambda ships plain files, so the stylesheet travels as a constant.
|
|
7
|
+
*/
|
|
8
|
+
'use strict'
|
|
9
|
+
|
|
10
|
+
const STYLE = ":root{color-scheme:light dark;--spacing-xs:2px;--spacing-sm:4px;--spacing-md:8px;--spacing-lg:12px;--spacing-xl:16px;--spacing-2xl:24px;--spacing-3xl:32px;--radius-sm:4px;--radius-md:6px;--radius-lg:8px;--radius-xl:12px;--font-family:-apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;--color-primary:#e67e22;--color-primary-foreground:#ffffff;--color-background:#fafaf9;--color-background-elevated:#ffffff;--color-surface:#f5f5f4;--color-surface-hover:#e7e5e4;--color-border:#d6d3d1;--color-border-subtle:#e7e5e4;--color-foreground:#1c1917;--color-foreground-muted:#57534e;--color-foreground-subtle:#78716c;--color-foreground-disabled:#a8a29e;--color-success:#16a34a;--color-warning:#d97706;--color-danger:#dc2626;--color-info:#2563eb;--color-success-fill:#15803d;--color-success-on:#ffffff;--color-warning-fill:#b45309;--color-warning-on:#ffffff;--color-danger-fill:#dc2626;--color-danger-on:#ffffff;--color-info-fill:#2563eb;--color-info-on:#ffffff;--color-primary-fill:#c2410c;--color-primary-on:#ffffff;--color-neutral-fill:#57534e;--color-neutral-on:#ffffff}@media (prefers-color-scheme:dark){:root{--color-primary:#f59e42;--color-primary-foreground:#0c0a09;--color-background:#0c0a09;--color-background-elevated:#1c1917;--color-surface:#1c1917;--color-surface-hover:#292524;--color-border:#292524;--color-border-subtle:#1c1917;--color-foreground:#fafaf9;--color-foreground-muted:#a8a29e;--color-foreground-subtle:#78716c;--color-foreground-disabled:#57534e;--color-success:#4ade80;--color-warning:#fbbf24;--color-danger:#f87171;--color-info:#60a5fa;--color-success-fill:#4ade80;--color-success-on:#0c0a09;--color-warning-fill:#fbbf24;--color-warning-on:#0c0a09;--color-danger-fill:#f87171;--color-danger-on:#0c0a09;--color-info-fill:#60a5fa;--color-info-on:#0c0a09;--color-primary-fill:#f59e42;--color-primary-on:#0c0a09;--color-neutral-fill:#44403c;--color-neutral-on:#fafaf9}}*,*::before,*::after{box-sizing:border-box}html{background:var(--color-background)}body{margin:0;min-height:100vh;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:max(var(--spacing-xl),env(safe-area-inset-top)) max(var(--spacing-xl),env(safe-area-inset-right)) max(var(--spacing-xl),env(safe-area-inset-bottom)) max(var(--spacing-xl),env(safe-area-inset-left));background:var(--color-background);color:var(--color-foreground);font-family:var(--font-family);font-size:14px;line-height:20px;-webkit-text-size-adjust:100%}main{width:100%;max-width:24rem}.brand{display:flex;justify-content:center;margin-bottom:var(--spacing-3xl)}.logo{height:3rem;width:auto}.logo-dark{display:none}@media (prefers-color-scheme:dark){.logo-dark{display:block}.logo-light{display:none}}.card{display:flex;flex-direction:column;gap:var(--spacing-xl);padding:var(--spacing-2xl);border:1px solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface);box-shadow:0 20px 25px -5px rgb(0 0 0/.1),0 8px 10px -6px rgb(0 0 0/.1)}.card h1{margin:0;font-size:16px;line-height:22px;font-weight:600;text-align:center;color:var(--color-foreground)}.card p{margin:0;color:var(--color-foreground-muted)}.card strong{color:var(--color-foreground);font-weight:600;overflow-wrap:anywhere}.card .hint{font-size:12px;line-height:16px;color:var(--color-foreground-subtle)}.btn{display:flex;align-items:center;justify-content:center;width:100%;min-height:44px;padding:10px 16px;border:1px solid transparent;border-radius:var(--radius-lg);background:var(--color-primary);color:var(--color-primary-foreground);font:inherit;font-weight:600;text-decoration:none;cursor:pointer;box-shadow:0 4px 6px -1px color-mix(in srgb,var(--color-primary) 20%,transparent)}.btn:hover{background:color-mix(in srgb,var(--color-primary) 90%,transparent)}.btn:disabled{opacity:.5;cursor:not-allowed;box-shadow:none}.btn.secondary{background:var(--color-background);color:var(--color-foreground);border-color:var(--color-border);box-shadow:none}.btn.secondary:hover{background:var(--color-surface-hover)}.link{display:block;text-align:center;padding:var(--spacing-md);font-size:12px;line-height:16px;color:var(--color-foreground-subtle);text-decoration:none}.link:hover{color:var(--color-foreground)}.error{padding:8px 12px;border:1px solid color-mix(in srgb,var(--color-danger) 20%,transparent);border-radius:var(--radius-md);background:color-mix(in srgb,var(--color-danger) 10%,transparent);color:var(--color-danger);font-size:12px;line-height:16px}.card p.error{color:var(--color-danger)}.error:empty{display:none}.btn:focus-visible,.link:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow-wrap:anywhere}.field{display:flex;flex-direction:column;gap:var(--spacing-md)}.field label{font-size:12px;line-height:16px;font-weight:500;color:var(--color-foreground-muted)}input[type=url],input[type=text]{width:100%;min-height:44px;padding:10px 12px;border:1px solid var(--color-border);border-radius:var(--radius-lg);background:var(--color-background);color:var(--color-foreground);font:inherit}input[type=url]:focus-visible,input[type=text]:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}form{display:contents}.actions{display:flex;flex-direction:column;gap:var(--spacing-md)}"
|
|
11
|
+
const LOGO_DARK = "<svg class=\"logo logo-dark\" role=\"img\" aria-label=\"CamStack\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 200 48\" fill=\"none\"><g transform=\"translate(4, 2) scale(0.68)\"><rect x=\"8\" y=\"28\" width=\"48\" height=\"28\" rx=\"6\" fill=\"#292524\"/><rect x=\"12\" y=\"16\" width=\"40\" height=\"28\" rx=\"6\" fill=\"#44403c\"/><rect x=\"16\" y=\"4\" width=\"32\" height=\"28\" rx=\"6\" fill=\"#f59e42\"/><circle cx=\"32\" cy=\"18\" r=\"8\" fill=\"#0c0a09\" opacity=\"0.3\"/><circle cx=\"32\" cy=\"18\" r=\"5\" fill=\"#0c0a09\" opacity=\"0.2\"/><circle cx=\"32\" cy=\"18\" r=\"2.5\" fill=\"#fafaf9\" opacity=\"0.8\"/></g><text x=\"52\" y=\"31\" font-family=\"system-ui, -apple-system, sans-serif\" font-size=\"26\" font-weight=\"700\" letter-spacing=\"-0.5\" fill=\"#fafaf9\">Cam<tspan fill=\"#f59e42\">Stack</tspan></text></svg>"
|
|
12
|
+
const LOGO_LIGHT = "<svg class=\"logo logo-light\" role=\"img\" aria-label=\"CamStack\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 200 48\" fill=\"none\"><g transform=\"translate(4, 2) scale(0.68)\"><rect x=\"8\" y=\"28\" width=\"48\" height=\"28\" rx=\"6\" fill=\"#d6d3d1\"/><rect x=\"12\" y=\"16\" width=\"40\" height=\"28\" rx=\"6\" fill=\"#a8a29e\"/><rect x=\"16\" y=\"4\" width=\"32\" height=\"28\" rx=\"6\" fill=\"#e67e22\"/><circle cx=\"32\" cy=\"18\" r=\"8\" fill=\"#fafaf9\" opacity=\"0.3\"/><circle cx=\"32\" cy=\"18\" r=\"5\" fill=\"#fafaf9\" opacity=\"0.2\"/><circle cx=\"32\" cy=\"18\" r=\"2.5\" fill=\"#1c1917\" opacity=\"0.7\"/></g><text x=\"52\" y=\"31\" font-family=\"system-ui, -apple-system, sans-serif\" font-size=\"26\" font-weight=\"700\" letter-spacing=\"-0.5\" fill=\"#1c1917\">Cam<tspan fill=\"#e67e22\">Stack</tspan></text></svg>"
|
|
13
|
+
|
|
14
|
+
module.exports = { STYLE, LOGO_DARK, LOGO_LIGHT }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-export-alexa",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.148",
|
|
4
4
|
"description": "Alexa Smart Home Skill export — hub-side OAuth provider + directive handler. The companion AWS Lambda forwards Alexa directives to this addon's /addon/export-alexa/directive endpoint.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|