@reclaimprotocol/client 0.1.28 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +255 -234
  2. package/lib/api.d.ts +2 -0
  3. package/lib/api.js +2 -0
  4. package/lib/client.js +2 -0
  5. package/lib/enckeys.d.ts +1 -1
  6. package/lib/enckeys.js +2 -2
  7. package/lib/encoding.d.ts +4 -0
  8. package/lib/encoding.js +10 -0
  9. package/lib/feature-flags.d.ts +9 -0
  10. package/lib/feature-flags.js +19 -0
  11. package/lib/index.d.ts +4 -4
  12. package/lib/index.js +4 -4
  13. package/lib/jwks.d.ts +8 -8
  14. package/lib/jwks.js +38 -17
  15. package/lib/launch/destination.d.ts +14 -0
  16. package/lib/launch/destination.js +89 -0
  17. package/lib/launch/device.d.ts +30 -0
  18. package/lib/launch/device.js +168 -0
  19. package/lib/launch/environment.d.ts +224 -0
  20. package/lib/launch/environment.js +98 -0
  21. package/lib/launch/errors.d.ts +20 -0
  22. package/lib/launch/errors.js +25 -0
  23. package/lib/launch/extension.d.ts +42 -0
  24. package/lib/launch/extension.js +72 -0
  25. package/lib/launch/flow.d.ts +82 -0
  26. package/lib/launch/flow.js +222 -0
  27. package/lib/launch/index.d.ts +16 -0
  28. package/lib/launch/index.js +12 -0
  29. package/lib/launch/mobile.d.ts +77 -0
  30. package/lib/launch/mobile.js +124 -0
  31. package/lib/launch/modal.d.ts +50 -0
  32. package/lib/launch/modal.js +258 -0
  33. package/lib/launch/options.d.ts +74 -0
  34. package/lib/launch/options.js +20 -0
  35. package/lib/launch/portal.d.ts +28 -0
  36. package/lib/launch/portal.js +68 -0
  37. package/lib/launch/qr.d.ts +5 -0
  38. package/lib/launch/qr.js +28 -0
  39. package/lib/launch/target.d.ts +46 -0
  40. package/lib/launch/target.js +128 -0
  41. package/lib/launch/vendor/qrcodegen.d.ts +134 -0
  42. package/lib/launch/vendor/qrcodegen.js +822 -0
  43. package/lib/legacy-proof.d.ts +2 -0
  44. package/lib/legacy-proof.js +37 -7
  45. package/lib/proof-hash.d.ts +5 -5
  46. package/lib/proof-hash.js +1 -1
  47. package/lib/proof-recipe.d.ts +3 -3
  48. package/lib/proof-recipe.js +1 -1
  49. package/lib/proof-types.d.ts +35 -8
  50. package/lib/reclaim.d.ts +81 -70
  51. package/lib/reclaim.js +79 -130
  52. package/lib/request-proof.d.ts +60 -0
  53. package/lib/request-proof.js +241 -0
  54. package/lib/result.d.ts +46 -38
  55. package/lib/result.js +160 -100
  56. package/lib/routes.gen.d.ts +76 -28
  57. package/lib/routes.gen.js +24 -11
  58. package/lib/tee-binding.d.ts +1 -1
  59. package/lib/types/openapi.d.ts +1 -0
  60. package/lib/types/openapi.gen.d.ts +1594 -488
  61. package/lib/types/openapi.gen.js +5 -0
  62. package/lib/verification-clients.d.ts +28 -14
  63. package/lib/verification-clients.js +31 -11
  64. package/lib/verify-attestor-tee.d.ts +19 -2
  65. package/lib/verify-attestor-tee.js +40 -4
  66. package/lib/verify-tee.d.ts +10 -1
  67. package/lib/verify-tee.js +34 -10
  68. package/package.json +5 -1
  69. package/src/api.ts +9 -0
  70. package/src/client.ts +2 -0
  71. package/src/enckeys.ts +2 -2
  72. package/src/encoding.ts +12 -0
  73. package/src/feature-flags.ts +39 -0
  74. package/src/index.ts +13 -14
  75. package/src/jwks.ts +52 -30
  76. package/src/launch/destination.ts +111 -0
  77. package/src/launch/device.ts +242 -0
  78. package/src/launch/environment.ts +337 -0
  79. package/src/launch/errors.ts +49 -0
  80. package/src/launch/extension.ts +124 -0
  81. package/src/launch/flow.ts +369 -0
  82. package/src/launch/index.ts +42 -0
  83. package/src/launch/mobile.ts +184 -0
  84. package/src/launch/modal.ts +378 -0
  85. package/src/launch/options.ts +99 -0
  86. package/src/launch/portal.ts +104 -0
  87. package/src/launch/qr.ts +37 -0
  88. package/src/launch/target.ts +150 -0
  89. package/src/launch/vendor/qrcodegen.ts +1034 -0
  90. package/src/proof-hash.ts +6 -6
  91. package/src/proof-recipe.ts +3 -3
  92. package/src/proof-types.ts +36 -8
  93. package/src/reclaim.ts +185 -218
  94. package/src/{legacy-proof.ts → request-proof.ts} +81 -36
  95. package/src/result.ts +238 -172
  96. package/src/routes.gen.ts +24 -11
  97. package/src/tee-binding.ts +1 -1
  98. package/src/types/openapi.gen.ts +1599 -488
  99. package/src/types/openapi.ts +3 -1
  100. package/src/verification-clients.ts +33 -19
  101. package/src/verify-attestor-tee.ts +66 -8
  102. package/src/verify-tee.ts +44 -13
  103. package/lib/api-client.d.ts +0 -31
  104. package/lib/api-client.js +0 -112
  105. package/lib/api-consts.d.ts +0 -4
  106. package/lib/api-consts.js +0 -4
  107. package/lib/api-errors.d.ts +0 -13
  108. package/lib/api-errors.js +0 -30
  109. package/lib/eip712.d.ts +0 -20
  110. package/lib/eip712.js +0 -108
@@ -0,0 +1,258 @@
1
+ import { defaultRenderQr } from "./qr.js";
2
+ const DEFAULT_TITLE = 'Verify with Reclaim';
3
+ const DEFAULT_DESCRIPTION = 'Scan the QR code with your phone to complete verification.';
4
+ const DEFAULT_AUTO_CLOSE_SECONDS = 60;
5
+ const MODAL_FONT_FAMILY = '-apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif';
6
+ const TICK_MS = 1000;
7
+ /**
8
+ * Opens the dialog and returns the handle that closes it again.
9
+ *
10
+ * Exported from the package root as well as used by `launch()`, so a consumer
11
+ * who only wants a QR over a share link can open one directly without going
12
+ * through flow resolution.
13
+ */
14
+ export function showQrModal(env, url, options = {}) {
15
+ const doc = env.document;
16
+ const body = doc?.body;
17
+ if (!doc || !body) {
18
+ return { close() { }, remainingSeconds: null };
19
+ }
20
+ // Captured with its narrowed type: `close` below is a hoisted function
21
+ // declaration, and TypeScript will not carry a narrowing into one.
22
+ const activeDoc = doc;
23
+ const theme = themeFor(!!options.darkTheme);
24
+ const total = normalizeSeconds(options.autoCloseSeconds);
25
+ let remaining = total;
26
+ let closed = false;
27
+ let timer;
28
+ const overlay = doc.createElement('div');
29
+ setStyle(overlay, {
30
+ position: 'fixed', inset: '0', display: 'flex', alignItems: 'center',
31
+ justifyContent: 'center', padding: '16px', boxSizing: 'border-box',
32
+ background: theme.backdrop, zIndex: '2147483647',
33
+ });
34
+ const card = doc.createElement('div');
35
+ card.setAttribute('role', 'dialog');
36
+ card.setAttribute('aria-modal', 'true');
37
+ card.setAttribute('aria-label', options.title ?? DEFAULT_TITLE);
38
+ setStyle(card, {
39
+ position: 'relative', background: theme.card, color: theme.title,
40
+ borderRadius: '12px', padding: '32px', maxWidth: '400px', width: '100%',
41
+ boxSizing: 'border-box', textAlign: 'center',
42
+ fontFamily: MODAL_FONT_FAMILY, boxShadow: theme.shadow,
43
+ });
44
+ overlay.appendChild(card);
45
+ card.appendChild(closeButton(doc, theme, () => close()));
46
+ const title = doc.createElement('h2');
47
+ title.innerHTML = escapeHtml(options.title ?? DEFAULT_TITLE);
48
+ setStyle(title, {
49
+ margin: '0 0 16px 0', fontSize: '24px', fontWeight: '600',
50
+ color: theme.title,
51
+ });
52
+ card.appendChild(title);
53
+ const description = doc.createElement('p');
54
+ description.innerHTML = escapeHtml(options.description ?? DEFAULT_DESCRIPTION);
55
+ setStyle(description, {
56
+ margin: '0 0 24px 0', fontSize: '14px', lineHeight: '1.5',
57
+ color: theme.text,
58
+ });
59
+ card.appendChild(description);
60
+ const qrSlot = doc.createElement('div');
61
+ setStyle(qrSlot, {
62
+ margin: '0 auto 24px auto', display: 'inline-block', padding: '8px',
63
+ background: theme.qr, borderRadius: '8px',
64
+ });
65
+ card.appendChild(qrSlot);
66
+ void (options.renderQr ?? defaultRenderQr)(url, qrSlot);
67
+ if (options.extensionUrl) {
68
+ card.appendChild(extensionTip(doc, theme, options.extensionUrl));
69
+ }
70
+ const copyLink = doc.createElement('button');
71
+ copyLink.setAttribute('type', 'button');
72
+ copyLink.innerHTML = 'Copy link';
73
+ setStyle(copyLink, {
74
+ background: theme.button, color: theme.buttonText, border: 'none',
75
+ borderRadius: '6px', padding: '8px 16px', fontSize: '13px',
76
+ cursor: 'pointer',
77
+ });
78
+ card.appendChild(copyLink);
79
+ copyLink.addEventListener('click', () => {
80
+ void env.navigator?.clipboard?.writeText(url).catch(() => { });
81
+ });
82
+ // Only built when there is a countdown to show. An unmoving timer and a
83
+ // full progress bar would both claim something is being measured.
84
+ let countdownText;
85
+ let progressBar;
86
+ if (total !== null) {
87
+ const wrap = doc.createElement('div');
88
+ setStyle(wrap, { marginTop: '16px' });
89
+ countdownText = doc.createElement('div');
90
+ setStyle(countdownText, {
91
+ fontSize: '12px', color: theme.countdown, marginBottom: '8px',
92
+ });
93
+ wrap.appendChild(countdownText);
94
+ const track = doc.createElement('div');
95
+ setStyle(track, {
96
+ width: '100%', height: '4px', backgroundColor: theme.progressTrack,
97
+ borderRadius: '2px', overflow: 'hidden',
98
+ });
99
+ progressBar = doc.createElement('div');
100
+ setStyle(progressBar, {
101
+ width: '100%', height: '100%', background: theme.progressFill,
102
+ borderRadius: '2px', transition: 'width 1s linear',
103
+ });
104
+ track.appendChild(progressBar);
105
+ wrap.appendChild(track);
106
+ card.appendChild(wrap);
107
+ }
108
+ overlay.addEventListener('click', onBackdropClick);
109
+ doc.addEventListener('keydown', onKeydown);
110
+ body.appendChild(overlay);
111
+ if (total !== null) {
112
+ renderCountdown();
113
+ timer = env.setTimeout(tick, TICK_MS);
114
+ }
115
+ return {
116
+ close,
117
+ get remainingSeconds() {
118
+ return remaining;
119
+ },
120
+ };
121
+ function tick() {
122
+ if (closed || remaining === null) {
123
+ return;
124
+ }
125
+ remaining -= 1;
126
+ renderCountdown();
127
+ if (remaining <= 0) {
128
+ close();
129
+ return;
130
+ }
131
+ timer = env.setTimeout(tick, TICK_MS);
132
+ }
133
+ function renderCountdown() {
134
+ if (remaining === null || total === null) {
135
+ return;
136
+ }
137
+ const minutes = Math.floor(remaining / 60);
138
+ const seconds = remaining % 60;
139
+ if (countdownText) {
140
+ countdownText.innerHTML = escapeHtml(`Closes in ${minutes}:${String(seconds).padStart(2, '0')}`);
141
+ }
142
+ if (progressBar) {
143
+ progressBar.style.width = `${Math.max(0, remaining / total) * 100}%`;
144
+ }
145
+ }
146
+ // `e.target === overlay` is what separates a click on the backdrop from
147
+ // one that bubbled up out of the card — without it, every click inside
148
+ // the dialog would close it.
149
+ function onBackdropClick(event) {
150
+ if (!event || event.target === overlay) {
151
+ close();
152
+ }
153
+ }
154
+ function onKeydown(event) {
155
+ if (event.key === 'Escape') {
156
+ close();
157
+ }
158
+ }
159
+ function close() {
160
+ if (closed) {
161
+ return;
162
+ }
163
+ closed = true;
164
+ remaining = total === null ? null : 0;
165
+ if (timer !== undefined) {
166
+ env.clearTimeout(timer);
167
+ timer = undefined;
168
+ }
169
+ overlay.removeEventListener('click', onBackdropClick);
170
+ activeDoc.removeEventListener('keydown', onKeydown);
171
+ overlay.remove();
172
+ options.onClose?.();
173
+ }
174
+ }
175
+ /** `undefined` means the default countdown; `0`/negative/non-finite means
176
+ * wait indefinitely, which the caller sees as `null` throughout. */
177
+ function normalizeSeconds(value) {
178
+ const seconds = value ?? DEFAULT_AUTO_CLOSE_SECONDS;
179
+ if (!Number.isFinite(seconds) || seconds <= 0) {
180
+ return null;
181
+ }
182
+ return Math.ceil(seconds);
183
+ }
184
+ function closeButton(doc, theme, onClick) {
185
+ const button = doc.createElement('button');
186
+ button.setAttribute('type', 'button');
187
+ button.setAttribute('aria-label', 'Close');
188
+ button.setAttribute('title', 'Close');
189
+ setStyle(button, {
190
+ position: 'absolute', top: '16px', right: '16px', width: '32px',
191
+ height: '32px', background: 'none', border: 'none', cursor: 'pointer',
192
+ borderRadius: '6px', display: 'flex', alignItems: 'center',
193
+ justifyContent: 'center', padding: '4px',
194
+ });
195
+ button.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" '
196
+ + 'fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
197
+ + `<path d="M12 4L4 12M4 4L12 12" stroke="${theme.buttonText}" `
198
+ + 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
199
+ + '</svg>';
200
+ button.addEventListener('click', onClick);
201
+ return button;
202
+ }
203
+ function extensionTip(doc, theme, extensionUrl) {
204
+ const tip = doc.createElement('div');
205
+ setStyle(tip, {
206
+ marginBottom: '24px', padding: '16px', background: theme.tip,
207
+ border: `1px solid ${theme.tipBorder}`, borderRadius: '8px',
208
+ textAlign: 'left',
209
+ });
210
+ tip.innerHTML = '<p style="margin:0 0 8px 0;font-size:14px;font-weight:500;'
211
+ + `color:${theme.tipText}">For a faster route</p>`
212
+ + '<p style="margin:0 0 12px 0;font-size:13px;line-height:1.4;'
213
+ + `color:${theme.tipText}">Install the browser extension to verify `
214
+ + 'here, without a phone.</p>'
215
+ + `<a href="${escapeHtml(extensionUrl)}" target="_blank" `
216
+ + 'rel="noopener noreferrer" style="display:inline-block;'
217
+ + `background:${theme.accent};color:#fff;text-decoration:none;`
218
+ + 'padding:8px 16px;border-radius:6px;font-size:12px;font-weight:500">'
219
+ + 'Install extension</a>';
220
+ return tip;
221
+ }
222
+ function themeFor(dark) {
223
+ return dark
224
+ ? {
225
+ backdrop: 'rgba(0,0,0,0.8)', card: '#1f2937',
226
+ shadow: '0 20px 25px -5px rgba(0,0,0,0.5)',
227
+ title: '#f9fafb', text: '#d1d5db', qr: '#374151',
228
+ button: '#374151', buttonText: '#f9fafb', countdown: '#6b7280',
229
+ progressTrack: '#4b5563',
230
+ progressFill: 'linear-gradient(90deg,#3b82f6 0%,#2563eb 50%,#1d4ed8 100%)',
231
+ tip: '#1e3a8a', tipBorder: '#1e40af', tipText: '#dbeafe',
232
+ accent: '#2563eb',
233
+ }
234
+ : {
235
+ backdrop: 'rgba(0,0,0,0.5)', card: '#fff',
236
+ shadow: '0 20px 25px -5px rgba(0,0,0,0.1),'
237
+ + '0 10px 10px -5px rgba(0,0,0,0.04)',
238
+ title: '#1f2937', text: '#6b7280', qr: '#f9fafb',
239
+ button: '#f3f4f6', buttonText: '#374151', countdown: '#9ca3af',
240
+ progressTrack: '#e5e7eb',
241
+ progressFill: 'linear-gradient(90deg,#2563eb 0%,#1d4ed8 50%,#1e40af 100%)',
242
+ tip: '#f0f9ff', tipBorder: '#e0f2fe', tipText: '#0369a1',
243
+ accent: '#2563eb',
244
+ };
245
+ }
246
+ /** Every property `LaunchStyleDeclaration` declares is a real
247
+ * `CSSStyleDeclaration` property name, so this needs no cast — unlike a
248
+ * generic `Record<string, string>` target, which would. */
249
+ function setStyle(el, declarations) {
250
+ Object.assign(el.style, declarations);
251
+ }
252
+ function escapeHtml(value) {
253
+ return value
254
+ .replace(/&/g, '&amp;')
255
+ .replace(/</g, '&lt;')
256
+ .replace(/>/g, '&gt;')
257
+ .replace(/"/g, '&quot;');
258
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `FlowOptions` — the launch-API plan's "Options" table. Option NAMES match
3
+ * the reference JS SDK's so existing call sites port unchanged; everything
4
+ * here is optional, and every default lives in `DEFAULT_FLOW_OPTIONS`/the
5
+ * `resolve*Options` helpers below rather than scattered through `flow.ts`.
6
+ */
7
+ import type { LaunchContainer, LaunchEnvironment, LaunchWindowHandle } from './environment.ts';
8
+ import type { ModalOptions } from './modal.ts';
9
+ import type { RenderQr } from './qr.ts';
10
+ export interface FlowOptions {
11
+ /** Whether to probe for the browser extension at all. Default `true`. */
12
+ useBrowserExtension?: boolean;
13
+ /** Strict-mode match against `chrome.runtime.id`. Omitted means any
14
+ * extension answers. */
15
+ extensionID?: string;
16
+ /** Availability probe budget, in ms. Default `200`. */
17
+ extensionTimeout?: number;
18
+ /** Portal origin; `/verify` is appended. Default
19
+ * `https://portal.reclaimprotocol.org`. */
20
+ portalUrl?: string;
21
+ /** Universal link for the verifier app. Default
22
+ * `https://share.reclaimprotocol.org/verify`. */
23
+ appLinkBaseUrl?: string;
24
+ /** iOS custom-scheme deep link base, used both for the direct iOS deep
25
+ * link and (with the same custom scheme) the Android probe. Default
26
+ * `reclaimverifier://org.reclaimprotocol.app`. */
27
+ iosDeepLinkBaseUrl?: string;
28
+ /** Android package id, used to build the Play Store market-intent
29
+ * fallback. Default `org.reclaimprotocol.app`. */
30
+ androidDeepLinkPackage?: string;
31
+ /** iOS App Store fallback. Default the Reclaim Verifier App Store page. */
32
+ iosAppDownloadUrl?: string;
33
+ /** Android Play Store fallback. Defaults to the Play Store page for
34
+ * `androidDeepLinkPackage`. */
35
+ androidAppDownloadUrl?: string;
36
+ /** Prefer the App Clip on iOS over the direct deep link. Default `false`. */
37
+ useAppClip?: boolean;
38
+ /** App Clip destination. Default
39
+ * `https://appclip.apple.com/id?p=org.reclaimprotocol.app.clip`. */
40
+ appClipUrl?: string;
41
+ /** Android: clipboard write + deep-link probe + market-intent fallback
42
+ * (`true`, the default) vs. a plain redirect to the share URL (`false`).
43
+ * iOS: no effect — the iOS mechanism is chosen by `useAppClip` alone.
44
+ * Defaults to `true` on Android, `false` on iOS. */
45
+ canUseDeferredDeepLinksFlow?: boolean;
46
+ /** DOM element to embed the portal/builder page into as an iframe,
47
+ * instead of opening a new tab (desktop only). */
48
+ target?: LaunchContainer;
49
+ /** QR dialog title, description, theme, auto-close countdown, optional
50
+ * extension tip, and close handler. */
51
+ modalOptions?: ModalOptions;
52
+ /** Overrides the bundled QR renderer. */
53
+ renderQr?: RenderQr;
54
+ /** A tab already opened via `window.open('about:blank')` inside the
55
+ * click handler, so the network round trip before `launch()` doesn't
56
+ * lose the user gesture a fresh `window.open` would need. */
57
+ preOpenedTab?: LaunchWindowHandle | null;
58
+ /** Escape hatch for a staff-registered custom client whose URL doesn't
59
+ * match the seeded path convention — see `./target.ts`. Never required. */
60
+ verificationClientId?: string;
61
+ /** Overrides the real browser globals — see ./environment.ts. Tests pass
62
+ * a fake; browser code never needs to set this. */
63
+ environment?: LaunchEnvironment;
64
+ }
65
+ export declare const DEFAULT_USE_BROWSER_EXTENSION = true;
66
+ export declare const DEFAULT_EXTENSION_TIMEOUT_MS = 200;
67
+ export declare const DEFAULT_IOS_DEEP_LINK_BASE_URL = "reclaimverifier://org.reclaimprotocol.app";
68
+ export declare const DEFAULT_ANDROID_DEEP_LINK_PACKAGE = "org.reclaimprotocol.app";
69
+ export declare const DEFAULT_IOS_APP_DOWNLOAD_URL = "itms-apps://apps.apple.com/in/app/reclaim-verifier/id6503247508";
70
+ export declare const DEFAULT_USE_APP_CLIP = false;
71
+ export declare const DEFAULT_APP_CLIP_URL = "https://appclip.apple.com/id?p=org.reclaimprotocol.app.clip";
72
+ export declare function resolveAndroidAppDownloadUrl(options: FlowOptions): string;
73
+ export declare function resolveAndroidDeepLinkPackage(options: FlowOptions): string;
74
+ export declare function resolveCanUseDeferredDeepLinksFlow(options: FlowOptions, device: 'ios' | 'android'): boolean;
@@ -0,0 +1,20 @@
1
+ export const DEFAULT_USE_BROWSER_EXTENSION = true;
2
+ export const DEFAULT_EXTENSION_TIMEOUT_MS = 200;
3
+ // `portalUrl`/`appLinkBaseUrl` defaults live in ./destination.ts, next to the
4
+ // `resolveDestinationUrl()` that's the only place they're read.
5
+ export const DEFAULT_IOS_DEEP_LINK_BASE_URL = 'reclaimverifier://org.reclaimprotocol.app';
6
+ export const DEFAULT_ANDROID_DEEP_LINK_PACKAGE = 'org.reclaimprotocol.app';
7
+ export const DEFAULT_IOS_APP_DOWNLOAD_URL = 'itms-apps://apps.apple.com/in/app/reclaim-verifier/id6503247508';
8
+ export const DEFAULT_USE_APP_CLIP = false;
9
+ export const DEFAULT_APP_CLIP_URL = 'https://appclip.apple.com/id?p=org.reclaimprotocol.app.clip';
10
+ export function resolveAndroidAppDownloadUrl(options) {
11
+ const androidPackage = resolveAndroidDeepLinkPackage(options);
12
+ return options.androidAppDownloadUrl
13
+ ?? `https://play.google.com/store/apps/details?id=${androidPackage}`;
14
+ }
15
+ export function resolveAndroidDeepLinkPackage(options) {
16
+ return options.androidDeepLinkPackage ?? DEFAULT_ANDROID_DEEP_LINK_PACKAGE;
17
+ }
18
+ export function resolveCanUseDeferredDeepLinksFlow(options, device) {
19
+ return options.canUseDeferredDeepLinksFlow ?? (device === 'android');
20
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The `new-tab`/`iframe` mechanisms shared by the `builder` and `portals`
3
+ * targets. Ported from the reference JS SDK's `openPortalTab`/
4
+ * `embedPortalIframe` (`Reclaim.ts` ~1159/~1191).
5
+ */
6
+ import type { LaunchContainer, LaunchElement, LaunchEnvironment, LaunchWindowHandle } from './environment.ts';
7
+ export interface PortalTabHandle {
8
+ tab: LaunchWindowHandle | null;
9
+ close(): void;
10
+ }
11
+ /**
12
+ * Opens `url` in a new tab. Popup blockers kill a `window.open` that happens
13
+ * after a network round trip, so a caller that already opened
14
+ * `window.open('about:blank')` synchronously inside the click handler passes
15
+ * it as `preOpenedTab` — this just navigates it, preserving the user
16
+ * gesture. Falls back to opening fresh (and recovers if the pre-opened tab
17
+ * never actually navigated, e.g. a blocker allowed the open but not the
18
+ * navigation) exactly as the reference SDK does.
19
+ */
20
+ export declare function openPortalTab(env: LaunchEnvironment, url: string, preOpenedTab?: LaunchWindowHandle | null): PortalTabHandle;
21
+ export interface PortalIframeHandle {
22
+ iframe: LaunchElement | undefined;
23
+ close(): void;
24
+ }
25
+ /** Embeds `url` inside `container` (`options.target`). Appends
26
+ * `embedded=true` so the portal skips extension detection, matching the
27
+ * reference SDK. */
28
+ export declare function embedPortalIframe(env: LaunchEnvironment, url: string, container: LaunchContainer): PortalIframeHandle;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Opens `url` in a new tab. Popup blockers kill a `window.open` that happens
3
+ * after a network round trip, so a caller that already opened
4
+ * `window.open('about:blank')` synchronously inside the click handler passes
5
+ * it as `preOpenedTab` — this just navigates it, preserving the user
6
+ * gesture. Falls back to opening fresh (and recovers if the pre-opened tab
7
+ * never actually navigated, e.g. a blocker allowed the open but not the
8
+ * navigation) exactly as the reference SDK does.
9
+ */
10
+ export function openPortalTab(env, url, preOpenedTab) {
11
+ // Matches the reference SDK exactly: the fresh path also opens
12
+ // `about:blank` first rather than the real URL directly, so the recovery
13
+ // check below (which looks for a tab stuck at `about:blank`) applies
14
+ // uniformly to both paths.
15
+ const tab = preOpenedTab ?? env.open('about:blank', '_blank');
16
+ if (!tab) {
17
+ return { tab: null, close() { } };
18
+ }
19
+ tab.navigate(url);
20
+ // Verify navigation actually happened; recover by opening fresh if not.
21
+ const recoveryHandle = env.setTimeout(() => {
22
+ try {
23
+ if (tab.location.href === 'about:blank') {
24
+ tab.close();
25
+ env.open(url, '_blank');
26
+ }
27
+ }
28
+ catch {
29
+ // Cross-origin after navigation means it worked.
30
+ }
31
+ }, 500);
32
+ return {
33
+ tab,
34
+ close() {
35
+ env.clearTimeout(recoveryHandle);
36
+ try {
37
+ tab.close();
38
+ }
39
+ catch {
40
+ // Cross-origin — tab may have navigated away.
41
+ }
42
+ },
43
+ };
44
+ }
45
+ /** Embeds `url` inside `container` (`options.target`). Appends
46
+ * `embedded=true` so the portal skips extension detection, matching the
47
+ * reference SDK. */
48
+ export function embedPortalIframe(env, url, container) {
49
+ if (!env.document) {
50
+ return { iframe: undefined, close() { } };
51
+ }
52
+ const embedUrl = new URL(url);
53
+ embedUrl.searchParams.set('embedded', 'true');
54
+ const iframe = env.document.createElement('iframe');
55
+ iframe.src = embedUrl.toString();
56
+ iframe.style.width = '100%';
57
+ iframe.style.height = '100%';
58
+ iframe.style.border = 'none';
59
+ iframe.setAttribute('allow', 'clipboard-write');
60
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
61
+ container.appendChild(iframe);
62
+ return {
63
+ iframe,
64
+ close() {
65
+ iframe.remove();
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,5 @@
1
+ import type { LaunchElement } from './environment.ts';
2
+ export type RenderQr = (url: string, container: LaunchElement) => void | Promise<void>;
3
+ /** Renders `url` as an inline SVG QR code into `container`. Quiet zone of 4
4
+ * modules, as the QR spec recommends. */
5
+ export declare const defaultRenderQr: RenderQr;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Default QR renderer — bundled so the desktop `verifier-app` modal works
3
+ * with zero configuration (`options.renderQr` overrides it for a branded
4
+ * code). Renders an inline SVG (no `<canvas>`/image needed) from the vendored
5
+ * encoder in ./vendor/qrcodegen.ts.
6
+ */
7
+ import { QrCode, QrCodeEcc } from "./vendor/qrcodegen.js";
8
+ /** Renders `url` as an inline SVG QR code into `container`. Quiet zone of 4
9
+ * modules, as the QR spec recommends. */
10
+ export const defaultRenderQr = (url, container) => {
11
+ const qr = QrCode.encodeText(url, QrCodeEcc.MEDIUM);
12
+ const quietZone = 4;
13
+ const dimension = qr.size + quietZone * 2;
14
+ let path = '';
15
+ for (let y = 0; y < qr.size; y++) {
16
+ for (let x = 0; x < qr.size; x++) {
17
+ if (qr.getModule(x, y)) {
18
+ path += `M${x + quietZone},${y + quietZone}h1v1h-1z`;
19
+ }
20
+ }
21
+ }
22
+ const viewBox = `0 0 ${dimension} ${dimension}`;
23
+ container.innerHTML = `<svg viewBox="${viewBox}" width="200" height="200" `
24
+ + 'role="img" aria-label="QR code" xmlns="http://www.w3.org/2000/svg">'
25
+ + `<rect width="${dimension}" height="${dimension}" fill="#fff"/>`
26
+ + `<path d="${path}" fill="#000"/>`
27
+ + '</svg>';
28
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Which client a `verificationUrl` names, and what a caller-supplied
3
+ * `verificationClientId` escape hatch can override.
4
+ *
5
+ * `build_verification_url` (packages/app/sql/ddl.sql) appends
6
+ * `?api=2&sessionId=<uuid>` (plus optional `&diag=1`) to the registered
7
+ * client's base URL, so parsing the path is enough to name the client — the
8
+ * registered URI is an attribution label, never the URL to open (see
9
+ * ./destination.ts for what actually gets opened).
10
+ *
11
+ * As of migration `20260917130000000.share-routes-uris.sql`, every built-in
12
+ * client (`builder` included) registers at the uniform `{origin}/share/
13
+ * {name}` shape (see `packages/app/src/verification/builtin-clients.ts`'s
14
+ * `clientUrl()` and `packages/app/src/routes/SharePage.tsx`). Only four of
15
+ * the six names are launchable targets here — `inapp-sdk` and `zkfetch` have
16
+ * no claimant browser flow, so `/share/inapp-sdk` and `/share/zkfetch`
17
+ * degrade the same as the old bare `/zkfetch` label always did.
18
+ *
19
+ * The pre-migration shapes — `/portals`, `/s` / `/verifier-app`,
20
+ * `/reclaim-browser-extension`, and `builder`'s own `/v/{id}` — are kept
21
+ * recognised too, deliberately, not just for a transition window:
22
+ * - a deployment that has not yet run that migration still seeds and
23
+ * serves the old uris, so dropping them would break every consumer
24
+ * hitting an un-migrated Builder;
25
+ * - `register_verification_client`'s seed upsert COALESCEs `uri` and never
26
+ * clobbers a non-null value, so an admin who set a custom (old-shape)
27
+ * uri before the migration keeps it forever — there is no future point
28
+ * at which the old shapes stop appearing in the wild;
29
+ * - `builder`'s own `/share/builder` route 302s to `/v/{sessionId}`
30
+ * server-side (see SharePage.tsx) — `/v/{id}` is where a claimant
31
+ * *actually lands* regardless of which shape the session was created
32
+ * against, so it must stay recognised independent of the migration
33
+ * question entirely.
34
+ * There is no ambiguity cost to recognising both: the shapes are disjoint
35
+ * path segments, so a URL can only ever match one.
36
+ */
37
+ export type VerificationTarget = 'builder' | 'portals' | 'verifier-app' | 'reclaim-browser-extension';
38
+ export interface ParsedFlowUrl {
39
+ url: URL;
40
+ target: VerificationTarget | undefined;
41
+ sessionId: string | undefined;
42
+ diag: boolean;
43
+ }
44
+ /** Parses a `verificationUrl` into its client and session id, without
45
+ * rebuilding anything — see `./destination.ts` for the openable URL. */
46
+ export declare function parseFlowUrl(verificationUrl: string, verificationClientId?: string): ParsedFlowUrl;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Which client a `verificationUrl` names, and what a caller-supplied
3
+ * `verificationClientId` escape hatch can override.
4
+ *
5
+ * `build_verification_url` (packages/app/sql/ddl.sql) appends
6
+ * `?api=2&sessionId=<uuid>` (plus optional `&diag=1`) to the registered
7
+ * client's base URL, so parsing the path is enough to name the client — the
8
+ * registered URI is an attribution label, never the URL to open (see
9
+ * ./destination.ts for what actually gets opened).
10
+ *
11
+ * As of migration `20260917130000000.share-routes-uris.sql`, every built-in
12
+ * client (`builder` included) registers at the uniform `{origin}/share/
13
+ * {name}` shape (see `packages/app/src/verification/builtin-clients.ts`'s
14
+ * `clientUrl()` and `packages/app/src/routes/SharePage.tsx`). Only four of
15
+ * the six names are launchable targets here — `inapp-sdk` and `zkfetch` have
16
+ * no claimant browser flow, so `/share/inapp-sdk` and `/share/zkfetch`
17
+ * degrade the same as the old bare `/zkfetch` label always did.
18
+ *
19
+ * The pre-migration shapes — `/portals`, `/s` / `/verifier-app`,
20
+ * `/reclaim-browser-extension`, and `builder`'s own `/v/{id}` — are kept
21
+ * recognised too, deliberately, not just for a transition window:
22
+ * - a deployment that has not yet run that migration still seeds and
23
+ * serves the old uris, so dropping them would break every consumer
24
+ * hitting an un-migrated Builder;
25
+ * - `register_verification_client`'s seed upsert COALESCEs `uri` and never
26
+ * clobbers a non-null value, so an admin who set a custom (old-shape)
27
+ * uri before the migration keeps it forever — there is no future point
28
+ * at which the old shapes stop appearing in the wild;
29
+ * - `builder`'s own `/share/builder` route 302s to `/v/{sessionId}`
30
+ * server-side (see SharePage.tsx) — `/v/{id}` is where a claimant
31
+ * *actually lands* regardless of which shape the session was created
32
+ * against, so it must stay recognised independent of the migration
33
+ * question entirely.
34
+ * There is no ambiguity cost to recognising both: the shapes are disjoint
35
+ * path segments, so a URL can only ever match one.
36
+ */
37
+ /**
38
+ * Fixed, deployment-independent ids for the built-in clients — duplicated
39
+ * from `packages/app/src/verification/builtin-clients.ts`'
40
+ * `BUILTIN_CLIENT_IDS` (also duplicated in the in-app SDK and the browser
41
+ * extension SDK). Changing one copy alone breaks every other caller.
42
+ *
43
+ * Used only as the `options.verificationClientId` escape hatch: when a
44
+ * staff-registered custom client's URL doesn't match the path convention
45
+ * above (an admin-overridden `uri`, or a fresh custom registration that was
46
+ * nonetheless assigned one of these fixed ids), a caller who knows the id
47
+ * out of band can still get the right launch mechanism instead of degrading
48
+ * to a tab. This is unrelated to — and does not feed — the extension
49
+ * handoff payload, which never carries a client id at all (see
50
+ * ./extension.ts).
51
+ */
52
+ const BUILTIN_CLIENT_IDS = {
53
+ builder: '00000000-0000-4000-8000-000000000001',
54
+ 'verifier-app': '00000000-0000-4000-8000-000000000002',
55
+ portals: '00000000-0000-4000-8000-000000000004',
56
+ 'reclaim-browser-extension': '00000000-0000-4000-8000-000000000005',
57
+ };
58
+ /** Parses a `verificationUrl` into its client and session id, without
59
+ * rebuilding anything — see `./destination.ts` for the openable URL. */
60
+ export function parseFlowUrl(verificationUrl, verificationClientId) {
61
+ const url = new URL(verificationUrl);
62
+ const segments = url.pathname.split('/').filter(Boolean);
63
+ let target;
64
+ if (segments[0] === 'v' && segments.length >= 2) {
65
+ // Both a pre-migration `builder` uri (registered directly at
66
+ // `/v/{sessionId}`) and where a claimant's browser actually lands after
67
+ // `/share/builder`'s server-side 302 — recognised unconditionally, not
68
+ // merely as one of the two shapes below.
69
+ target = 'builder';
70
+ }
71
+ else if (segments[0] === 'share' && segments.length >= 2) {
72
+ target = targetForShareName(segments[1]);
73
+ }
74
+ else if (segments[0] === 'portals') {
75
+ target = 'portals';
76
+ }
77
+ else if (segments[0] === 's' || segments[0] === 'verifier-app') {
78
+ // `/s` is Builder's own share page and the uri `verifier-app` is
79
+ // registered at today; `/verifier-app` is the attribution-only label it
80
+ // used to carry, still recognised for a deployment that has not run the
81
+ // re-pointing migration (or whose admin kept the old uri).
82
+ target = 'verifier-app';
83
+ }
84
+ else if (segments[0] === 'reclaim-browser-extension') {
85
+ target = 'reclaim-browser-extension';
86
+ }
87
+ // Falls back for EITHER an unrecognised path (old shape or new) or a
88
+ // `/share/{name}` naming a non-launchable client (`inapp-sdk`/`zkfetch`) —
89
+ // `targetForShareName` returning `undefined` for those must still let this
90
+ // run, not short-circuit it.
91
+ if (!target && verificationClientId) {
92
+ target = targetForClientId(verificationClientId);
93
+ }
94
+ return {
95
+ url,
96
+ target,
97
+ sessionId: url.searchParams.get('sessionId')?.trim() || undefined,
98
+ diag: url.searchParams.get('diag') === '1',
99
+ };
100
+ }
101
+ /** The four launchable targets' `/share/{name}` segment, post-migration —
102
+ * see `builtin-clients.ts`'s `clientUrl()`. `inapp-sdk` and `zkfetch` get a
103
+ * real `/share/{name}` page too (so a stray GET doesn't 404), but neither
104
+ * has a claimant browser flow, so they degrade like the old bare `/zkfetch`
105
+ * label always did. */
106
+ function targetForShareName(name) {
107
+ switch (name) {
108
+ case 'builder':
109
+ case 'portals':
110
+ case 'verifier-app':
111
+ case 'reclaim-browser-extension':
112
+ return name;
113
+ default:
114
+ return undefined;
115
+ }
116
+ }
117
+ function targetForClientId(verificationClientId) {
118
+ const normalized = verificationClientId.trim().toLowerCase();
119
+ for (const [target, id] of Object.entries(BUILTIN_CLIENT_IDS)) {
120
+ if (id === normalized) {
121
+ // The key side of `Object.entries` on
122
+ // `Record<VerificationTarget, string>` is exactly
123
+ // `VerificationTarget`; TS widens it to `string` on the way out.
124
+ return target;
125
+ }
126
+ }
127
+ return undefined;
128
+ }