From d495d1e164b1f15fd90ccf0cd3c1a926c4a8a1e0 Mon Sep 17 00:00:00 2001 From: Brittany Ellich Date: Mon, 1 Jun 2026 07:36:48 -0700 Subject: [PATCH] feat(qr): add connect-URL matcher with tests Co-Authored-By: Claude Opus 4.8 --- web/resources/static/js/qr-scan.js | 40 ++++++++++++++++++++++ web/resources/static/js/qr-scan.test.js | 45 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 web/resources/static/js/qr-scan.js create mode 100644 web/resources/static/js/qr-scan.test.js diff --git a/web/resources/static/js/qr-scan.js b/web/resources/static/js/qr-scan.js new file mode 100644 index 0000000..2bd9fe1 --- /dev/null +++ b/web/resources/static/js/qr-scan.js @@ -0,0 +1,40 @@ +// qr-scan.js — live QR scanner for the "scan to connect" button. +// +// Tapping any [data-qr-scan] button opens a fullscreen overlay with the rear +// camera, decodes frames with jsQR, and navigates to a decoded atmo.quest +// connect URL (/c/{did} or /c/l/{id}) on the current origin. Non-connect codes +// are ignored with a transient hint. If the live camera is unavailable or +// permission is denied, it falls back to a single still capture via the +// button's hidden , decoded the same +// way. + +(function () { + "use strict"; + + // --- Pure URL matching (unit-tested) -------------------------------- + // Returns the same-origin path to navigate to for an atmo.quest connect + // QR, or null if `text` is not one of our connect URLs. The origin is + // dropped on purpose so a QR baked with the production PublicURL still + // works when scanned against a local/dev origin. + function parseConnectPath(text, origin) { + let url; + try { + url = new URL(text, origin); + } catch (e) { + return null; + } + const m = url.pathname.match(/^\/c\/(?:l\/([^/]+)|([^/]+))\/?$/); + if (!m) return null; + // Reject the bare "/c/l" case (segment captured as "l", no local id). + if (!m[1] && m[2] === "l") return null; + return url.pathname + url.search + url.hash; + } + + // Export for Node tests; harmless in the browser (no `module`). + if (typeof module !== "undefined" && module.exports) { + module.exports = { parseConnectPath }; + } + + // Browser-only wiring is added in a later task; bail out under Node. + if (typeof document === "undefined") return; +})(); diff --git a/web/resources/static/js/qr-scan.test.js b/web/resources/static/js/qr-scan.test.js new file mode 100644 index 0000000..1eb3267 --- /dev/null +++ b/web/resources/static/js/qr-scan.test.js @@ -0,0 +1,45 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { parseConnectPath } = require("./qr-scan.js"); + +const ORIGIN = "http://127.0.0.1:9090"; + +test("matches /c/{did} and returns the same-origin path", () => { + assert.strictEqual( + parseConnectPath("https://atmo.quest/c/did:plc:abc", ORIGIN), + "/c/did:plc:abc", + ); +}); + +test("matches /c/l/{local_id}", () => { + assert.strictEqual( + parseConnectPath("https://atmo.quest/c/l/local_abc-123", ORIGIN), + "/c/l/local_abc-123", + ); +}); + +test("drops a foreign origin, keeps the connect path", () => { + assert.strictEqual( + parseConnectPath("https://prod.example.com/c/did:plc:xyz", ORIGIN), + "/c/did:plc:xyz", + ); +}); + +test("preserves query and hash", () => { + assert.strictEqual( + parseConnectPath("https://atmo.quest/c/did:plc:abc?x=1#y", ORIGIN), + "/c/did:plc:abc?x=1#y", + ); +}); + +test("rejects a non-connect URL", () => { + assert.strictEqual(parseConnectPath("https://example.com/foo", ORIGIN), null); +}); + +test("rejects plain garbage text", () => { + assert.strictEqual(parseConnectPath("just some text $$$", ORIGIN), null); +}); + +test("rejects the bare /c/l with no id", () => { + assert.strictEqual(parseConnectPath("https://atmo.quest/c/l", ORIGIN), null); +}); -- 2.51.2