@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.
- package/README.md +255 -234
- package/lib/api.d.ts +2 -0
- package/lib/api.js +2 -0
- package/lib/client.js +2 -0
- package/lib/enckeys.d.ts +1 -1
- package/lib/enckeys.js +2 -2
- package/lib/encoding.d.ts +4 -0
- package/lib/encoding.js +10 -0
- package/lib/feature-flags.d.ts +9 -0
- package/lib/feature-flags.js +19 -0
- package/lib/index.d.ts +4 -4
- package/lib/index.js +4 -4
- package/lib/jwks.d.ts +8 -8
- package/lib/jwks.js +38 -17
- package/lib/launch/destination.d.ts +14 -0
- package/lib/launch/destination.js +89 -0
- package/lib/launch/device.d.ts +30 -0
- package/lib/launch/device.js +168 -0
- package/lib/launch/environment.d.ts +224 -0
- package/lib/launch/environment.js +98 -0
- package/lib/launch/errors.d.ts +20 -0
- package/lib/launch/errors.js +25 -0
- package/lib/launch/extension.d.ts +42 -0
- package/lib/launch/extension.js +72 -0
- package/lib/launch/flow.d.ts +82 -0
- package/lib/launch/flow.js +222 -0
- package/lib/launch/index.d.ts +16 -0
- package/lib/launch/index.js +12 -0
- package/lib/launch/mobile.d.ts +77 -0
- package/lib/launch/mobile.js +124 -0
- package/lib/launch/modal.d.ts +50 -0
- package/lib/launch/modal.js +258 -0
- package/lib/launch/options.d.ts +74 -0
- package/lib/launch/options.js +20 -0
- package/lib/launch/portal.d.ts +28 -0
- package/lib/launch/portal.js +68 -0
- package/lib/launch/qr.d.ts +5 -0
- package/lib/launch/qr.js +28 -0
- package/lib/launch/target.d.ts +46 -0
- package/lib/launch/target.js +128 -0
- package/lib/launch/vendor/qrcodegen.d.ts +134 -0
- package/lib/launch/vendor/qrcodegen.js +822 -0
- package/lib/legacy-proof.d.ts +2 -0
- package/lib/legacy-proof.js +37 -7
- package/lib/proof-hash.d.ts +5 -5
- package/lib/proof-hash.js +1 -1
- package/lib/proof-recipe.d.ts +3 -3
- package/lib/proof-recipe.js +1 -1
- package/lib/proof-types.d.ts +35 -8
- package/lib/reclaim.d.ts +81 -70
- package/lib/reclaim.js +79 -130
- package/lib/request-proof.d.ts +60 -0
- package/lib/request-proof.js +241 -0
- package/lib/result.d.ts +46 -38
- package/lib/result.js +160 -100
- package/lib/routes.gen.d.ts +76 -28
- package/lib/routes.gen.js +24 -11
- package/lib/tee-binding.d.ts +1 -1
- package/lib/types/openapi.d.ts +1 -0
- package/lib/types/openapi.gen.d.ts +1594 -488
- package/lib/types/openapi.gen.js +5 -0
- package/lib/verification-clients.d.ts +28 -14
- package/lib/verification-clients.js +31 -11
- package/lib/verify-attestor-tee.d.ts +19 -2
- package/lib/verify-attestor-tee.js +40 -4
- package/lib/verify-tee.d.ts +10 -1
- package/lib/verify-tee.js +34 -10
- package/package.json +5 -1
- package/src/api.ts +9 -0
- package/src/client.ts +2 -0
- package/src/enckeys.ts +2 -2
- package/src/encoding.ts +12 -0
- package/src/feature-flags.ts +39 -0
- package/src/index.ts +13 -14
- package/src/jwks.ts +52 -30
- package/src/launch/destination.ts +111 -0
- package/src/launch/device.ts +242 -0
- package/src/launch/environment.ts +337 -0
- package/src/launch/errors.ts +49 -0
- package/src/launch/extension.ts +124 -0
- package/src/launch/flow.ts +369 -0
- package/src/launch/index.ts +42 -0
- package/src/launch/mobile.ts +184 -0
- package/src/launch/modal.ts +378 -0
- package/src/launch/options.ts +99 -0
- package/src/launch/portal.ts +104 -0
- package/src/launch/qr.ts +37 -0
- package/src/launch/target.ts +150 -0
- package/src/launch/vendor/qrcodegen.ts +1034 -0
- package/src/proof-hash.ts +6 -6
- package/src/proof-recipe.ts +3 -3
- package/src/proof-types.ts +36 -8
- package/src/reclaim.ts +185 -218
- package/src/{legacy-proof.ts → request-proof.ts} +81 -36
- package/src/result.ts +238 -172
- package/src/routes.gen.ts +24 -11
- package/src/tee-binding.ts +1 -1
- package/src/types/openapi.gen.ts +1599 -488
- package/src/types/openapi.ts +3 -1
- package/src/verification-clients.ts +33 -19
- package/src/verify-attestor-tee.ts +66 -8
- package/src/verify-tee.ts +44 -13
- package/lib/api-client.d.ts +0 -31
- package/lib/api-client.js +0 -112
- package/lib/api-consts.d.ts +0 -4
- package/lib/api-consts.js +0 -4
- package/lib/api-errors.d.ts +0 -13
- package/lib/api-errors.js +0 -30
- package/lib/eip712.d.ts +0 -20
- 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, '&')
|
|
255
|
+
.replace(/</g, '<')
|
|
256
|
+
.replace(/>/g, '>')
|
|
257
|
+
.replace(/"/g, '"');
|
|
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;
|
package/lib/launch/qr.js
ADDED
|
@@ -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
|
+
}
|