@espuni/browser 0.1.1 → 0.1.2

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 (2) hide show
  1. package/package.json +13 -6
  2. package/src/index.ts +0 -308
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "@espuni/browser",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Browser SDK for the espuni Age Verification flow (DC API → OID4VP/QR fallback)",
5
5
  "license": "MIT",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
6
+ "//": "Workspace resolves from ./src (no build-order coupling); publishConfig below repoints the *published* manifest at the compiled ./dist so npm consumers (bundlers and the portal) resolve real files, not the unshipped src.",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
8
9
  "exports": {
9
- ".": "./src/index.ts"
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ },
15
+ "./global": "./dist/index.global.js"
10
16
  },
11
17
  "browser": "dist/index.global.js",
12
18
  "unpkg": "dist/index.global.js",
@@ -20,10 +26,11 @@
20
26
  "devDependencies": {
21
27
  "tsup": "^8.0.0",
22
28
  "typescript": "^5.4.5",
23
- "@espuni/core": "0.1.1"
29
+ "@espuni/core": "0.1.2"
24
30
  },
25
31
  "scripts": {
26
32
  "build": "tsup",
27
33
  "dev": "tsup --watch"
28
- }
34
+ },
35
+ "module": "./dist/index.mjs"
29
36
  }
package/src/index.ts DELETED
@@ -1,308 +0,0 @@
1
- // @espuni/browser — browser SDK for the espuni Age Verification flow.
2
- //
3
- // Encapsulates the DC API → OID4VP/QR fallback so relying parties don't
4
- // reimplement it. Pure decisions (protocol selection, av:// scheme rewrite,
5
- // QR/deeplink derivation, claim extraction) come from @espuni/core, bundled in
6
- // at build time.
7
- //
8
- // The SDK never touches secrets: it asks YOUR backend for an offer per protocol
9
- // via the `createSession` factory, then runs the wallet flow and resolves the
10
- // result via your poll/SSE endpoints. DC API (ISO 18013-7) and the OID4VP
11
- // fallback are separate espuni offers, so the factory is called per protocol.
12
-
13
- import {
14
- buildAvOfferLinks,
15
- extractAgeOver18,
16
- isDcApiProtocol,
17
- resolveAvProtocol,
18
- type AvProtocol,
19
- } from '@espuni/core';
20
-
21
- export * from '@espuni/core';
22
-
23
- /** Session/offer as returned by espuni's `createSession` (server-side). */
24
- export interface EspuniSession {
25
- sessionId: string;
26
- /** OID4VP deeplink `openid4vp://...` (same-device). */
27
- uri?: string;
28
- /** OID4VP cross-device URI to encode as a QR. */
29
- crossDeviceUri?: string;
30
- /** Present for the ISO 18013-7 DC API flow. */
31
- orgIsoMdoc?: { deviceRequest: string; encryptionInfo?: string };
32
- }
33
-
34
- /** What the `createSession` factory returns for a given protocol. */
35
- export interface SessionBundle {
36
- session: EspuniSession;
37
- /** Endpoint on YOUR backend that forwards the DC API response to espuni. */
38
- dcApiSubmitUrl?: string;
39
- /** GET endpoint returning `{ status, claims }` (polled with no-store). */
40
- pollUrl?: string;
41
- /** SSE endpoint streaming `{ status, claims }` events. */
42
- eventsUrl?: string;
43
- }
44
-
45
- export interface VerifyResult {
46
- status: 'completed';
47
- claims: unknown;
48
- /** Derived from `claims` via @espuni/core. */
49
- ageOver18: boolean;
50
- }
51
-
52
- export interface VerifyFailure {
53
- error: string;
54
- status?: string;
55
- claims?: unknown;
56
- }
57
-
58
- export interface VerifyOptions {
59
- /**
60
- * Factory the SDK calls to obtain an offer for a chosen protocol. Called with
61
- * `'dc-api-iso'` first when the browser supports the DC API, then `'oid4vp'`
62
- * as fallback (or just `'oid4vp'` when the DC API is unavailable).
63
- */
64
- createSession: (protocol: AvProtocol) => Promise<SessionBundle>;
65
- /** Force a protocol. Default `'auto'` (capability-based). */
66
- protocol?: 'auto' | AvProtocol;
67
-
68
- /** Custom wallet scheme for the QR/deeplink (default `av`; `openid4vp` disables the rewrite). */
69
- deeplinkScheme?: string;
70
- /**
71
- * Container to render the QR into. May be a lazy getter — useful in
72
- * frameworks where the element mounts after `verify()` is called (the SDK
73
- * resolves it only when it actually renders the QR).
74
- */
75
- container?: HTMLElement | null | (() => HTMLElement | null);
76
- /** Custom QR renderer. Defaults to qrcodejs (`window.QRCode`, lazy-loaded from `qrCdnUrl`). */
77
- renderQr?: (container: HTMLElement, uri: string) => void;
78
- /** CDN URL for the default QR lib (qrcodejs). */
79
- qrCdnUrl?: string;
80
- /** Called with the derived links so the host can render its own QR/buttons. */
81
- onLinks?: (links: { qrUri: string; deepLinkUri: string; avLinkUri: string }) => void;
82
-
83
- /** Poll cadence in ms (default 2500). */
84
- pollIntervalMs?: number;
85
-
86
- onSuccess?: (result: VerifyResult) => void;
87
- onFailure?: (err: VerifyFailure) => void;
88
- }
89
-
90
- const DEFAULT_QR_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js';
91
-
92
- /** True when the browser exposes the Digital Credentials API. */
93
- export function hasDcApi(): boolean {
94
- try {
95
- return (
96
- typeof window !== 'undefined' &&
97
- typeof (window as unknown as { DigitalCredential?: unknown }).DigitalCredential !== 'undefined'
98
- );
99
- } catch {
100
- return false;
101
- }
102
- }
103
-
104
- /**
105
- * Runs the AV verification: DC API (ISO 18013-7) when available, otherwise the
106
- * OID4VP QR/deeplink fallback (and DC API falls back to OID4VP on error). The
107
- * result is resolved via the bundle's `pollUrl` / `eventsUrl`.
108
- */
109
- export function verify(opts: VerifyOptions): void {
110
- const primary = resolveAvProtocol(hasDcApi(), opts.protocol ?? 'auto');
111
- const fallback: AvProtocol | null = isDcApiProtocol(primary) ? 'oid4vp' : null;
112
- void attempt(opts, primary, fallback);
113
- }
114
-
115
- async function attempt(opts: VerifyOptions, protocol: AvProtocol, fallback: AvProtocol | null): Promise<void> {
116
- const toFallback = () => {
117
- if (fallback) void attempt(opts, fallback, null);
118
- else opts.onFailure?.({ error: 'error' });
119
- };
120
-
121
- let bundle: SessionBundle;
122
- try {
123
- bundle = await opts.createSession(protocol);
124
- } catch (err) {
125
- // Fall back to the next protocol on a create-session error; only surface
126
- // the failure once there is no fallback left (preserves the last error,
127
- // e.g. a "not configured" from the OID4VP attempt).
128
- if (fallback) void attempt(opts, fallback, null);
129
- else opts.onFailure?.({ error: (err as Error)?.message || 'create_session_failed' });
130
- return;
131
- }
132
- if (!bundle?.session?.sessionId) {
133
- toFallback();
134
- return;
135
- }
136
-
137
- if (isDcApiProtocol(protocol)) {
138
- if (!bundle.session.orgIsoMdoc || !bundle.dcApiSubmitUrl) {
139
- toFallback();
140
- return;
141
- }
142
- try {
143
- await runDcApi(bundle);
144
- startResultWatch(opts, bundle);
145
- } catch (err) {
146
- const name = (err as { name?: string } | undefined)?.name;
147
- if (name === 'NotAllowedError' || name === 'AbortError') {
148
- opts.onFailure?.({ error: 'cancelled' });
149
- } else {
150
- toFallback();
151
- }
152
- }
153
- return;
154
- }
155
-
156
- runOid4vp(opts, bundle);
157
- startResultWatch(opts, bundle);
158
- }
159
-
160
- async function runDcApi(bundle: SessionBundle): Promise<void> {
161
- const mdoc = bundle.session.orgIsoMdoc!;
162
- const nav = navigator as unknown as {
163
- credentials: { get: (o: unknown) => Promise<{ protocol: string; data: unknown } | null> };
164
- };
165
- const result = await nav.credentials.get({
166
- digital: {
167
- requests: [
168
- {
169
- protocol: 'org-iso-mdoc',
170
- data: { deviceRequest: mdoc.deviceRequest, encryptionInfo: mdoc.encryptionInfo },
171
- },
172
- ],
173
- },
174
- });
175
- if (!result) throw new Error('empty_result');
176
-
177
- let data: unknown = result.data;
178
- if (data instanceof ArrayBuffer) data = toBase64url(new Uint8Array(data));
179
- else if (data instanceof Uint8Array) data = toBase64url(data);
180
- else if (data && typeof data === 'object') {
181
- const d = data as { response?: unknown };
182
- data = d.response != null ? d.response : JSON.stringify(data);
183
- }
184
-
185
- const res = await fetch(bundle.dcApiSubmitUrl!, {
186
- method: 'POST',
187
- headers: { 'Content-Type': 'application/json' },
188
- body: JSON.stringify({ protocol: result.protocol, data }),
189
- });
190
- const body: unknown = await res.json().catch(() => ({}));
191
- if (!res.ok) throw Object.assign(new Error('dc_api_submit_failed'), { body });
192
- }
193
-
194
- function runOid4vp(opts: VerifyOptions, bundle: SessionBundle): void {
195
- const links = buildAvOfferLinks(
196
- { uri: bundle.session.uri ?? '', crossDeviceUri: bundle.session.crossDeviceUri },
197
- opts.deeplinkScheme,
198
- );
199
- opts.onLinks?.(links);
200
- const container = typeof opts.container === 'function' ? opts.container() : opts.container;
201
- if (container && links.qrUri) {
202
- (opts.renderQr ?? makeDefaultRenderQr(opts.qrCdnUrl))(container, links.qrUri);
203
- }
204
- }
205
-
206
- interface StatusMessage {
207
- status?: string;
208
- claims?: unknown;
209
- errorReason?: string | null;
210
- }
211
-
212
- /** Polls `pollUrl` and/or listens to `eventsUrl` until a terminal status. */
213
- function startResultWatch(opts: VerifyOptions, bundle: SessionBundle): void {
214
- let resolved = false;
215
- let sse: EventSource | null = null;
216
- let pollTimer: ReturnType<typeof setInterval> | null = null;
217
-
218
- const stop = () => {
219
- if (sse) { try { sse.close(); } catch { /* noop */ } sse = null; }
220
- if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
221
- };
222
- const handle = (msg: StatusMessage) => {
223
- if (resolved || !msg) return;
224
- if (msg.status === 'completed') {
225
- resolved = true;
226
- stop();
227
- opts.onSuccess?.({
228
- status: 'completed',
229
- claims: msg.claims ?? null,
230
- ageOver18: extractAgeOver18(msg.claims as never),
231
- });
232
- } else if (msg.status === 'failed' || msg.status === 'expired') {
233
- resolved = true;
234
- stop();
235
- opts.onFailure?.({ error: msg.status, status: msg.status, claims: msg.claims });
236
- }
237
- };
238
-
239
- if (bundle.eventsUrl) {
240
- try {
241
- sse = new EventSource(bundle.eventsUrl);
242
- sse.onmessage = (e) => {
243
- try { handle(JSON.parse(e.data) as StatusMessage); } catch { /* ignore */ }
244
- };
245
- sse.onerror = () => { if (sse && !resolved) { sse.close(); sse = null; } };
246
- } catch { /* SSE optional */ }
247
- }
248
-
249
- if (bundle.pollUrl) {
250
- const url = bundle.pollUrl;
251
- pollTimer = setInterval(() => {
252
- // no-store: the first poll usually returns `pending`; a cached response
253
- // would be served stale forever and the completion never observed.
254
- fetch(url, { cache: 'no-store' })
255
- .then((r) => (r.ok ? r.json() : null))
256
- .then((d) => { if (d) handle(d as StatusMessage); })
257
- .catch(() => { /* transient */ });
258
- }, opts.pollIntervalMs ?? 2500);
259
- }
260
- }
261
-
262
- // ── QR lib (qrcodejs) lazy loader — mirrors the original espuni-av.js snippet ──
263
- let qrState = 0; // 0 idle, 1 loading, 2 ready
264
- let qrQueue: Array<() => void> = [];
265
-
266
- function withQrLib(cdnUrl: string, fn: () => void): void {
267
- const g = window as unknown as { QRCode?: unknown };
268
- if (typeof g.QRCode !== 'undefined' || qrState === 2) { fn(); return; }
269
- qrQueue.push(fn);
270
- if (qrState === 1) return;
271
- qrState = 1;
272
- const s = document.createElement('script');
273
- s.src = cdnUrl;
274
- s.onload = () => { qrState = 2; qrQueue.forEach((f) => f()); qrQueue = []; };
275
- s.onerror = () => { qrState = 0; qrQueue = []; };
276
- (document.head || document.body).appendChild(s);
277
- }
278
-
279
- function makeDefaultRenderQr(cdnUrl = DEFAULT_QR_CDN) {
280
- return (container: HTMLElement, uri: string): void => {
281
- container.innerHTML = '';
282
- withQrLib(cdnUrl, () => {
283
- const g = window as unknown as {
284
- QRCode?: (new (el: HTMLElement, cfg: Record<string, unknown>) => void) & {
285
- CorrectLevel: { L: number };
286
- };
287
- };
288
- if (typeof g.QRCode === 'function') {
289
- try {
290
- // Level L = fewest modules for the long by-value redirect_uri payload.
291
- new g.QRCode(container, { text: uri, width: 260, height: 260, correctLevel: g.QRCode.CorrectLevel.L });
292
- return;
293
- } catch { /* fall through */ }
294
- }
295
- container.textContent = uri;
296
- });
297
- };
298
- }
299
-
300
- function toBase64url(bytes: Uint8Array): string {
301
- let s = '';
302
- for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
303
- return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
304
- }
305
-
306
- /** Namespaced default export for the IIFE build (`window.espuni`). */
307
- const espuni = { hasDcApi, verify };
308
- export default espuni;