Compare commits

..

2 commits

Author SHA1 Message Date
3c8d60e614 Merge pull request 'fix(device-auth): inline helpers into each hook handler (JSVM scope)' (#4) from fix/device-auth-jsvm-scope into main
Reviewed-on: #4
2026-07-19 17:48:46 +00:00
0d29431734 fix(device-auth): inline helpers into each hook handler (JSVM scope)
PocketBase runs every routerAdd/cronAdd handler in its OWN isolated JSVM, so the
file-level helpers/consts (nowSeconds, genCode, CODE_ALPHABET, the TTLs, and the
DEVICE_APPROVE_HTML page) were invisible inside the callbacks — every route threw
`ReferenceError: <name> is not defined` at request time (POST /request, the
cleanup cron, GET /device). A runtime-only trap that only surfaces on a live
PocketBase, which the original PR couldn't exercise.

Define what each handler needs *inside* it (local scope works). No behavior
change; syntax-checked with `node --check`.

Fixes the deploy of device-auth (amber-app #12).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:48:03 +02:00
2 changed files with 23 additions and 18 deletions

View file

@ -14,26 +14,24 @@
// stores and forwards it as an opaque blob and never holds the key — see // stores and forwards it as an opaque blob and never holds the key — see
// `pb_hooks/device_page.pb.js` and the app's `device_key_transfer.dart`. // `pb_hooks/device_page.pb.js` and the app's `device_key_transfer.dart`.
const DEVICE_TTL_SECONDS = 300 // 5 min to approve a fresh request // NOTE: PocketBase runs every routerAdd/cronAdd handler in its OWN isolated JSVM,
const PICKUP_TTL_SECONDS = 120 // after approval, the TV has this long to fetch // so file-level consts/functions are NOT visible inside the callbacks — referencing
// them throws `ReferenceError: x is not defined` at request time. So everything a
// Human code: 8 chars, unambiguous alphabet (Crockford minus I L O U), shown as // handler needs (nowSeconds, the code alphabet, the TTLs) is defined *inside* it.
// XXXX-XXXX. Stored without the dash; the app + page normalize before lookup. // Keep it that way; don't hoist helpers back to file scope.
const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
function nowSeconds() {
return Math.floor(Date.now() / 1000)
}
function genCode() {
return $security.randomStringWithAlphabet(8, CODE_ALPHABET)
}
// ── POST /api/device-auth/request ───────────────────────────────────────────── // ── POST /api/device-auth/request ─────────────────────────────────────────────
// Public. The TV creates a sign-in request and gets back the code to show, the // Public. The TV creates a sign-in request and gets back the code to show, the
// pollSecret only it will hold, and the id + expiry. Body: { deviceName, // pollSecret only it will hold, and the id + expiry. Body: { deviceName,
// devicePubKey }. // devicePubKey }.
routerAdd("POST", "/api/device-auth/request", (e) => { routerAdd("POST", "/api/device-auth/request", (e) => {
const nowSeconds = () => Math.floor(Date.now() / 1000)
const DEVICE_TTL_SECONDS = 300 // 5 min to approve a fresh request
// Human code: 8 chars, unambiguous alphabet (Crockford minus I L O U), shown as
// XXXX-XXXX. Stored without the dash; the app + page normalize before lookup.
const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
const genCode = () => $security.randomStringWithAlphabet(8, CODE_ALPHABET)
const data = new DynamicModel({ deviceName: "", devicePubKey: "" }) const data = new DynamicModel({ deviceName: "", devicePubKey: "" })
e.bindBody(data) e.bindBody(data)
@ -77,6 +75,7 @@ routerAdd("POST", "/api/device-auth/request", (e) => {
// and to read the TV's ephemeral public key it encrypts the vault key to. Returns // and to read the TV's ephemeral public key it encrypts the vault key to. Returns
// nothing sensitive (no pollSecret, no token). // nothing sensitive (no pollSecret, no token).
routerAdd("GET", "/api/device-auth/info", (e) => { routerAdd("GET", "/api/device-auth/info", (e) => {
const nowSeconds = () => Math.floor(Date.now() / 1000)
const code = (e.request.url.query().get("code") || "").toUpperCase().trim() const code = (e.request.url.query().get("code") || "").toUpperCase().trim()
if (!code) throw new BadRequestError("missing code") if (!code) throw new BadRequestError("missing code")
@ -103,6 +102,7 @@ routerAdd("GET", "/api/device-auth/info", (e) => {
// The minted token + the E2E vault-key ciphertext are only ever returned here, to // The minted token + the E2E vault-key ciphertext are only ever returned here, to
// a caller holding the matching pollSecret. // a caller holding the matching pollSecret.
routerAdd("GET", "/api/device-auth/poll", (e) => { routerAdd("GET", "/api/device-auth/poll", (e) => {
const nowSeconds = () => Math.floor(Date.now() / 1000)
const id = e.request.url.query().get("id") || "" const id = e.request.url.query().get("id") || ""
const secret = e.request.url.query().get("secret") || "" const secret = e.request.url.query().get("secret") || ""
if (!id || !secret) throw new BadRequestError("missing id/secret") if (!id || !secret) throw new BadRequestError("missing id/secret")
@ -169,6 +169,8 @@ routerAdd("GET", "/api/device-auth/poll", (e) => {
// token for the caller and stores the E2E vault-key ciphertext the page produced. // token for the caller and stores the E2E vault-key ciphertext the page produced.
// Body: { code, keyCiphertext }. // Body: { code, keyCiphertext }.
routerAdd("POST", "/api/device-auth/approve", (e) => { routerAdd("POST", "/api/device-auth/approve", (e) => {
const nowSeconds = () => Math.floor(Date.now() / 1000)
const PICKUP_TTL_SECONDS = 120 // after approval, the TV has this long to fetch
const data = new DynamicModel({ code: "", keyCiphertext: "" }) const data = new DynamicModel({ code: "", keyCiphertext: "" })
e.bindBody(data) e.bindBody(data)
const code = (data.code || "").toUpperCase().trim() const code = (data.code || "").toUpperCase().trim()
@ -233,6 +235,7 @@ routerAdd("POST", "/api/device-auth/decline", (e) => {
// pending/denied rows. The device list keeps the approved rows (status + user + // pending/denied rows. The device list keeps the approved rows (status + user +
// created), just without any live secret at rest. // created), just without any live secret at rest.
cronAdd("device_auth_cleanup", "*/5 * * * *", () => { cronAdd("device_auth_cleanup", "*/5 * * * *", () => {
const nowSeconds = () => Math.floor(Date.now() / 1000)
const now = nowSeconds() const now = nowSeconds()
try { try {
const stale = $app.findRecordsByFilter( const stale = $app.findRecordsByFilter(

View file

@ -20,11 +20,11 @@
// reaches the server (E2E for this one blob). The minted token is delivered by the // reaches the server (E2E for this one blob). The minted token is delivered by the
// poll route, not here. // poll route, not here.
// NOTE: PocketBase runs this route handler in its own isolated JSVM — a file-level
// `const DEVICE_APPROVE_HTML` is invisible inside it (ReferenceError at request
// time). So the whole self-contained page is defined *inside* the handler.
routerAdd("GET", "/device", (e) => { routerAdd("GET", "/device", (e) => {
return e.html(200, DEVICE_APPROVE_HTML) const DEVICE_APPROVE_HTML = `<!doctype html>
})
const DEVICE_APPROVE_HTML = `<!doctype html>
<html lang="cs"> <html lang="cs">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@ -277,3 +277,5 @@ loadInfo();
</script> </script>
</body> </body>
</html>` </html>`
return e.html(200, DEVICE_APPROVE_HTML)
})