Files
bitlogin/packages/widget/src/qr.test.ts
T
thePR0M3TH3ANandClaude Fable 5 49682dd75c NIP-46 sign-in: remote-signer sessions behind the same facade, memory-only
Implements login-methods.md §LM5 phase one. Core gains nostr/nip46.ts -- a
Nip46Client speaking kind-24133 RPC with NIP-44 transport encryption,
bunker:// parsing, nostrconnect listen (secret echo is proof-of-scan),
auth_url surfacing, and typed deadline errors -- plus subscribeLive on
RelayConnection with re-REQ on reconnect, and live post-EOSE delivery in
the MockRelay so tests exercise real round trips against a fake bunker.

The ephemeral client key and Nip46Client live in the crypto worker
(§LM5.4); nip46* actions bypass the serial queue so a signer waiting
minutes for human approval cannot block other worker calls, and the
signer's auth_url reaches the UI as the worker's first notification frame.

The widget's connect screen runs both legs at once: a nostrconnect QR --
rendered as dot-style inline SVG with rounded finder eyes, per owner
preference -- for phone signers, and a bunker:// paste box (password
input; the URI carries a token). window.nostr is now an element-routed
provider, so a NIP-46 session claims the slot and hosts reading it get
the remote signer transparently; NIP-07 still leaves the slot to the
extension. Sessions stay thin per §LM7: no vault, no persistence.
Persistence remains gated on the external vault review (§LM5.3).

14 new tests (core fake-bunker e2e, QR geometry, signer delegation) and
two policy pins: bunker option below the password path, element stays
web-storage-free.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 10:31:36 -04:00

62 lines
2.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import qrcodeGenerator from "qrcode-generator";
import { renderQrSvg } from "./qr.js";
const SAMPLE =
"nostrconnect://" +
"ab".repeat(32) +
"?relay=wss%3A%2F%2Frelay.example&secret=0123456789abcdef&name=BitLogin";
describe("renderQrSvg", () => {
it("renders a complete standalone SVG with backing, eyes, and dots", () => {
const svg = renderQrSvg(SAMPLE);
expect(svg.startsWith("<svg ")).toBe(true);
expect(svg.endsWith("</svg>")).toBe(true);
// One opaque backing rect plus three finder eyes of two rects each.
expect(svg.match(/<rect /gu)!.length).toBe(1 + 3 * 2);
// Data modules are circles -- the "dots" -- and there are plenty of them.
expect(svg.match(/<circle /gu)!.length).toBeGreaterThan(50);
});
it("draws exactly the dark data modules the encoder produced, minus finder zones", () => {
const qr = qrcodeGenerator(0, "M");
qr.addData(SAMPLE);
qr.make();
const n = qr.getModuleCount();
let expected = 0;
const inEye = (r: number, c: number) =>
(r < 7 && c < 7) || (r < 7 && c >= n - 7) || (r >= n - 7 && c < 7);
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
if (!inEye(r, c) && qr.isDark(r, c)) expected++;
}
}
const svg = renderQrSvg(SAMPLE);
expect(svg.match(/<circle /gu)!.length).toBe(expected);
});
it("keeps the quiet zone: no dot within four modules of the edge", () => {
const svg = renderQrSvg(SAMPLE);
const centers = [...svg.matchAll(/<circle cx="([\d.]+)" cy="([\d.]+)"/gu)].map((m) => [
Number(m[1]),
Number(m[2])
]);
const viewBox = /viewBox="0 0 (\d+) (\d+)"/u.exec(svg)!;
const size = Number(viewBox[1]);
for (const [cx, cy] of centers) {
expect(cx).toBeGreaterThanOrEqual(4);
expect(cy).toBeGreaterThanOrEqual(4);
expect(cx).toBeLessThanOrEqual(size - 4);
expect(cy).toBeLessThanOrEqual(size - 4);
}
});
it("escapes nothing it shouldn't: the payload never appears in the markup", () => {
// The QR encodes the URI as modules; the raw string (with its secret) must
// not leak into the SVG text.
const svg = renderQrSvg(SAMPLE);
expect(svg).not.toContain("nostrconnect");
expect(svg).not.toContain("secret");
});
});