From 0428c2f2aebf27b1ae3dd2022f501ea508c897d9 Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 17 Jun 2026 06:28:35 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20add=20QR=20placement=20contract=20?= =?UTF-8?q?=E2=80=94=20buildVerifyUrl=20+=20resolveQrRect=20(v1.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with @validpay/node-sdk 0.7.0 / validpay (py) 1.4.0. Adds the canonical placement contract so a position picked once (e.g. in the developer console "Try it" tool) maps to the same spot across every SDK: - buildVerifyUrl(retrievalId, key) -> verify URL with a base64url key in the fragment; feed it to a QR component (react-native-qrcode-svg, …). - resolveQrRect(placement, pageW, pageH) -> { x, y, size } in PDF bottom-left points, for stamping with your own PDF lib. - QrPlacement / QrAnchor / QrUnit types + MIN_RECOMMENDED_QR_PT. No on-device embedQr: editing PDF bytes on a phone isn't practical, so RN ships the pure contract only (seal PDFs server-side with the Node SDK, or just display the QR). Pure + dependency-free. 10 new tests; suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 33 +++++++++++ __tests__/pdf.test.ts | 90 ++++++++++++++++++++++++++++++ package.json | 13 ++--- src/index.ts | 12 ++++ src/pdf.ts | 127 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 __tests__/pdf.test.ts create mode 100644 src/pdf.ts diff --git a/README.md b/README.md index 628674e..b957479 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,39 @@ const result = await client.createBoundIntent( ); ``` +### QR placement (`buildVerifyUrl` + `resolveQrRect`) + +A verifiable document needs a scannable QR — encoding the verify URL — placed on +it. This SDK gives you the canonical placement contract, shared verbatim with +the Node and Python SDKs and the developer console's "Try it" tool, so a +position picked once maps to the exact same spot everywhere. + +```ts +import { buildVerifyUrl } from '@validpay/react-native-sdk'; +import QRCode from 'react-native-qrcode-svg'; // your QR component of choice + +const url = buildVerifyUrl(retrievalId, key); // key is base64url'd into the #fragment +; +``` + +To stamp the QR onto a generated PDF, use the placement → points helper and hand +the result to your PDF library (or do it server-side with `@validpay/node-sdk`'s +`embedQr`, which mints the PDF for you): + +```ts +import { resolveQrRect, type QrPlacement } from '@validpay/react-native-sdk'; + +const placement: QrPlacement = { anchor: 'bottom-right', x: 36, y: 36, width: 90 }; +const { x, y, size } = resolveQrRect(placement, pageWidthPt, pageHeightPt); // bottom-left points +``` + +> **Why no on-device `embedQr`?** Editing PDF bytes on a phone isn't practical, +> so React Native ships only the pure contract. Render the QR for display, or +> seal the PDF where you build it. Keep the QR **≥ 72pt (1in)** so it scans once +> printed (`MIN_RECOMMENDED_QR_PT`). + +`QrPlacement` fields: `anchor` (page corner — `top-left`/`top-right`/`bottom-left`/`bottom-right`, default `top-left`), `x`/`y` (insets from that corner), `width` (QR side), `units` (`pt`/`mm`/`in`, default `pt`), `page` (1-based). + ### Errors Every SDK and API failure is thrown as `ValidPayError`: diff --git a/__tests__/pdf.test.ts b/__tests__/pdf.test.ts new file mode 100644 index 0000000..926ed5c --- /dev/null +++ b/__tests__/pdf.test.ts @@ -0,0 +1,90 @@ +import { + buildVerifyUrl, + resolveQrRect, + MIN_RECOMMENDED_QR_PT, + ValidPayError, +} from '../src/index'; + +const W = 612; +const H = 792; // US Letter, points + +describe('buildVerifyUrl', () => { + it('builds the canonical /verify URL with the key in the fragment', () => { + expect(buildVerifyUrl('abc123', 'deadbeef')).toBe( + 'https://validpay.com/verify/abc123#key=deadbeef', + ); + }); + + it('base64url-encodes the key and percent-encodes the id', () => { + expect(buildVerifyUrl('a/b c', 'K+K/m==')).toBe( + 'https://validpay.com/verify/a%2Fb%20c#key=K-K_m', + ); + }); + + it('honors a custom base and strips trailing slashes', () => { + expect(buildVerifyUrl('id', 'k', { baseUrl: 'https://staging.validpay.com/' })).toBe( + 'https://staging.validpay.com/verify/id#key=k', + ); + }); + + it('rejects missing args', () => { + expect(() => buildVerifyUrl('', 'k')).toThrow(ValidPayError); + expect(() => buildVerifyUrl('id', '')).toThrow(ValidPayError); + }); +}); + +describe('resolveQrRect', () => { + it('top-left anchor maps inset-from-top into pdf bottom-left y', () => { + expect(resolveQrRect({ anchor: 'top-left', x: 400, y: 50, width: 90 }, W, H)).toEqual({ + x: 400, + y: 652, + size: 90, + }); + }); + + it('defaults to top-left + pt', () => { + expect(resolveQrRect({ x: 10, y: 20, width: 100 }, W, H)).toEqual({ + x: 10, + y: H - 20 - 100, + size: 100, + }); + }); + + it('bottom-right insets from the bottom and right edges', () => { + expect(resolveQrRect({ anchor: 'bottom-right', x: 36, y: 36, width: 90 }, W, H)).toEqual({ + x: 486, + y: 36, + size: 90, + }); + }); + + it('top-right and bottom-left', () => { + expect(resolveQrRect({ anchor: 'top-right', x: 40, y: 40, width: 80 }, W, H)).toEqual({ + x: 612 - 40 - 80, + y: 792 - 40 - 80, + size: 80, + }); + expect(resolveQrRect({ anchor: 'bottom-left', x: 40, y: 40, width: 80 }, W, H)).toEqual({ + x: 40, + y: 40, + size: 80, + }); + }); + + it('converts mm and in to points', () => { + const mm = resolveQrRect({ anchor: 'top-left', x: 25.4, y: 0, width: 25.4, units: 'mm' }, W, H); + expect(mm.size).toBeCloseTo(72, 5); + expect(mm.x).toBeCloseTo(72, 5); + expect(resolveQrRect({ anchor: 'bottom-left', x: 1, y: 1, width: 1, units: 'in' }, W, H)).toEqual({ + x: 72, + y: 72, + size: 72, + }); + }); +}); + +describe('MIN_RECOMMENDED_QR_PT', () => { + it('is one inch', () => { + expect(MIN_RECOMMENDED_QR_PT).toBe(72); + }); +}); diff --git a/package.json b/package.json index b20d549..4766342 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@validpay/react-native-sdk", - "version": "1.1.0", + "version": "1.2.0", "description": "ValidPay verification SDK for React Native — 8 patented protections for document authenticity", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -15,8 +15,8 @@ "prepublishOnly": "npm run build" }, "peerDependencies": { - "react-native": ">=0.70.0", - "@react-native-async-storage/async-storage": ">=1.19.0" + "@react-native-async-storage/async-storage": ">=1.19.0", + "react-native": ">=0.70.0" }, "peerDependenciesMeta": { "react-native": { @@ -26,13 +26,12 @@ "optional": true } }, - "dependencies": {}, "devDependencies": { - "typescript": "^5.4.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", "jest": "^29.7.0", "ts-jest": "^29.1.0", - "@types/jest": "^29.5.0", - "@types/node": "^20.0.0" + "typescript": "^5.4.0" }, "keywords": [ "validpay", diff --git a/src/index.ts b/src/index.ts index 5475098..ff8f0d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,18 @@ export { setCryptoProvider, } from './crypto'; export { computeTimeLockStatus, validateTimeLock } from './timelock'; +export { + buildVerifyUrl, + resolveQrRect, + MIN_RECOMMENDED_QR_PT, +} from './pdf'; +export type { + QrAnchor, + QrUnit, + QrPlacement, + ResolvedQrRect, + VerifyUrlOptions, +} from './pdf'; export type { CreateIntentResult, VerifyIntentResult, diff --git a/src/pdf.ts b/src/pdf.ts new file mode 100644 index 0000000..6753f4f --- /dev/null +++ b/src/pdf.ts @@ -0,0 +1,127 @@ +/** + * QR placement contract (file mode add-on). + * + * Sealing a document returns `{ retrievalId, key }`. To verify it, a scannable + * QR encoding the verify URL must appear ON the document. This module gives you + * the canonical placement contract — identical to the Node and Python SDKs and + * the developer console's "Try it" tool — so a position picked once maps to the + * exact same spot everywhere. + * + * Unlike the Node SDK, there is no `embedQr` here: stamping an image onto a PDF + * on-device isn't practical in React Native. Instead: + * + * - To DISPLAY the QR in your app, pass `buildVerifyUrl(...)` to a QR + * component such as `react-native-qrcode-svg`. + * - To STAMP the QR onto a generated PDF, do it where you build the PDF — + * server-side with `@validpay/node-sdk`'s `embedQr`, or with your own PDF + * library using the points from `resolveQrRect`. + * + * Both helpers below are pure and dependency-free. + */ +import { ValidPayError } from './errors'; + +/** Which page corner the (x, y) inset is measured from. */ +export type QrAnchor = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +/** Units for placement values. 1pt = 1/72 inch (PDF's native unit). */ +export type QrUnit = 'pt' | 'mm' | 'in'; + +/** + * Where to place the QR, the way people think about a page. `anchor` names a + * page CORNER; `x` / `y` are insets from that corner's edges; the QR's matching + * corner is pinned there. `{ anchor: 'bottom-right', x: 36, y: 36, width: 90 }` + * sits 36pt in from the bottom and right edges on any page size. The default + * `top-left` anchor reads like screen coordinates. + */ +export interface QrPlacement { + /** 1-based page number. Default `1`. */ + page?: number; + /** Page corner the insets are measured from. Default `'top-left'`. */ + anchor?: QrAnchor; + /** Horizontal inset from the anchor's vertical edge, in `units`. */ + x: number; + /** Vertical inset from the anchor's horizontal edge, in `units`. */ + y: number; + /** QR side length (it is square), in `units`. */ + width: number; + /** Units for `x` / `y` / `width`. Default `'pt'`. */ + units?: QrUnit; +} + +/** A QR rectangle in PDF's bottom-left-origin point space. */ +export interface ResolvedQrRect { + /** Distance from the page's LEFT edge to the QR's left edge, in points. */ + x: number; + /** Distance from the page's BOTTOM edge to the QR's bottom edge, in points. */ + y: number; + /** QR side length, in points. */ + size: number; +} + +const UNIT_TO_PT: Record = { pt: 1, mm: 72 / 25.4, in: 72 }; + +/** + * Smallest QR side that scans reliably from a printed page at arm's length + * (~72pt ≈ 1in ≈ 2.54cm). Keep `width` at or above this. + */ +export const MIN_RECOMMENDED_QR_PT = 72; + +export interface VerifyUrlOptions { + /** Web origin that serves `/verify`. Default `'https://validpay.com'`. */ + baseUrl?: string; +} + +/** base64 → base64url. Phone scanners + share-sheets mangle `+`, `/`, and `=` + * in URL fragments, so QR keys must be base64url. Idempotent; `/verify` + * accepts both. */ +function toBase64Url(b64: string): string { + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * Build the canonical verify URL the QR encodes: + * + * /verify/#key= + * + * The key rides in the URL FRAGMENT (`#key=`), which browsers never send to + * any server. Feed the result to a QR component to display it. + */ +export function buildVerifyUrl( + retrievalId: string, + key: string, + opts: VerifyUrlOptions = {}, +): string { + if (!retrievalId) { + throw new ValidPayError('invalid_argument', 'retrievalId is required'); + } + if (!key) { + throw new ValidPayError('invalid_argument', 'key is required'); + } + const base = (opts.baseUrl ?? 'https://validpay.com').replace(/\/+$/, ''); + return `${base}/verify/${encodeURIComponent(retrievalId)}#key=${toBase64Url(key)}`; +} + +/** + * Convert a {@link QrPlacement} into PDF's bottom-left-origin point rectangle + * for a page of the given size. The EXACT conversion the website "Try it" tool + * and the other SDKs use, so copied coordinates land in the same place. Pure. + */ +export function resolveQrRect( + placement: QrPlacement, + pageWidthPt: number, + pageHeightPt: number, +): ResolvedQrRect { + const unit = UNIT_TO_PT[placement.units ?? 'pt']; + const size = placement.width * unit; + const insetX = placement.x * unit; + const insetY = placement.y * unit; + const anchor = placement.anchor ?? 'top-left'; + + const leftAnchored = anchor === 'top-left' || anchor === 'bottom-left'; + const topAnchored = anchor === 'top-left' || anchor === 'top-right'; + + const x = leftAnchored ? insetX : pageWidthPt - insetX - size; + const y = topAnchored ? pageHeightPt - insetY - size : insetY; + + return { x, y, size }; +}