@espuni/browser 0.1.1

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/dist/index.mjs ADDED
@@ -0,0 +1,304 @@
1
+ // ../core/src/index.ts
2
+ var OID4VP_SCHEME = "openid4vp";
3
+ var DEFAULT_AV_DEEPLINK_SCHEME = "av";
4
+ function resolveAvDeeplinkScheme(envValue) {
5
+ const scheme = (envValue != null ? envValue : DEFAULT_AV_DEEPLINK_SCHEME).trim();
6
+ return scheme || DEFAULT_AV_DEEPLINK_SCHEME;
7
+ }
8
+ function resolveAvProtocol(hasDcApi2, requested = "auto") {
9
+ if (requested === "dc-api" || requested === "dc-api-iso" || requested === "oid4vp") {
10
+ return requested;
11
+ }
12
+ return hasDcApi2 ? "dc-api-iso" : "oid4vp";
13
+ }
14
+ function isDcApiProtocol(protocol) {
15
+ return protocol === "dc-api" || protocol === "dc-api-iso";
16
+ }
17
+ function rewriteDeeplinkScheme(uri, scheme = DEFAULT_AV_DEEPLINK_SCHEME) {
18
+ if (!uri) return uri;
19
+ const target = (scheme || "").trim();
20
+ if (!target || target === OID4VP_SCHEME) return uri;
21
+ return uri.replace(/^openid4vp:\/\//, `${target}://`);
22
+ }
23
+ function buildAvOfferLinks(offer, scheme = DEFAULT_AV_DEEPLINK_SCHEME) {
24
+ var _a;
25
+ const crossDevice = (_a = offer.crossDeviceUri) != null ? _a : offer.uri;
26
+ return {
27
+ qrUri: rewriteDeeplinkScheme(crossDevice, scheme),
28
+ deepLinkUri: offer.uri,
29
+ avLinkUri: rewriteDeeplinkScheme(offer.uri, scheme)
30
+ };
31
+ }
32
+ function extractAgeOver18(claims) {
33
+ var _a;
34
+ if (!claims) return true;
35
+ if (!Array.isArray(claims)) {
36
+ const v = claims["age_over_18"];
37
+ return typeof v === "boolean" ? v : true;
38
+ }
39
+ for (const c of claims) {
40
+ if (c.claims && "age_over_18" in c.claims) {
41
+ const v = c.claims["age_over_18"];
42
+ return typeof v === "boolean" ? v : true;
43
+ }
44
+ for (const obj of (_a = c.values) != null ? _a : []) {
45
+ if ("age_over_18" in obj) {
46
+ const v = obj["age_over_18"];
47
+ return typeof v === "boolean" ? v : true;
48
+ }
49
+ }
50
+ }
51
+ return true;
52
+ }
53
+ function buildFallbackVerifierConfig(opts) {
54
+ var _a, _b;
55
+ const trusted = (_a = opts.trustedAuthorities) != null ? _a : [];
56
+ return {
57
+ id: opts.id,
58
+ description: `AV OID4VP fallback (redirect_uri): ${opts.ageClaim}`,
59
+ lifeTime: (_b = opts.lifeTime) != null ? _b : 300,
60
+ clientIdScheme: "redirect_uri",
61
+ webhook: { url: opts.webhookUrl, auth: { type: "none" } },
62
+ dcql_query: {
63
+ credentials: [
64
+ {
65
+ id: "av-credential",
66
+ format: "mso_mdoc",
67
+ claims: [{ path: ["eu.europa.ec.av.1", opts.ageClaim] }],
68
+ // OpenID4VP DCQL spec: mso_mdoc `meta` accepts ONLY `doctype_value`.
69
+ // A redundant `doctype` here (and this is the redirect_uri/AVP path,
70
+ // which never hits EUDIPLO's iso18013 code that needs it) makes a
71
+ // strict wallet — confirmed with AltID's AVP profile — reject the
72
+ // whole request. Keep it minimal and conformant.
73
+ meta: { doctype_value: "eu.europa.ec.av.1" },
74
+ ...trusted.length > 0 ? { trusted_authorities: trusted } : {}
75
+ }
76
+ ]
77
+ // `credential_sets` omitted: with a single credential its absence already
78
+ // means "request all of credentials[]" (DCQL default), and some stricter
79
+ // wallets (AltID AVP) reject the redundant form.
80
+ }
81
+ };
82
+ }
83
+
84
+ // src/index.ts
85
+ var DEFAULT_QR_CDN = "https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js";
86
+ function hasDcApi() {
87
+ try {
88
+ return typeof window !== "undefined" && typeof window.DigitalCredential !== "undefined";
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+ function verify(opts) {
94
+ var _a;
95
+ const primary = resolveAvProtocol(hasDcApi(), (_a = opts.protocol) != null ? _a : "auto");
96
+ const fallback = isDcApiProtocol(primary) ? "oid4vp" : null;
97
+ void attempt(opts, primary, fallback);
98
+ }
99
+ async function attempt(opts, protocol, fallback) {
100
+ var _a, _b, _c;
101
+ const toFallback = () => {
102
+ var _a2;
103
+ if (fallback) void attempt(opts, fallback, null);
104
+ else (_a2 = opts.onFailure) == null ? void 0 : _a2.call(opts, { error: "error" });
105
+ };
106
+ let bundle;
107
+ try {
108
+ bundle = await opts.createSession(protocol);
109
+ } catch (err) {
110
+ if (fallback) void attempt(opts, fallback, null);
111
+ else (_a = opts.onFailure) == null ? void 0 : _a.call(opts, { error: (err == null ? void 0 : err.message) || "create_session_failed" });
112
+ return;
113
+ }
114
+ if (!((_b = bundle == null ? void 0 : bundle.session) == null ? void 0 : _b.sessionId)) {
115
+ toFallback();
116
+ return;
117
+ }
118
+ if (isDcApiProtocol(protocol)) {
119
+ if (!bundle.session.orgIsoMdoc || !bundle.dcApiSubmitUrl) {
120
+ toFallback();
121
+ return;
122
+ }
123
+ try {
124
+ await runDcApi(bundle);
125
+ startResultWatch(opts, bundle);
126
+ } catch (err) {
127
+ const name = err == null ? void 0 : err.name;
128
+ if (name === "NotAllowedError" || name === "AbortError") {
129
+ (_c = opts.onFailure) == null ? void 0 : _c.call(opts, { error: "cancelled" });
130
+ } else {
131
+ toFallback();
132
+ }
133
+ }
134
+ return;
135
+ }
136
+ runOid4vp(opts, bundle);
137
+ startResultWatch(opts, bundle);
138
+ }
139
+ async function runDcApi(bundle) {
140
+ const mdoc = bundle.session.orgIsoMdoc;
141
+ const nav = navigator;
142
+ const result = await nav.credentials.get({
143
+ digital: {
144
+ requests: [
145
+ {
146
+ protocol: "org-iso-mdoc",
147
+ data: { deviceRequest: mdoc.deviceRequest, encryptionInfo: mdoc.encryptionInfo }
148
+ }
149
+ ]
150
+ }
151
+ });
152
+ if (!result) throw new Error("empty_result");
153
+ let data = result.data;
154
+ if (data instanceof ArrayBuffer) data = toBase64url(new Uint8Array(data));
155
+ else if (data instanceof Uint8Array) data = toBase64url(data);
156
+ else if (data && typeof data === "object") {
157
+ const d = data;
158
+ data = d.response != null ? d.response : JSON.stringify(data);
159
+ }
160
+ const res = await fetch(bundle.dcApiSubmitUrl, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ protocol: result.protocol, data })
164
+ });
165
+ const body = await res.json().catch(() => ({}));
166
+ if (!res.ok) throw Object.assign(new Error("dc_api_submit_failed"), { body });
167
+ }
168
+ function runOid4vp(opts, bundle) {
169
+ var _a, _b, _c;
170
+ const links = buildAvOfferLinks(
171
+ { uri: (_a = bundle.session.uri) != null ? _a : "", crossDeviceUri: bundle.session.crossDeviceUri },
172
+ opts.deeplinkScheme
173
+ );
174
+ (_b = opts.onLinks) == null ? void 0 : _b.call(opts, links);
175
+ const container = typeof opts.container === "function" ? opts.container() : opts.container;
176
+ if (container && links.qrUri) {
177
+ ((_c = opts.renderQr) != null ? _c : makeDefaultRenderQr(opts.qrCdnUrl))(container, links.qrUri);
178
+ }
179
+ }
180
+ function startResultWatch(opts, bundle) {
181
+ var _a;
182
+ let resolved = false;
183
+ let sse = null;
184
+ let pollTimer = null;
185
+ const stop = () => {
186
+ if (sse) {
187
+ try {
188
+ sse.close();
189
+ } catch {
190
+ }
191
+ sse = null;
192
+ }
193
+ if (pollTimer) {
194
+ clearInterval(pollTimer);
195
+ pollTimer = null;
196
+ }
197
+ };
198
+ const handle = (msg) => {
199
+ var _a2, _b, _c;
200
+ if (resolved || !msg) return;
201
+ if (msg.status === "completed") {
202
+ resolved = true;
203
+ stop();
204
+ (_b = opts.onSuccess) == null ? void 0 : _b.call(opts, {
205
+ status: "completed",
206
+ claims: (_a2 = msg.claims) != null ? _a2 : null,
207
+ ageOver18: extractAgeOver18(msg.claims)
208
+ });
209
+ } else if (msg.status === "failed" || msg.status === "expired") {
210
+ resolved = true;
211
+ stop();
212
+ (_c = opts.onFailure) == null ? void 0 : _c.call(opts, { error: msg.status, status: msg.status, claims: msg.claims });
213
+ }
214
+ };
215
+ if (bundle.eventsUrl) {
216
+ try {
217
+ sse = new EventSource(bundle.eventsUrl);
218
+ sse.onmessage = (e) => {
219
+ try {
220
+ handle(JSON.parse(e.data));
221
+ } catch {
222
+ }
223
+ };
224
+ sse.onerror = () => {
225
+ if (sse && !resolved) {
226
+ sse.close();
227
+ sse = null;
228
+ }
229
+ };
230
+ } catch {
231
+ }
232
+ }
233
+ if (bundle.pollUrl) {
234
+ const url = bundle.pollUrl;
235
+ pollTimer = setInterval(() => {
236
+ fetch(url, { cache: "no-store" }).then((r) => r.ok ? r.json() : null).then((d) => {
237
+ if (d) handle(d);
238
+ }).catch(() => {
239
+ });
240
+ }, (_a = opts.pollIntervalMs) != null ? _a : 2500);
241
+ }
242
+ }
243
+ var qrState = 0;
244
+ var qrQueue = [];
245
+ function withQrLib(cdnUrl, fn) {
246
+ const g = window;
247
+ if (typeof g.QRCode !== "undefined" || qrState === 2) {
248
+ fn();
249
+ return;
250
+ }
251
+ qrQueue.push(fn);
252
+ if (qrState === 1) return;
253
+ qrState = 1;
254
+ const s = document.createElement("script");
255
+ s.src = cdnUrl;
256
+ s.onload = () => {
257
+ qrState = 2;
258
+ qrQueue.forEach((f) => f());
259
+ qrQueue = [];
260
+ };
261
+ s.onerror = () => {
262
+ qrState = 0;
263
+ qrQueue = [];
264
+ };
265
+ (document.head || document.body).appendChild(s);
266
+ }
267
+ function makeDefaultRenderQr(cdnUrl = DEFAULT_QR_CDN) {
268
+ return (container, uri) => {
269
+ container.innerHTML = "";
270
+ withQrLib(cdnUrl, () => {
271
+ const g = window;
272
+ if (typeof g.QRCode === "function") {
273
+ try {
274
+ new g.QRCode(container, { text: uri, width: 260, height: 260, correctLevel: g.QRCode.CorrectLevel.L });
275
+ return;
276
+ } catch {
277
+ }
278
+ }
279
+ container.textContent = uri;
280
+ });
281
+ };
282
+ }
283
+ function toBase64url(bytes) {
284
+ let s = "";
285
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
286
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
287
+ }
288
+ var espuni = { hasDcApi, verify };
289
+ var index_default = espuni;
290
+ export {
291
+ DEFAULT_AV_DEEPLINK_SCHEME,
292
+ OID4VP_SCHEME,
293
+ buildAvOfferLinks,
294
+ buildFallbackVerifierConfig,
295
+ index_default as default,
296
+ extractAgeOver18,
297
+ hasDcApi,
298
+ isDcApiProtocol,
299
+ resolveAvDeeplinkScheme,
300
+ resolveAvProtocol,
301
+ rewriteDeeplinkScheme,
302
+ verify
303
+ };
304
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../core/src/index.ts","../src/index.ts"],"sourcesContent":["// @espuni/core — framework-free, isomorphic core shared by the espuni SDKs and\n// the portal. Pure logic only: no I/O, no DOM, no environment access, so it runs\n// identically in Node and the browser and is trivially unit-testable.\n//\n// Covers the two AV presentation paths:\n// 1. DC API (ISO 18013-7 / org-iso-mdoc) — the default when the browser exposes\n// the Digital Credentials API.\n// 2. OID4VP QR/deeplink fallback — an unsigned request-by-value against an\n// EUDIPLO presentation config whose `clientIdScheme` is `redirect_uri`\n// (EU AV profile Annex A §A.6), used when the DC API is unavailable.\n\n/** Deeplink URI scheme EUDIPLO emits for OID4VP requests. */\nexport const OID4VP_SCHEME = 'openid4vp';\n\n/**\n * Default same-device URI scheme the reference EU AV wallet registers. EUDIPLO\n * always emits `openid4vp://`; when the target wallet only intercepts `av://`\n * the prefix is rewritten (the rest of the query is identical).\n */\nexport const DEFAULT_AV_DEEPLINK_SCHEME = 'av';\n\n/**\n * Normalizes a configured deeplink scheme value (trim, fall back to the default\n * when empty/undefined). Pure: the caller supplies the value (e.g. from an env\n * var) so this stays environment-agnostic.\n */\nexport function resolveAvDeeplinkScheme(envValue?: string): string {\n const scheme = (envValue ?? DEFAULT_AV_DEEPLINK_SCHEME).trim();\n return scheme || DEFAULT_AV_DEEPLINK_SCHEME;\n}\n\nexport type AvProtocol = 'dc-api-iso' | 'dc-api' | 'oid4vp';\n\n/**\n * Selection rule shared by every AV surface: prefer the DC API (ISO 18013-7)\n * when the browser supports it, otherwise fall back to the OID4VP QR/deeplink\n * flow. An explicit non-`auto` request is honoured verbatim.\n */\nexport function resolveAvProtocol(\n hasDcApi: boolean,\n requested: string | undefined = 'auto',\n): AvProtocol {\n if (requested === 'dc-api' || requested === 'dc-api-iso' || requested === 'oid4vp') {\n return requested;\n }\n // 'auto' (or anything unknown): browser capability decides.\n return hasDcApi ? 'dc-api-iso' : 'oid4vp';\n}\n\nexport function isDcApiProtocol(protocol: AvProtocol): boolean {\n return protocol === 'dc-api' || protocol === 'dc-api-iso';\n}\n\n/**\n * Rewrites the `openid4vp://` prefix of a deeplink to a custom wallet scheme\n * (e.g. `av`). Only the scheme changes — the query string (all OID4VP params\n * passed by value) is preserved byte-for-byte. Returns the URI unchanged when\n * the scheme is empty or already `openid4vp`, or when the URI doesn't use the\n * `openid4vp://` prefix.\n */\nexport function rewriteDeeplinkScheme(\n uri: string,\n scheme: string = DEFAULT_AV_DEEPLINK_SCHEME,\n): string {\n if (!uri) return uri;\n const target = (scheme || '').trim();\n if (!target || target === OID4VP_SCHEME) return uri;\n return uri.replace(/^openid4vp:\\/\\//, `${target}://`);\n}\n\nexport interface EudiploOfferUris {\n uri: string;\n crossDeviceUri?: string;\n}\n\nexport interface AvOfferLinks {\n /**\n * URI encoded into the QR code (cross-device), rewritten to the AV wallet\n * scheme (e.g. `av://`). The reference AV wallet only engages its custom\n * scheme, so the QR must carry it too — not just the same-device button.\n * Set the scheme to `openid4vp` to keep the standard scheme (e.g. to target\n * a generic EUDI wallet).\n */\n qrUri: string;\n /** Same-device deeplink kept on the standard `openid4vp://` scheme. */\n deepLinkUri: string;\n /** Same-device deeplink rewritten to the AV wallet scheme (e.g. `av://`). */\n avLinkUri: string;\n}\n\n/**\n * Derives the QR / same-device links to render for the OID4VP fallback. Both\n * the QR and the AV button use the configurable wallet scheme (default `av://`)\n * so the reference AV wallet engages either way; `deepLinkUri` keeps the raw\n * `openid4vp://` scheme for a standard EUDI wallet.\n */\nexport function buildAvOfferLinks(\n offer: EudiploOfferUris,\n scheme: string = DEFAULT_AV_DEEPLINK_SCHEME,\n): AvOfferLinks {\n const crossDevice = offer.crossDeviceUri ?? offer.uri;\n return {\n qrUri: rewriteDeeplinkScheme(crossDevice, scheme),\n deepLinkUri: offer.uri,\n avLinkUri: rewriteDeeplinkScheme(offer.uri, scheme),\n };\n}\n\nexport type AvClaims =\n | Record<string, unknown>\n | Array<{\n claims?: Record<string, unknown>;\n values?: Array<Record<string, unknown>>;\n }>\n | null\n | undefined;\n\n/**\n * Extracts the `age_over_18` boolean from a completed AV result, tolerating\n * both shapes CP can hand back:\n * - flat object `{ age_over_18: true }` — `GET /api/session/:id` (poll/SSE)\n * - array of credentials `[{ claims } | { values }]` — raw EUDIPLO credentials\n *\n * The claim is non-disclosable, so a completed presentation already proves the\n * holder is over 18; we only read an explicit disclosed value as an override,\n * defaulting to `true`. Iterating the flat-object form as if it were an array\n * throws a swallowed TypeError and silently strands the UI — hence the guard.\n */\nexport function extractAgeOver18(claims: AvClaims): boolean {\n if (!claims) return true;\n\n // Flat object form: { age_over_18: true }\n if (!Array.isArray(claims)) {\n const v = (claims as Record<string, unknown>)['age_over_18'];\n return typeof v === 'boolean' ? v : true;\n }\n\n // Array-of-credentials form\n for (const c of claims) {\n if (c.claims && 'age_over_18' in c.claims) {\n const v = c.claims['age_over_18'];\n return typeof v === 'boolean' ? v : true;\n }\n for (const obj of c.values ?? []) {\n if ('age_over_18' in obj) {\n const v = obj['age_over_18'];\n return typeof v === 'boolean' ? v : true;\n }\n }\n }\n return true;\n}\n\nexport interface TrustedAuthority {\n type: string;\n values: string[];\n}\n\n/**\n * Builds an EUDIPLO presentation config for the OID4VP QR/deeplink fallback:\n * unsigned request-by-value + unencrypted `direct_post` via\n * `clientIdScheme: \"redirect_uri\"`. Kept distinct from the DC API config\n * because EUDIPLO branches on `clientIdScheme` before it inspects the response\n * type — a `redirect_uri` config must never be used for a DC API request.\n */\nexport function buildFallbackVerifierConfig(opts: {\n id: string;\n ageClaim: string;\n trustedAuthorities?: TrustedAuthority[];\n webhookUrl: string;\n lifeTime?: number;\n}) {\n const trusted = opts.trustedAuthorities ?? [];\n return {\n id: opts.id,\n description: `AV OID4VP fallback (redirect_uri): ${opts.ageClaim}`,\n lifeTime: opts.lifeTime ?? 300,\n clientIdScheme: 'redirect_uri' as const,\n webhook: { url: opts.webhookUrl, auth: { type: 'none' } },\n dcql_query: {\n credentials: [\n {\n id: 'av-credential',\n format: 'mso_mdoc',\n claims: [{ path: ['eu.europa.ec.av.1', opts.ageClaim] }],\n // OpenID4VP DCQL spec: mso_mdoc `meta` accepts ONLY `doctype_value`.\n // A redundant `doctype` here (and this is the redirect_uri/AVP path,\n // which never hits EUDIPLO's iso18013 code that needs it) makes a\n // strict wallet — confirmed with AltID's AVP profile — reject the\n // whole request. Keep it minimal and conformant.\n meta: { doctype_value: 'eu.europa.ec.av.1' },\n ...(trusted.length > 0 ? { trusted_authorities: trusted } : {}),\n },\n ],\n // `credential_sets` omitted: with a single credential its absence already\n // means \"request all of credentials[]\" (DCQL default), and some stricter\n // wallets (AltID AVP) reject the redundant form.\n },\n };\n}\n","// @espuni/browser — browser SDK for the espuni Age Verification flow.\n//\n// Encapsulates the DC API → OID4VP/QR fallback so relying parties don't\n// reimplement it. Pure decisions (protocol selection, av:// scheme rewrite,\n// QR/deeplink derivation, claim extraction) come from @espuni/core, bundled in\n// at build time.\n//\n// The SDK never touches secrets: it asks YOUR backend for an offer per protocol\n// via the `createSession` factory, then runs the wallet flow and resolves the\n// result via your poll/SSE endpoints. DC API (ISO 18013-7) and the OID4VP\n// fallback are separate espuni offers, so the factory is called per protocol.\n\nimport {\n buildAvOfferLinks,\n extractAgeOver18,\n isDcApiProtocol,\n resolveAvProtocol,\n type AvProtocol,\n} from '@espuni/core';\n\nexport * from '@espuni/core';\n\n/** Session/offer as returned by espuni's `createSession` (server-side). */\nexport interface EspuniSession {\n sessionId: string;\n /** OID4VP deeplink `openid4vp://...` (same-device). */\n uri?: string;\n /** OID4VP cross-device URI to encode as a QR. */\n crossDeviceUri?: string;\n /** Present for the ISO 18013-7 DC API flow. */\n orgIsoMdoc?: { deviceRequest: string; encryptionInfo?: string };\n}\n\n/** What the `createSession` factory returns for a given protocol. */\nexport interface SessionBundle {\n session: EspuniSession;\n /** Endpoint on YOUR backend that forwards the DC API response to espuni. */\n dcApiSubmitUrl?: string;\n /** GET endpoint returning `{ status, claims }` (polled with no-store). */\n pollUrl?: string;\n /** SSE endpoint streaming `{ status, claims }` events. */\n eventsUrl?: string;\n}\n\nexport interface VerifyResult {\n status: 'completed';\n claims: unknown;\n /** Derived from `claims` via @espuni/core. */\n ageOver18: boolean;\n}\n\nexport interface VerifyFailure {\n error: string;\n status?: string;\n claims?: unknown;\n}\n\nexport interface VerifyOptions {\n /**\n * Factory the SDK calls to obtain an offer for a chosen protocol. Called with\n * `'dc-api-iso'` first when the browser supports the DC API, then `'oid4vp'`\n * as fallback (or just `'oid4vp'` when the DC API is unavailable).\n */\n createSession: (protocol: AvProtocol) => Promise<SessionBundle>;\n /** Force a protocol. Default `'auto'` (capability-based). */\n protocol?: 'auto' | AvProtocol;\n\n /** Custom wallet scheme for the QR/deeplink (default `av`; `openid4vp` disables the rewrite). */\n deeplinkScheme?: string;\n /**\n * Container to render the QR into. May be a lazy getter — useful in\n * frameworks where the element mounts after `verify()` is called (the SDK\n * resolves it only when it actually renders the QR).\n */\n container?: HTMLElement | null | (() => HTMLElement | null);\n /** Custom QR renderer. Defaults to qrcodejs (`window.QRCode`, lazy-loaded from `qrCdnUrl`). */\n renderQr?: (container: HTMLElement, uri: string) => void;\n /** CDN URL for the default QR lib (qrcodejs). */\n qrCdnUrl?: string;\n /** Called with the derived links so the host can render its own QR/buttons. */\n onLinks?: (links: { qrUri: string; deepLinkUri: string; avLinkUri: string }) => void;\n\n /** Poll cadence in ms (default 2500). */\n pollIntervalMs?: number;\n\n onSuccess?: (result: VerifyResult) => void;\n onFailure?: (err: VerifyFailure) => void;\n}\n\nconst DEFAULT_QR_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js';\n\n/** True when the browser exposes the Digital Credentials API. */\nexport function hasDcApi(): boolean {\n try {\n return (\n typeof window !== 'undefined' &&\n typeof (window as unknown as { DigitalCredential?: unknown }).DigitalCredential !== 'undefined'\n );\n } catch {\n return false;\n }\n}\n\n/**\n * Runs the AV verification: DC API (ISO 18013-7) when available, otherwise the\n * OID4VP QR/deeplink fallback (and DC API falls back to OID4VP on error). The\n * result is resolved via the bundle's `pollUrl` / `eventsUrl`.\n */\nexport function verify(opts: VerifyOptions): void {\n const primary = resolveAvProtocol(hasDcApi(), opts.protocol ?? 'auto');\n const fallback: AvProtocol | null = isDcApiProtocol(primary) ? 'oid4vp' : null;\n void attempt(opts, primary, fallback);\n}\n\nasync function attempt(opts: VerifyOptions, protocol: AvProtocol, fallback: AvProtocol | null): Promise<void> {\n const toFallback = () => {\n if (fallback) void attempt(opts, fallback, null);\n else opts.onFailure?.({ error: 'error' });\n };\n\n let bundle: SessionBundle;\n try {\n bundle = await opts.createSession(protocol);\n } catch (err) {\n // Fall back to the next protocol on a create-session error; only surface\n // the failure once there is no fallback left (preserves the last error,\n // e.g. a \"not configured\" from the OID4VP attempt).\n if (fallback) void attempt(opts, fallback, null);\n else opts.onFailure?.({ error: (err as Error)?.message || 'create_session_failed' });\n return;\n }\n if (!bundle?.session?.sessionId) {\n toFallback();\n return;\n }\n\n if (isDcApiProtocol(protocol)) {\n if (!bundle.session.orgIsoMdoc || !bundle.dcApiSubmitUrl) {\n toFallback();\n return;\n }\n try {\n await runDcApi(bundle);\n startResultWatch(opts, bundle);\n } catch (err) {\n const name = (err as { name?: string } | undefined)?.name;\n if (name === 'NotAllowedError' || name === 'AbortError') {\n opts.onFailure?.({ error: 'cancelled' });\n } else {\n toFallback();\n }\n }\n return;\n }\n\n runOid4vp(opts, bundle);\n startResultWatch(opts, bundle);\n}\n\nasync function runDcApi(bundle: SessionBundle): Promise<void> {\n const mdoc = bundle.session.orgIsoMdoc!;\n const nav = navigator as unknown as {\n credentials: { get: (o: unknown) => Promise<{ protocol: string; data: unknown } | null> };\n };\n const result = await nav.credentials.get({\n digital: {\n requests: [\n {\n protocol: 'org-iso-mdoc',\n data: { deviceRequest: mdoc.deviceRequest, encryptionInfo: mdoc.encryptionInfo },\n },\n ],\n },\n });\n if (!result) throw new Error('empty_result');\n\n let data: unknown = result.data;\n if (data instanceof ArrayBuffer) data = toBase64url(new Uint8Array(data));\n else if (data instanceof Uint8Array) data = toBase64url(data);\n else if (data && typeof data === 'object') {\n const d = data as { response?: unknown };\n data = d.response != null ? d.response : JSON.stringify(data);\n }\n\n const res = await fetch(bundle.dcApiSubmitUrl!, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ protocol: result.protocol, data }),\n });\n const body: unknown = await res.json().catch(() => ({}));\n if (!res.ok) throw Object.assign(new Error('dc_api_submit_failed'), { body });\n}\n\nfunction runOid4vp(opts: VerifyOptions, bundle: SessionBundle): void {\n const links = buildAvOfferLinks(\n { uri: bundle.session.uri ?? '', crossDeviceUri: bundle.session.crossDeviceUri },\n opts.deeplinkScheme,\n );\n opts.onLinks?.(links);\n const container = typeof opts.container === 'function' ? opts.container() : opts.container;\n if (container && links.qrUri) {\n (opts.renderQr ?? makeDefaultRenderQr(opts.qrCdnUrl))(container, links.qrUri);\n }\n}\n\ninterface StatusMessage {\n status?: string;\n claims?: unknown;\n errorReason?: string | null;\n}\n\n/** Polls `pollUrl` and/or listens to `eventsUrl` until a terminal status. */\nfunction startResultWatch(opts: VerifyOptions, bundle: SessionBundle): void {\n let resolved = false;\n let sse: EventSource | null = null;\n let pollTimer: ReturnType<typeof setInterval> | null = null;\n\n const stop = () => {\n if (sse) { try { sse.close(); } catch { /* noop */ } sse = null; }\n if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }\n };\n const handle = (msg: StatusMessage) => {\n if (resolved || !msg) return;\n if (msg.status === 'completed') {\n resolved = true;\n stop();\n opts.onSuccess?.({\n status: 'completed',\n claims: msg.claims ?? null,\n ageOver18: extractAgeOver18(msg.claims as never),\n });\n } else if (msg.status === 'failed' || msg.status === 'expired') {\n resolved = true;\n stop();\n opts.onFailure?.({ error: msg.status, status: msg.status, claims: msg.claims });\n }\n };\n\n if (bundle.eventsUrl) {\n try {\n sse = new EventSource(bundle.eventsUrl);\n sse.onmessage = (e) => {\n try { handle(JSON.parse(e.data) as StatusMessage); } catch { /* ignore */ }\n };\n sse.onerror = () => { if (sse && !resolved) { sse.close(); sse = null; } };\n } catch { /* SSE optional */ }\n }\n\n if (bundle.pollUrl) {\n const url = bundle.pollUrl;\n pollTimer = setInterval(() => {\n // no-store: the first poll usually returns `pending`; a cached response\n // would be served stale forever and the completion never observed.\n fetch(url, { cache: 'no-store' })\n .then((r) => (r.ok ? r.json() : null))\n .then((d) => { if (d) handle(d as StatusMessage); })\n .catch(() => { /* transient */ });\n }, opts.pollIntervalMs ?? 2500);\n }\n}\n\n// ── QR lib (qrcodejs) lazy loader — mirrors the original espuni-av.js snippet ──\nlet qrState = 0; // 0 idle, 1 loading, 2 ready\nlet qrQueue: Array<() => void> = [];\n\nfunction withQrLib(cdnUrl: string, fn: () => void): void {\n const g = window as unknown as { QRCode?: unknown };\n if (typeof g.QRCode !== 'undefined' || qrState === 2) { fn(); return; }\n qrQueue.push(fn);\n if (qrState === 1) return;\n qrState = 1;\n const s = document.createElement('script');\n s.src = cdnUrl;\n s.onload = () => { qrState = 2; qrQueue.forEach((f) => f()); qrQueue = []; };\n s.onerror = () => { qrState = 0; qrQueue = []; };\n (document.head || document.body).appendChild(s);\n}\n\nfunction makeDefaultRenderQr(cdnUrl = DEFAULT_QR_CDN) {\n return (container: HTMLElement, uri: string): void => {\n container.innerHTML = '';\n withQrLib(cdnUrl, () => {\n const g = window as unknown as {\n QRCode?: (new (el: HTMLElement, cfg: Record<string, unknown>) => void) & {\n CorrectLevel: { L: number };\n };\n };\n if (typeof g.QRCode === 'function') {\n try {\n // Level L = fewest modules for the long by-value redirect_uri payload.\n new g.QRCode(container, { text: uri, width: 260, height: 260, correctLevel: g.QRCode.CorrectLevel.L });\n return;\n } catch { /* fall through */ }\n }\n container.textContent = uri;\n });\n };\n}\n\nfunction toBase64url(bytes: Uint8Array): string {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\n/** Namespaced default export for the IIFE build (`window.espuni`). */\nconst espuni = { hasDcApi, verify };\nexport default espuni;\n"],"mappings":"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","names":["hasDcApi","_a"]}
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@espuni/browser",
3
+ "version": "0.1.1",
4
+ "description": "Browser SDK for the espuni Age Verification flow (DC API → OID4VP/QR fallback)",
5
+ "license": "MIT",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "browser": "dist/index.global.js",
12
+ "unpkg": "dist/index.global.js",
13
+ "jsdelivr": "dist/index.global.js",
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "devDependencies": {
21
+ "tsup": "^8.0.0",
22
+ "typescript": "^5.4.5",
23
+ "@espuni/core": "0.1.1"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev": "tsup --watch"
28
+ }
29
+ }