Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<QRCode value={url} size={160} />;
```

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`:
Expand Down
90 changes: 90 additions & 0 deletions __tests__/pdf.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand All @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 127 additions & 0 deletions src/pdf.ts
Original file line number Diff line number Diff line change
@@ -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<QrUnit, number> = { 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:
*
* <baseUrl>/verify/<retrievalId>#key=<base64url(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 };
}
Loading