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
This commit is contained in:
richiexec 2026-07-19 17:48:46 +00:00
commit 3c8d60e614
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,10 +20,10 @@
// 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>
@ -277,3 +277,5 @@ loadInfo();
</script> </script>
</body> </body>
</html>` </html>`
return e.html(200, DEVICE_APPROVE_HTML)
})