@coordination-network/user-app-link 0.0.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.js ADDED
@@ -0,0 +1,539 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Protocol v1 types for the user-app <-> network-member-app deep link. */
4
+ /** Where the user app's device key lives in the platform's key hierarchy. */
5
+ const SECURITY_LEVELS = ["STRONGBOX", "TRUSTED_ENVIRONMENT", "SOFTWARE", "UNKNOWN"];
6
+ const LINK_OPS = [
7
+ "registration.sign",
8
+ "credential.issue",
9
+ "presentation.create"
10
+ ];
11
+ const LINK_ERROR_CODES = [
12
+ "USER_DENIED",
13
+ /** Declined at the share step, after the proof was built and the person saw what it reveals. */
14
+ "CONSENT_DENIED",
15
+ "UNSUPPORTED_OP",
16
+ "INVALID_REQUEST",
17
+ "NOT_ONBOARDED",
18
+ "NO_CREDENTIAL",
19
+ /** A credential for this programme and country is held, but its issuer has revoked it. */
20
+ "CREDENTIAL_REVOKED",
21
+ "PROOF_FAILED",
22
+ "ISSUER_UNREACHABLE",
23
+ "VERIFIER_UNREACHABLE",
24
+ "INTERNAL"
25
+ ];
26
+
27
+ /**
28
+ * A response must stay on-device, in a custom app scheme: `http(s)` would let it leave the device
29
+ * entirely, and Android additionally resolves `intent`/`file`/`content`/`javascript`/`data` to
30
+ * behavior beyond opening another app (launching an arbitrary component, reading a local file, or
31
+ * running script), none of which this protocol's `callback` is meant to reach.
32
+ *
33
+ * The browser schemes map straight onto `http(s)://host/path` - `googlechrome://evil/cb` fetches
34
+ * `http://evil/cb` - so they defeat the rule exactly as `https` would. This list cannot be
35
+ * exhaustive: any installed app may register a scheme that proxies to the network, and nothing in
36
+ * the string says so. Closing it properly needs the signed response envelope and a consent screen
37
+ * showing the resolved callback, both already tracked as follow-ups.
38
+ */
39
+ const DENIED_CALLBACK_SCHEMES = [
40
+ "http",
41
+ "https",
42
+ "intent",
43
+ "file",
44
+ "content",
45
+ "javascript",
46
+ "data",
47
+ "googlechrome",
48
+ "googlechromes",
49
+ "x-safari-http",
50
+ "x-safari-https",
51
+ "microsoft-edge-http",
52
+ "microsoft-edge-https",
53
+ "firefox",
54
+ "ftp",
55
+ "ws",
56
+ "wss"
57
+ ];
58
+ /**
59
+ * These fields name server endpoints the app itself fetches or posts to, arriving from an
60
+ * unauthenticated deep-link caller - an allowlist has no unknown-scheme bypass to worry about.
61
+ * TODO: production should require https only; that belongs in configuration, not this schema.
62
+ */
63
+ const ALLOWED_ENDPOINT_URL_SCHEMES = ["http", "https"];
64
+ /** `encodeResponse` always appends its own `?payload=` to `callback`, so a `?`/`#` already in it would bury or hide that parameter and leave the client waiting until timeout instead of ever decoding a response. */
65
+ const callbackSchema = z
66
+ .string()
67
+ .regex(/^[a-z][a-z0-9+.-]*:\/\/[^\s?#]+$/i, "must be an absolute URL with no query string or fragment")
68
+ .refine((value) => !DENIED_CALLBACK_SCHEMES.includes(value.slice(0, value.indexOf(":")).toLowerCase()), {
69
+ message: `scheme must not be one of: ${DENIED_CALLBACK_SCHEMES.join(", ")}`
70
+ });
71
+ /** No `z.string().url()`: it relies on the global `URL` class, which Hermes/React Native lack without a polyfill. */
72
+ const urlLikeSchema = z
73
+ .string()
74
+ .regex(/^[a-z][a-z0-9+.-]*:\/\/\S+$/i, "must be an absolute URL")
75
+ .refine((value) => ALLOWED_ENDPOINT_URL_SCHEMES.includes(value.slice(0, value.indexOf(":")).toLowerCase()), {
76
+ message: `scheme must be one of: ${ALLOWED_ENDPOINT_URL_SCHEMES.join(", ")}`
77
+ });
78
+ const hexOfLength = (byteLength) => z
79
+ .string()
80
+ .regex(new RegExp(`^0x[0-9a-fA-F]{${byteLength * 2}}$`), `must be ${byteLength}-byte 0x-prefixed hex`)
81
+ .transform((value) => value.toLowerCase());
82
+ const hex20Schema = hexOfLength(20);
83
+ const hex31Schema = hexOfLength(31);
84
+ const hex32Schema = hexOfLength(32);
85
+ const hex65Schema = hexOfLength(65);
86
+ const hex66Schema = hexOfLength(66);
87
+ const securityLevelSchema = z.enum(["STRONGBOX", "TRUSTED_ENVIRONMENT", "SOFTWARE", "UNKNOWN"]);
88
+ /**
89
+ * `name` and `programme` are both rendered verbatim, on their own line and interpolated into a
90
+ * caption sentence the wallet wrote, so an unbounded or unclean value is a caller reshaping
91
+ * content it doesn't author: past the limit its text crowds the wallet's own closing sentence out
92
+ * of the three-line clip, and a control, formatting, or line/paragraph-separator character can
93
+ * hide text or relayout the line, as a bidi override does.
94
+ */
95
+ const sentenceSafeTextSchema = (maxLength) => z
96
+ .string()
97
+ .min(1)
98
+ .max(maxLength)
99
+ .regex(/^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]*$/u, "must not contain control, formatting or line-break characters");
100
+ const callerSchema = z
101
+ .object({
102
+ name: sentenceSafeTextSchema(64),
103
+ scheme: z.string().min(1)
104
+ })
105
+ .strict();
106
+ const approvalScopeSchema = z
107
+ .object({ programme: sentenceSafeTextSchema(64), country: z.string().regex(/^[A-Z]{3}$/, "ISO 3166-1 alpha-3") })
108
+ .strict();
109
+ const registrationSignParamsSchema = z
110
+ .object({ chainId: z.number().int().positive(), verifyingContract: hex20Schema })
111
+ .strict();
112
+ const signedRegistrationRequestSchema = z
113
+ .object({
114
+ version: z.literal("v1"),
115
+ networkId: hex32Schema,
116
+ schemeId: z.literal(1),
117
+ metaStealthAddress: hex66Schema,
118
+ chainId: z.number().int().positive(),
119
+ verifyingContract: hex20Schema,
120
+ signature: hex65Schema
121
+ })
122
+ .strict();
123
+ const registrationSignResultSchema = z
124
+ .object({ request: signedRegistrationRequestSchema, securityLevel: securityLevelSchema })
125
+ .strict();
126
+ /** Scheme, host and port, as `urlLikeSchema` guarantees them: everything before the path. No
127
+ * `new URL` here either, for the same Hermes reason. */
128
+ function originOf(url) {
129
+ const pathStart = url.indexOf("/", url.indexOf("://") + 3);
130
+ return (pathStart === -1 ? url : url.slice(0, pathStart)).toLowerCase();
131
+ }
132
+ /** The four URLs are one server's, so they must say so. The wallet stores `revocationsUrl` for the
133
+ * credential's life and shows it once, at issuance, as the issuer's server: split origins would
134
+ * let a caller drive a genuine issuance while pointing every later revocation read at itself. */
135
+ const sameOriginEndpoints = ["credentialsUrl", "issuersUrl", "revocationsUrl"];
136
+ const credentialIssueParamsSchema = z
137
+ .object({
138
+ issuerNetworkId: hex32Schema,
139
+ approvalScope: approvalScopeSchema,
140
+ challengeUrl: urlLikeSchema,
141
+ credentialsUrl: urlLikeSchema,
142
+ issuersUrl: urlLikeSchema,
143
+ revocationsUrl: urlLikeSchema
144
+ })
145
+ .strict()
146
+ .superRefine((params, ctx) => {
147
+ const origin = originOf(params.challengeUrl);
148
+ for (const field of sameOriginEndpoints) {
149
+ if (originOf(params[field]) !== origin) {
150
+ ctx.addIssue({
151
+ code: "custom",
152
+ path: [field],
153
+ message: `${field} must be on the same origin as challengeUrl (${origin})`
154
+ });
155
+ }
156
+ }
157
+ });
158
+ const credentialIssueResultSchema = z.discriminatedUnion("status", [
159
+ z.object({ status: z.literal("stored"), credentialId: z.string().min(1) }).strict(),
160
+ z.object({ status: z.literal("alreadyHeld"), credentialId: z.string().min(1) }).strict(),
161
+ z.object({ status: z.literal("refused"), code: z.string().min(1), message: z.string() }).strict()
162
+ ]);
163
+ const presentationCreateParamsSchema = z
164
+ .object({
165
+ requestId: z.string().min(1).max(128),
166
+ challenge: hex31Schema,
167
+ audience: z.string().min(1),
168
+ currentTime: z.number().int().nonnegative(),
169
+ programme: sentenceSafeTextSchema(64),
170
+ country: z.string().regex(/^[A-Z]{3}$/, "ISO 3166-1 alpha-3"),
171
+ issuersUrl: urlLikeSchema,
172
+ revocationsUrl: urlLikeSchema,
173
+ responseUrl: urlLikeSchema
174
+ })
175
+ .strict()
176
+ .superRefine((params, ctx) => {
177
+ // The verifier serves all three, and it is the party that must accept the roots the proof
178
+ // carries. A split origin would let it send the wallet to read somebody else's tree.
179
+ const origin = originOf(params.responseUrl);
180
+ for (const field of ["issuersUrl", "revocationsUrl"]) {
181
+ if (originOf(params[field]) !== origin) {
182
+ ctx.addIssue({
183
+ code: "custom",
184
+ path: [field],
185
+ message: `${field} must be on the same origin as responseUrl (${origin})`
186
+ });
187
+ }
188
+ }
189
+ });
190
+ const presentationCreateResultSchema = z.object({ verificationId: z.string().min(1) }).strict();
191
+ /** Looked up by the decoded `op` string, so the same schema can be resolved dynamically at runtime. */
192
+ const paramsSchemas = {
193
+ "registration.sign": registrationSignParamsSchema,
194
+ "credential.issue": credentialIssueParamsSchema,
195
+ "presentation.create": presentationCreateParamsSchema
196
+ };
197
+ const resultSchemas = {
198
+ "registration.sign": registrationSignResultSchema,
199
+ "credential.issue": credentialIssueResultSchema,
200
+ "presentation.create": presentationCreateResultSchema
201
+ };
202
+ /** Envelope only: `op`/`params` stay unvalidated because a request that fails those checks still needs a usable `id`/`callback` to answer with an `ok: false` response. */
203
+ const linkRequestEnvelopeSchema = z
204
+ .object({
205
+ id: z.string().min(1),
206
+ version: z.literal("v1"),
207
+ caller: callerSchema,
208
+ callback: callbackSchema,
209
+ op: z.string().min(1),
210
+ params: z.unknown()
211
+ })
212
+ .strict();
213
+ const requestEnvelopeFields = {
214
+ id: z.string().min(1),
215
+ version: z.literal("v1"),
216
+ caller: callerSchema,
217
+ callback: callbackSchema
218
+ };
219
+ /** Written out per op rather than mapped from `LINK_OPS`: `z.discriminatedUnion` needs each branch's literal `op` at the type level, which a `z.ZodTypeAny`-typed loop erases. */
220
+ const linkRequestSchema = z.discriminatedUnion("op", [
221
+ z
222
+ .object({ ...requestEnvelopeFields, op: z.literal("registration.sign"), params: registrationSignParamsSchema })
223
+ .strict(),
224
+ z
225
+ .object({ ...requestEnvelopeFields, op: z.literal("credential.issue"), params: credentialIssueParamsSchema })
226
+ .strict(),
227
+ z
228
+ .object({
229
+ ...requestEnvelopeFields,
230
+ op: z.literal("presentation.create"),
231
+ params: presentationCreateParamsSchema
232
+ })
233
+ .strict()
234
+ ]);
235
+ const linkErrorCodeSchema = z.enum(LINK_ERROR_CODES);
236
+ const linkResponseSchema = z.discriminatedUnion("ok", [
237
+ z.object({ id: z.string().min(1), version: z.literal("v1"), ok: z.literal(true), result: z.unknown() }).strict(),
238
+ z
239
+ .object({
240
+ id: z.string().min(1),
241
+ version: z.literal("v1"),
242
+ ok: z.literal(false),
243
+ code: linkErrorCodeSchema,
244
+ message: z.string().optional()
245
+ })
246
+ .strict()
247
+ ]);
248
+
249
+ /** A link-protocol failure, discriminated by the same `code` a wire `{ ok: false }` response carries. */
250
+ class LinkError extends Error {
251
+ constructor(code, message) {
252
+ super(message ?? code);
253
+ this.name = "LinkError";
254
+ this.code = code;
255
+ }
256
+ }
257
+ /** Thrown by encodeRequest/encodeResponse when the built URL exceeds MAX_URL_LENGTH. */
258
+ class LinkPayloadTooLargeError extends LinkError {
259
+ constructor(message) {
260
+ super("INVALID_REQUEST", message);
261
+ this.name = "LinkPayloadTooLargeError";
262
+ }
263
+ }
264
+ /**
265
+ * Thrown by decodeRequest/decodeResponse when a URL can't be decoded into a well-formed v1
266
+ * envelope - the only failure this package ever throws rather than turning into an `ok: false`
267
+ * response, because without a valid `id`/`callback` there is nowhere to send that response.
268
+ */
269
+ class LinkDecodeError extends LinkError {
270
+ constructor(message) {
271
+ super("INVALID_REQUEST", message);
272
+ this.name = "LinkDecodeError";
273
+ }
274
+ }
275
+
276
+ const USER_APP_SCHEME = "userapp";
277
+ /** Above this, a platform's `Linking.openURL` (and some OSes) start truncating or refusing the URL. */
278
+ const MAX_URL_LENGTH = 2000;
279
+ /** Encodes bytes with the base64url alphabet (RFC 4648 §5), unpadded. No Node `Buffer`: `atob`/`btoa` only. */
280
+ function base64UrlEncode(bytes) {
281
+ let binary = "";
282
+ for (const byte of bytes) {
283
+ binary += String.fromCharCode(byte);
284
+ }
285
+ return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
286
+ }
287
+ function base64UrlDecode(value) {
288
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
289
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
290
+ const binary = globalThis.atob(padded);
291
+ const bytes = new Uint8Array(binary.length);
292
+ for (let i = 0; i < binary.length; i += 1) {
293
+ bytes[i] = binary.charCodeAt(i);
294
+ }
295
+ return bytes;
296
+ }
297
+ function encodePayload(value) {
298
+ return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
299
+ }
300
+ function decodePayload(payload) {
301
+ try {
302
+ return JSON.parse(new TextDecoder().decode(base64UrlDecode(payload)));
303
+ }
304
+ catch {
305
+ throw new LinkDecodeError("payload is not valid base64url-encoded JSON");
306
+ }
307
+ }
308
+ function checkUrlLength(url) {
309
+ if (url.length > MAX_URL_LENGTH) {
310
+ throw new LinkPayloadTooLargeError(`link URL is ${url.length} characters, over the ${MAX_URL_LENGTH} limit`);
311
+ }
312
+ }
313
+ /**
314
+ * Reads a query parameter by hand rather than via the `URL`/`URLSearchParams` globals, which
315
+ * Hermes (React Native's JS engine) doesn't provide without a polyfill.
316
+ */
317
+ function getQueryParam(url, key) {
318
+ const queryIndex = url.indexOf("?");
319
+ if (queryIndex === -1) {
320
+ return null;
321
+ }
322
+ for (const pair of url.slice(queryIndex + 1).split("&")) {
323
+ const [rawKey, rawValue = ""] = pair.split("=");
324
+ if (decodeURIComponent(rawKey) === key) {
325
+ return decodeURIComponent(rawValue);
326
+ }
327
+ }
328
+ return null;
329
+ }
330
+ function encodeRequest(request, options = {}) {
331
+ const scheme = options.scheme ?? USER_APP_SCHEME;
332
+ // The request URL is built as `${scheme}://...`, so a `?`/`#` in scheme would corrupt it the
333
+ // same way a bad callback corrupts the response URL below.
334
+ if (scheme.includes("?") || scheme.includes("#")) {
335
+ throw new LinkError("INVALID_REQUEST", "scheme must not contain a query string or fragment");
336
+ }
337
+ if (DENIED_CALLBACK_SCHEMES.includes(scheme.toLowerCase())) {
338
+ throw new LinkError("INVALID_REQUEST", `scheme must not be one of: ${DENIED_CALLBACK_SCHEMES.join(", ")}`);
339
+ }
340
+ const parsedCaller = callerSchema.safeParse(request.caller);
341
+ if (!parsedCaller.success) {
342
+ // Otherwise a caller decodeRequest's own callerSchema would refuse (too long a name, say)
343
+ // gets a URL the user app can't decode, and the member app just waits out its own timeout.
344
+ throw new LinkError("INVALID_REQUEST", `caller ${parsedCaller.error.issues[0].message}`);
345
+ }
346
+ const parsedCallback = callbackSchema.safeParse(request.callback);
347
+ if (!parsedCallback.success) {
348
+ // Surfaces callbackSchema's own issue message so a denied scheme and a query
349
+ // string/fragment report distinct reasons instead of one hardcoded message for both.
350
+ throw new LinkError("INVALID_REQUEST", `callback ${parsedCallback.error.issues[0].message}`);
351
+ }
352
+ const url = `${scheme}://v1/request?payload=${encodePayload(request)}`;
353
+ checkUrlLength(url);
354
+ return url;
355
+ }
356
+ function decodeRequest(url) {
357
+ const payload = getQueryParam(url, "payload");
358
+ if (payload === null) {
359
+ throw new LinkDecodeError("link URL is missing the payload query parameter");
360
+ }
361
+ const parsed = linkRequestEnvelopeSchema.safeParse(decodePayload(payload));
362
+ if (!parsed.success) {
363
+ throw new LinkDecodeError(`malformed request payload: ${parsed.error.message}`);
364
+ }
365
+ return parsed.data;
366
+ }
367
+ function encodeResponse(callback, response) {
368
+ const url = `${callback}?payload=${encodePayload(response)}`;
369
+ checkUrlLength(url);
370
+ return url;
371
+ }
372
+ function decodeResponse(url) {
373
+ const payload = getQueryParam(url, "payload");
374
+ if (payload === null) {
375
+ throw new LinkDecodeError("link URL is missing the payload query parameter");
376
+ }
377
+ const parsed = linkResponseSchema.safeParse(decodePayload(payload));
378
+ if (!parsed.success) {
379
+ throw new LinkDecodeError(`malformed response payload: ${parsed.error.message}`);
380
+ }
381
+ // The envelope carries no `op`, so the schema can only prove `result` is present, not which
382
+ // op's shape it has - the caller (client.ts) knows that from the id it matches the response to.
383
+ return parsed.data;
384
+ }
385
+
386
+ const DEFAULT_TIMEOUT_MS = 120000;
387
+ function defaultRandomId() {
388
+ const cryptoObj = globalThis.crypto;
389
+ if (typeof cryptoObj?.randomUUID === "function") {
390
+ return cryptoObj.randomUUID();
391
+ }
392
+ const bytes = new Uint8Array(16);
393
+ cryptoObj.getRandomValues(bytes);
394
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
395
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
396
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
397
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
398
+ }
399
+ /** One member-app-side client per callback URL: exactly one request in flight, matched by id. */
400
+ function createLinkClient(options) {
401
+ const randomId = options.randomId ?? defaultRandomId;
402
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
403
+ let pending = null;
404
+ // Handles a stray/duplicate/unrelated URL (pending already cleared, or some other deep link)
405
+ // by returning early; every other exit clears `pending` itself rather than sharing a "settle"
406
+ // helper, since by the time those exits run they already hold their own non-null references.
407
+ function handleUrl(url) {
408
+ if (!pending) {
409
+ return;
410
+ }
411
+ let response;
412
+ try {
413
+ response = decodeResponse(url);
414
+ }
415
+ catch {
416
+ return; // Not a response this client understands - some other deep link, ignore it.
417
+ }
418
+ if (response.id !== pending.id) {
419
+ return; // A response to a different (already-settled or stale) request.
420
+ }
421
+ const { op, resolve, reject, timer, unsubscribe } = pending;
422
+ pending = null;
423
+ clearTimeout(timer);
424
+ unsubscribe();
425
+ if (response.ok) {
426
+ // Mirrors handler.ts's own check on the responder side: a result that decoded fine but
427
+ // has the wrong shape for its op is a responder bug, not a transport glitch.
428
+ const parsedResult = resultSchemas[op].safeParse(response.result);
429
+ if (parsedResult.success) {
430
+ resolve(parsedResult.data);
431
+ }
432
+ else {
433
+ reject(new LinkError("INTERNAL", `response result failed its schema: ${parsedResult.error.message}`));
434
+ }
435
+ }
436
+ else {
437
+ reject(new LinkError(response.code, response.message));
438
+ }
439
+ }
440
+ return {
441
+ request(op, params) {
442
+ if (pending) {
443
+ return Promise.reject(new Error("createLinkClient: a request is already in flight"));
444
+ }
445
+ return new Promise((resolve, reject) => {
446
+ // randomId/encodeRequest can throw (a caller-supplied randomId, a callback failing
447
+ // its own schema, an oversized payload); thrown here, before resolve/reject has
448
+ // been called, it rejects this promise automatically instead of escaping request()
449
+ // as a synchronous throw.
450
+ const id = randomId();
451
+ // op and params are correlated by this call's own Op, but a deferred conditional
452
+ // type like LinkRequest<Op> doesn't resolve for an unsubstituted generic - assert
453
+ // it here.
454
+ const request = {
455
+ id,
456
+ version: "v1",
457
+ caller: options.caller,
458
+ callback: options.callback,
459
+ op,
460
+ params
461
+ };
462
+ const url = encodeRequest(request, { scheme: options.scheme });
463
+ const unsubscribe = options.onUrl(handleUrl);
464
+ const timer = setTimeout(() => {
465
+ pending = null;
466
+ unsubscribe();
467
+ reject(new Error(`createLinkClient: request timed out after ${timeoutMs}ms`));
468
+ }, timeoutMs);
469
+ // resolve only ever receives this call's own response, matched by id in handleUrl above;
470
+ // the cast to `(result: unknown) => void` just erases that per-call Op for storage below.
471
+ pending = { id, op, resolve: resolve, reject, unsubscribe, timer };
472
+ options.openUrl(url).catch((error) => {
473
+ pending = null;
474
+ clearTimeout(timer);
475
+ unsubscribe();
476
+ reject(error);
477
+ });
478
+ });
479
+ }
480
+ };
481
+ }
482
+
483
+ function isLinkOp(op) {
484
+ return LINK_OPS.includes(op);
485
+ }
486
+ /** A zod error can name every unrecognized key in a params object, easily blowing the response URL's budget. */
487
+ const MAX_RESPONSE_MESSAGE_LENGTH = 256;
488
+ function createRequestHandler(options) {
489
+ const { handlers, consent } = options;
490
+ function respond(request, code, message) {
491
+ const cappedMessage = message?.slice(0, MAX_RESPONSE_MESSAGE_LENGTH);
492
+ return encodeResponse(request.callback, {
493
+ id: request.id,
494
+ version: "v1",
495
+ ok: false,
496
+ code,
497
+ message: cappedMessage
498
+ });
499
+ }
500
+ return {
501
+ async handle(url) {
502
+ const request = decodeRequest(url);
503
+ if (!isLinkOp(request.op)) {
504
+ return respond(request, "UNSUPPORTED_OP");
505
+ }
506
+ const handler = handlers[request.op];
507
+ if (!handler) {
508
+ return respond(request, "UNSUPPORTED_OP");
509
+ }
510
+ const parsedParams = paramsSchemas[request.op].safeParse(request.params);
511
+ if (!parsedParams.success) {
512
+ return respond(request, "INVALID_REQUEST", parsedParams.error.message);
513
+ }
514
+ if (!(await consent(request))) {
515
+ return respond(request, "USER_DENIED");
516
+ }
517
+ try {
518
+ const result = await handler(parsedParams.data, { caller: request.caller });
519
+ // A handler's return type isn't checked against Result<Op> at compile time
520
+ // (AnyLinkHandler erased that), so a wrong shape here is a bug in this app's own
521
+ // handler, not the caller's fault - report it as INTERNAL, not INVALID_REQUEST.
522
+ const parsedResult = resultSchemas[request.op].safeParse(result);
523
+ if (!parsedResult.success) {
524
+ return respond(request, "INTERNAL");
525
+ }
526
+ const response = { id: request.id, version: "v1", ok: true, result: parsedResult.data };
527
+ return encodeResponse(request.callback, response);
528
+ }
529
+ catch (error) {
530
+ if (error instanceof LinkError) {
531
+ return respond(request, error.code, error.message);
532
+ }
533
+ return respond(request, "INTERNAL");
534
+ }
535
+ }
536
+ };
537
+ }
538
+
539
+ export { ALLOWED_ENDPOINT_URL_SCHEMES, DENIED_CALLBACK_SCHEMES, LINK_ERROR_CODES, LINK_OPS, LinkDecodeError, LinkError, LinkPayloadTooLargeError, MAX_URL_LENGTH, SECURITY_LEVELS, USER_APP_SCHEME, approvalScopeSchema, base64UrlDecode, base64UrlEncode, callbackSchema, callerSchema, createLinkClient, createRequestHandler, credentialIssueParamsSchema, credentialIssueResultSchema, decodeRequest, decodeResponse, encodeRequest, encodeResponse, hex20Schema, hex31Schema, hex32Schema, hex65Schema, hex66Schema, linkErrorCodeSchema, linkRequestEnvelopeSchema, linkRequestSchema, linkResponseSchema, paramsSchemas, presentationCreateParamsSchema, presentationCreateResultSchema, registrationSignParamsSchema, registrationSignResultSchema, resultSchemas, securityLevelSchema, signedRegistrationRequestSchema };