@coordination-network/user-app-link 0.0.1 → 0.1.0

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 CHANGED
@@ -26,7 +26,9 @@ yarn add @coordination-network/user-app-link
26
26
  { id, version: "v1", ok: true, result } | { id, version: "v1", ok: false, code, message? }
27
27
  ```
28
28
 
29
- `code` is one of `USER_DENIED`, `CONSENT_DENIED`, `UNSUPPORTED_OP`, `INVALID_REQUEST`, `NOT_ONBOARDED`, `NO_CREDENTIAL`, `CREDENTIAL_REVOKED`, `PROOF_FAILED`, `ISSUER_UNREACHABLE`, `VERIFIER_UNREACHABLE`, `INTERNAL`.
29
+ `code` is one of `USER_DENIED`, `CONSENT_DENIED`, `UNSUPPORTED_OP`, `INVALID_REQUEST`, `NOT_ONBOARDED`, `NO_CREDENTIAL`, `CREDENTIAL_REVOKED`, `PROOF_FAILED`, `ISSUER_UNREACHABLE`, `VERIFIER_UNREACHABLE`, `ISSUER_NOT_APPROVED`, `CHAIN_UNREACHABLE`, `INTERNAL`.
30
+
31
+ `message` is one fixed sentence per code (`LINK_ERROR_MESSAGES`), so a response never carries detail about the holder. The one exception is `INVALID_REQUEST` for malformed params, which describes the caller's own request.
30
32
 
31
33
  **Size.** `encodeRequest`/`encodeResponse` throw `LinkPayloadTooLargeError` if the built URL exceeds `MAX_URL_LENGTH` (2000 characters) — the point past which platform `Linking.openURL` calls and some OSes start truncating or refusing a URL. Every result below fits comfortably; the two payloads that wouldn't (a credential and a ZK proof) never travel over a link at all — the user app fetches and posts both itself.
32
34
 
@@ -44,9 +46,9 @@ Three ops, one per flow, each behind exactly one consent.
44
46
 
45
47
  `securityLevel` is one of `STRONGBOX`, `TRUSTED_ENVIRONMENT`, `SOFTWARE`, `UNKNOWN`. `registration.sign`'s `request` is posted to the member server verbatim; the security level rides along because registration is the first thing every member app does and nothing else in the request says how the phone guards its keys.
46
48
 
47
- `credential.issue` runs the whole issuance inside the user app after one approval: it checks what it already holds, gets a nonce from `challengeUrl`, builds the proof-of-possession material, posts it to `credentialsUrl`, checks the credential it gets back against `issuersUrl` and `revocationsUrl`, and stores it. The revocation secret never leaves the user app. `refused` is the issuer server's own decision, in its own error vocabulary; a server that cannot be reached at all is an `ISSUER_UNREACHABLE` error, not a refusal.
49
+ `credential.issue` runs the whole issuance inside the user app after one approval: it checks what it already holds, gets a nonce from `challengeUrl`, builds the proof-of-possession material, posts it to `credentialsUrl`, checks the credential it gets back against `issuersUrl` and `revocationsUrl` (lists that must hash to the chain's current roots, else `ISSUER_UNREACHABLE`), checks that its issuer key is one the chain lists for `issuerNetworkId` (`ISSUER_NOT_APPROVED`, or `CHAIN_UNREACHABLE` when the chain cannot be read), and stores it. The revocation secret never leaves the user app. `refused` is the issuer server's own decision, in its own error vocabulary; a server that cannot be reached at all is an `ISSUER_UNREACHABLE` error, not a refusal.
48
50
 
49
- `presentation.create` does not return a proof. `requestId` identifies a presentation request the member server already issued via `POST /v1/presentation-requests` (which returns `{ requestId, challenge, audience, currentTime, programme, country }`); the rest of the params are that same response, since the user app still needs them to build the proof inputs. The user app picks a matching credential, checks it against **its own issuer's** revocation snapshot (read from the user app's index, never a URL the verifier chose), proves it, asks the person once more before sharing, and itself `POST`s `{ requestId, proof, publicInputs }` to `responseUrl` (the member server's `/v1/verify`). The result is only the `verificationId`: the member app reads the outcome from its own server at `GET /v1/verifications/:id` rather than trusting a relayed answer. A proof is large — far past `MAX_URL_LENGTH` — so it never travels as a query parameter.
51
+ `presentation.create` does not return a proof. `requestId` identifies a presentation request the member server already issued via `POST /v1/presentation-requests` (which returns `{ requestId, challenge, audience, currentTime, programme, country }`); the rest of the params are that same response, since the user app still needs them to build the proof inputs. The user app picks a matching credential, checks it against the chain's current roots (the verifier's served lists must hash to them, or the request fails with `VERIFIER_UNREACHABLE`), proves it, asks the person once more before sharing, and itself `POST`s `{ requestId, proof, publicInputs }` to `responseUrl` (the member server's `/v1/verify`). The result is only the `verificationId`: the member app reads the outcome from its own server at `GET /v1/verifications/:id` rather than trusting a relayed answer. A proof is large — far past `MAX_URL_LENGTH` — so it never travels as a query parameter.
50
52
 
51
53
  ## Using `createLinkClient` (member app side)
52
54
 
@@ -70,9 +72,68 @@ console.log(signed.request.networkId)
70
72
 
71
73
  `request` encodes the op, opens the request URL in the user app, and resolves with the typed result (or rejects with a `LinkError` carrying the response's `code`) once the matching callback URL arrives. One request may be in flight at a time per client; a second call while one is pending rejects immediately.
72
74
 
75
+ ### WS1 mobile presentation transport (#112)
76
+
77
+ The holder's wallet and verifier's member app can run on **separate Android phones**. The verifier
78
+ shows a public challenge QR; the holder scans it, reviews the request and consents to proving. The
79
+ wallet posts the proof directly to the verifier server (HTTPS for hosted services). The verifier
80
+ polls its server with its own member token; no app callback needs to cross devices. Both phones
81
+ need connectivity, and the wallet also needs its configured chain RPC. This is an online reference
82
+ demo, not a claim of offline suitability for every field deployment. The same-phone deep-link
83
+ flow remains available. Both configurations must name a verifier URL reachable from each phone;
84
+ localhost URLs require USB reverse forwarding on each device.
85
+
86
+ 1. In the member app, choose **Verify → Check another phone → Create challenge QR**. The server's
87
+ `POST /v1/presentation-requests` supplies the single-use challenge, audience, scope, time and
88
+ expiry. The screen renders `cn-present:v1:` with a compact encoding of these fields and one
89
+ server base URL. It carries no token, credential, proof or callback. URLs are reconstructed
90
+ from that base; encoded QR data is capped at 2,000 characters.
91
+ 2. In the wallet, choose **Scan proof → Scan challenge QR**. Before network access or consent,
92
+ the wallet checks the schema and time bounds and pins the audience and every endpoint to a
93
+ member in its previously configured stack. Scanning a QR never adds a new trusted verifier.
94
+ The person reviews the configured verifier and programme/country, then approves sharing.
95
+ 3. The wallet reads the verifier's lists and checks them against roots from its own chain RPC,
96
+ builds the proof, and posts `{ requestId, proof, publicInputs }` to `/v1/verify`. A proof
97
+ discloses time, challenge, audience, programme hash, country code and registry roots. The raw
98
+ credential, issuer identity and private keys are not sent. The wallet reports **submitted**,
99
+ not **verified**: only the verifier server's result establishes acceptance.
100
+ 4. The verifier uses authenticated `GET /v1/presentation-requests/:id` to obtain `pending`,
101
+ `expired`, `cancelled`, or `completed` with its stored verification record. This is how a lost
102
+ wallet HTTP response can be recovered without replaying the proof. Only the original attempt
103
+ can set this result; replay failures cannot replace a successful verdict. Polling stops on a
104
+ terminal result, connection error, cancellation or screen exit. **Retry status** queries the
105
+ same request after a connection error. **New check** creates a fresh challenge.
106
+ 5. **Cancel check** or leaving the verifier screen stops waiting and calls authenticated
107
+ `POST /v1/presentation-requests/:id/cancel`. Cancellation can invalidate an unsubmitted
108
+ request; once proof submission has claimed it, the server returns 409 and may still record
109
+ its result. The UI does not claim that an in-flight proof was cancelled.
110
+
111
+ **Payload and limits.** The proof travels as hex in JSON, not in the QR. The real-proof HTTP E2E
112
+ run measured 14,656 proof bytes, 29,886 JSON bytes and seven public inputs, within the server's
113
+ 100 KiB parser limit. Circuit/backend changes must repeat that measurement. The member UI tests
114
+ rasterize the actual native QR Views and decode them with an independent QR decoder, checking
115
+ that the challenge fits and contains no token. The same-phone link tests also check its separate
116
+ 2,000-character limit.
117
+
118
+ **Offline behaviour.** There is no cached-root/offline acceptance. A list/verifier outage produces
119
+ `VERIFIER_UNREACHABLE`; an RPC outage produces `CHAIN_UNREACHABLE` and no proof is submitted.
120
+ A lost proof-POST response is not acceptance or rejection: the verifier checks its own request
121
+ status. A consented no-credential/revoked-credential refusal sends no proof and closes the request
122
+ with that refusal. Denied consent sends nothing; the verifier can cancel or let the request expire. By default requests expire after 300 seconds. After expiry or cancellation, start a new
123
+ check rather than replaying the old proof. Hosted endpoints must use HTTPS; configured LAN HTTP
124
+ is retained for the local demo, where QR contents and network traffic are not confidential.
125
+
126
+ **Two-phone acceptance for #67.** Configure both phones against the same current Sepolia stack.
127
+ Issue a credential to the holder, display a challenge on the verifier, scan and consent on the
128
+ holder, and confirm that only the verifier shows the matching server verdict. Repeat with denied
129
+ consent, a revoked credential, a stale/unknown-member QR, duplicate scan frames, cancellation,
130
+ verifier/RPC outage, and loss of the wallet response after server acceptance. Never project member
131
+ pairing tokens or record credentials/proofs. Automated UI and local HTTP/Anvil tests cover these
132
+ boundaries, but do not replace physical-camera, secure-hardware and live-Sepolia acceptance.
133
+
73
134
  ### Browser transport
74
135
 
75
- Member apps choose a transport only when constructing the client; all six operations and the rest of
136
+ Member apps choose a transport only when constructing the client; all three operations and the rest of
76
137
  their code stay the same when moving between web and mobile:
77
138
 
78
139
  ```typescript
@@ -135,6 +196,14 @@ await Linking.openURL(responseUrl)
135
196
  - **No cross-device support.** Both apps are assumed to be installed on the same phone, communicating through the OS's own URL-scheme routing. There is no pairing, QR handoff, or relay for a user app and member app on different devices.
136
197
  - **No responder authentication.** See "Security notes" above: this protocol alone doesn't prove `callback` belongs to the app the user thinks it does.
137
198
 
199
+ ### Local demo relay
200
+
201
+ `createRelayWebLinkTransport` requires a private `token` for the running demo session. It sends it
202
+ in the Authorization header for requests, response polls and delivery acknowledgements. The relay
203
+ URL and the public stack descriptor must never carry that token. `browserSessionKey` reads an
204
+ operator pairing fragment or prompt, removes the fragment key and stores it only for that tab and
205
+ service. See the [relay setup](../../apps/integration-testbed/README.md#browser-link-relay-e1--l7).
206
+
138
207
  ## License
139
208
 
140
209
  MIT, see [`LICENSE`](./LICENSE).
package/dist/index.cjs CHANGED
@@ -23,6 +23,10 @@ const LINK_ERROR_CODES = [
23
23
  "PROOF_FAILED",
24
24
  "ISSUER_UNREACHABLE",
25
25
  "VERIFIER_UNREACHABLE",
26
+ /** The credential's issuer key is not among those the chain lists for the member the caller named. */
27
+ "ISSUER_NOT_APPROVED",
28
+ /** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
29
+ "CHAIN_UNREACHABLE",
26
30
  "INTERNAL"
27
31
  ];
28
32
 
@@ -482,6 +486,23 @@ function createLinkClient(options) {
482
486
  };
483
487
  }
484
488
 
489
+ /** The only error text that leaves the wallet, bar the zod text for malformed params; other detail stays on the device. */
490
+ const LINK_ERROR_MESSAGES = {
491
+ USER_DENIED: "The person declined the request.",
492
+ CONSENT_DENIED: "The person declined to share.",
493
+ UNSUPPORTED_OP: "This wallet does not support that operation.",
494
+ INVALID_REQUEST: "The request was not valid.",
495
+ NOT_ONBOARDED: "The wallet has no identity yet.",
496
+ NO_CREDENTIAL: "No matching credential.",
497
+ CREDENTIAL_REVOKED: "The matching credential has been revoked.",
498
+ PROOF_FAILED: "The wallet could not build the proof.",
499
+ ISSUER_UNREACHABLE: "The wallet could not use the issuer's server.",
500
+ VERIFIER_UNREACHABLE: "The wallet could not use the verifier's server.",
501
+ ISSUER_NOT_APPROVED: "The credential's issuer key is not approved on chain.",
502
+ CHAIN_UNREACHABLE: "The wallet could not read the chain.",
503
+ INTERNAL: "The wallet failed."
504
+ };
505
+
485
506
  function isLinkOp(op) {
486
507
  return LINK_OPS.includes(op);
487
508
  }
@@ -489,8 +510,8 @@ function isLinkOp(op) {
489
510
  const MAX_RESPONSE_MESSAGE_LENGTH = 256;
490
511
  function createRequestHandler(options) {
491
512
  const { handlers, consent } = options;
492
- function respond(request, code, message) {
493
- const cappedMessage = message?.slice(0, MAX_RESPONSE_MESSAGE_LENGTH);
513
+ function respond(request, code, message = LINK_ERROR_MESSAGES[code]) {
514
+ const cappedMessage = message.slice(0, MAX_RESPONSE_MESSAGE_LENGTH);
494
515
  return encodeResponse(request.callback, {
495
516
  id: request.id,
496
517
  version: "v1",
@@ -530,7 +551,7 @@ function createRequestHandler(options) {
530
551
  }
531
552
  catch (error) {
532
553
  if (error instanceof LinkError) {
533
- return respond(request, error.code, error.message);
554
+ return respond(request, error.code);
534
555
  }
535
556
  return respond(request, "INTERNAL");
536
557
  }
@@ -538,9 +559,83 @@ function createRequestHandler(options) {
538
559
  };
539
560
  }
540
561
 
562
+ const PREFIX = "cn-present:v1:";
563
+ const publicBase = zod.z
564
+ .string()
565
+ .regex(/^https?:\/\/[^/?#@\s]+(?:\/[^?#@\s]*)?$/, "Server base must not contain credentials, query or fragment");
566
+ const schema = zod.z
567
+ .object({
568
+ params: presentationCreateParamsSchema,
569
+ expiresAt: zod.z.number().int().positive()
570
+ })
571
+ .strict();
572
+ /** Public challenge only. No credential, proof, callback or member bearer token crosses devices. */
573
+ function encodePresentationQr(value) {
574
+ const parsed = schema.parse(value);
575
+ const { params, expiresAt } = parsed;
576
+ const base = params.responseUrl.replace(/\/v1\/verify$/, "");
577
+ if (base === params.responseUrl ||
578
+ params.issuersUrl !== `${base}/v1/issuers` ||
579
+ params.revocationsUrl !== `${base}/v1/revocations/snapshot`)
580
+ throw new Error("Unsupported presentation endpoints");
581
+ publicBase.parse(base);
582
+ // Compact keys and one shared endpoint keep the QR readable at phone-screen sizes.
583
+ const wire = {
584
+ r: params.requestId,
585
+ c: params.challenge,
586
+ a: params.audience,
587
+ t: params.currentTime,
588
+ p: params.programme,
589
+ k: params.country,
590
+ u: base,
591
+ e: expiresAt
592
+ };
593
+ const encoded = PREFIX + base64UrlEncode(new TextEncoder().encode(JSON.stringify(wire)));
594
+ if (encoded.length > MAX_URL_LENGTH)
595
+ throw new Error("Presentation QR is too large");
596
+ return encoded;
597
+ }
598
+ function decodePresentationQr(raw) {
599
+ if (!raw.startsWith(PREFIX) || raw.length > MAX_URL_LENGTH)
600
+ throw new Error("Not a presentation QR");
601
+ try {
602
+ const wire = zod.z
603
+ .object({
604
+ r: zod.z.string(),
605
+ c: zod.z.string(),
606
+ a: zod.z.string(),
607
+ t: zod.z.number(),
608
+ p: zod.z.string(),
609
+ k: zod.z.string(),
610
+ u: publicBase,
611
+ e: zod.z.number()
612
+ })
613
+ .strict()
614
+ .parse(JSON.parse(new TextDecoder().decode(base64UrlDecode(raw.slice(PREFIX.length)))));
615
+ return schema.parse({
616
+ expiresAt: wire.e,
617
+ params: {
618
+ requestId: wire.r,
619
+ challenge: wire.c,
620
+ audience: wire.a,
621
+ currentTime: wire.t,
622
+ programme: wire.p,
623
+ country: wire.k,
624
+ issuersUrl: `${wire.u}/v1/issuers`,
625
+ revocationsUrl: `${wire.u}/v1/revocations/snapshot`,
626
+ responseUrl: `${wire.u}/v1/verify`
627
+ }
628
+ });
629
+ }
630
+ catch {
631
+ throw new Error("Invalid presentation QR");
632
+ }
633
+ }
634
+
541
635
  exports.ALLOWED_ENDPOINT_URL_SCHEMES = ALLOWED_ENDPOINT_URL_SCHEMES;
542
636
  exports.DENIED_CALLBACK_SCHEMES = DENIED_CALLBACK_SCHEMES;
543
637
  exports.LINK_ERROR_CODES = LINK_ERROR_CODES;
638
+ exports.LINK_ERROR_MESSAGES = LINK_ERROR_MESSAGES;
544
639
  exports.LINK_OPS = LINK_OPS;
545
640
  exports.LinkDecodeError = LinkDecodeError;
546
641
  exports.LinkError = LinkError;
@@ -557,8 +652,10 @@ exports.createLinkClient = createLinkClient;
557
652
  exports.createRequestHandler = createRequestHandler;
558
653
  exports.credentialIssueParamsSchema = credentialIssueParamsSchema;
559
654
  exports.credentialIssueResultSchema = credentialIssueResultSchema;
655
+ exports.decodePresentationQr = decodePresentationQr;
560
656
  exports.decodeRequest = decodeRequest;
561
657
  exports.decodeResponse = decodeResponse;
658
+ exports.encodePresentationQr = encodePresentationQr;
562
659
  exports.encodeRequest = encodeRequest;
563
660
  exports.encodeResponse = encodeResponse;
564
661
  exports.hex20Schema = hex20Schema;
package/dist/index.js CHANGED
@@ -21,6 +21,10 @@ const LINK_ERROR_CODES = [
21
21
  "PROOF_FAILED",
22
22
  "ISSUER_UNREACHABLE",
23
23
  "VERIFIER_UNREACHABLE",
24
+ /** The credential's issuer key is not among those the chain lists for the member the caller named. */
25
+ "ISSUER_NOT_APPROVED",
26
+ /** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
27
+ "CHAIN_UNREACHABLE",
24
28
  "INTERNAL"
25
29
  ];
26
30
 
@@ -480,6 +484,23 @@ function createLinkClient(options) {
480
484
  };
481
485
  }
482
486
 
487
+ /** The only error text that leaves the wallet, bar the zod text for malformed params; other detail stays on the device. */
488
+ const LINK_ERROR_MESSAGES = {
489
+ USER_DENIED: "The person declined the request.",
490
+ CONSENT_DENIED: "The person declined to share.",
491
+ UNSUPPORTED_OP: "This wallet does not support that operation.",
492
+ INVALID_REQUEST: "The request was not valid.",
493
+ NOT_ONBOARDED: "The wallet has no identity yet.",
494
+ NO_CREDENTIAL: "No matching credential.",
495
+ CREDENTIAL_REVOKED: "The matching credential has been revoked.",
496
+ PROOF_FAILED: "The wallet could not build the proof.",
497
+ ISSUER_UNREACHABLE: "The wallet could not use the issuer's server.",
498
+ VERIFIER_UNREACHABLE: "The wallet could not use the verifier's server.",
499
+ ISSUER_NOT_APPROVED: "The credential's issuer key is not approved on chain.",
500
+ CHAIN_UNREACHABLE: "The wallet could not read the chain.",
501
+ INTERNAL: "The wallet failed."
502
+ };
503
+
483
504
  function isLinkOp(op) {
484
505
  return LINK_OPS.includes(op);
485
506
  }
@@ -487,8 +508,8 @@ function isLinkOp(op) {
487
508
  const MAX_RESPONSE_MESSAGE_LENGTH = 256;
488
509
  function createRequestHandler(options) {
489
510
  const { handlers, consent } = options;
490
- function respond(request, code, message) {
491
- const cappedMessage = message?.slice(0, MAX_RESPONSE_MESSAGE_LENGTH);
511
+ function respond(request, code, message = LINK_ERROR_MESSAGES[code]) {
512
+ const cappedMessage = message.slice(0, MAX_RESPONSE_MESSAGE_LENGTH);
492
513
  return encodeResponse(request.callback, {
493
514
  id: request.id,
494
515
  version: "v1",
@@ -528,7 +549,7 @@ function createRequestHandler(options) {
528
549
  }
529
550
  catch (error) {
530
551
  if (error instanceof LinkError) {
531
- return respond(request, error.code, error.message);
552
+ return respond(request, error.code);
532
553
  }
533
554
  return respond(request, "INTERNAL");
534
555
  }
@@ -536,4 +557,77 @@ function createRequestHandler(options) {
536
557
  };
537
558
  }
538
559
 
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 };
560
+ const PREFIX = "cn-present:v1:";
561
+ const publicBase = z
562
+ .string()
563
+ .regex(/^https?:\/\/[^/?#@\s]+(?:\/[^?#@\s]*)?$/, "Server base must not contain credentials, query or fragment");
564
+ const schema = z
565
+ .object({
566
+ params: presentationCreateParamsSchema,
567
+ expiresAt: z.number().int().positive()
568
+ })
569
+ .strict();
570
+ /** Public challenge only. No credential, proof, callback or member bearer token crosses devices. */
571
+ function encodePresentationQr(value) {
572
+ const parsed = schema.parse(value);
573
+ const { params, expiresAt } = parsed;
574
+ const base = params.responseUrl.replace(/\/v1\/verify$/, "");
575
+ if (base === params.responseUrl ||
576
+ params.issuersUrl !== `${base}/v1/issuers` ||
577
+ params.revocationsUrl !== `${base}/v1/revocations/snapshot`)
578
+ throw new Error("Unsupported presentation endpoints");
579
+ publicBase.parse(base);
580
+ // Compact keys and one shared endpoint keep the QR readable at phone-screen sizes.
581
+ const wire = {
582
+ r: params.requestId,
583
+ c: params.challenge,
584
+ a: params.audience,
585
+ t: params.currentTime,
586
+ p: params.programme,
587
+ k: params.country,
588
+ u: base,
589
+ e: expiresAt
590
+ };
591
+ const encoded = PREFIX + base64UrlEncode(new TextEncoder().encode(JSON.stringify(wire)));
592
+ if (encoded.length > MAX_URL_LENGTH)
593
+ throw new Error("Presentation QR is too large");
594
+ return encoded;
595
+ }
596
+ function decodePresentationQr(raw) {
597
+ if (!raw.startsWith(PREFIX) || raw.length > MAX_URL_LENGTH)
598
+ throw new Error("Not a presentation QR");
599
+ try {
600
+ const wire = z
601
+ .object({
602
+ r: z.string(),
603
+ c: z.string(),
604
+ a: z.string(),
605
+ t: z.number(),
606
+ p: z.string(),
607
+ k: z.string(),
608
+ u: publicBase,
609
+ e: z.number()
610
+ })
611
+ .strict()
612
+ .parse(JSON.parse(new TextDecoder().decode(base64UrlDecode(raw.slice(PREFIX.length)))));
613
+ return schema.parse({
614
+ expiresAt: wire.e,
615
+ params: {
616
+ requestId: wire.r,
617
+ challenge: wire.c,
618
+ audience: wire.a,
619
+ currentTime: wire.t,
620
+ programme: wire.p,
621
+ country: wire.k,
622
+ issuersUrl: `${wire.u}/v1/issuers`,
623
+ revocationsUrl: `${wire.u}/v1/revocations/snapshot`,
624
+ responseUrl: `${wire.u}/v1/verify`
625
+ }
626
+ });
627
+ }
628
+ catch {
629
+ throw new Error("Invalid presentation QR");
630
+ }
631
+ }
632
+
633
+ export { ALLOWED_ENDPOINT_URL_SCHEMES, DENIED_CALLBACK_SCHEMES, LINK_ERROR_CODES, LINK_ERROR_MESSAGES, LINK_OPS, LinkDecodeError, LinkError, LinkPayloadTooLargeError, MAX_URL_LENGTH, SECURITY_LEVELS, USER_APP_SCHEME, approvalScopeSchema, base64UrlDecode, base64UrlEncode, callbackSchema, callerSchema, createLinkClient, createRequestHandler, credentialIssueParamsSchema, credentialIssueResultSchema, decodePresentationQr, decodeRequest, decodeResponse, encodePresentationQr, encodeRequest, encodeResponse, hex20Schema, hex31Schema, hex32Schema, hex65Schema, hex66Schema, linkErrorCodeSchema, linkRequestEnvelopeSchema, linkRequestSchema, linkResponseSchema, paramsSchemas, presentationCreateParamsSchema, presentationCreateResultSchema, registrationSignParamsSchema, registrationSignResultSchema, resultSchemas, securityLevelSchema, signedRegistrationRequestSchema };
package/dist/web.cjs CHANGED
@@ -37,6 +37,10 @@ const LINK_ERROR_CODES = [
37
37
  "PROOF_FAILED",
38
38
  "ISSUER_UNREACHABLE",
39
39
  "VERIFIER_UNREACHABLE",
40
+ /** The credential's issuer key is not among those the chain lists for the member the caller named. */
41
+ "ISSUER_NOT_APPROVED",
42
+ /** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
43
+ "CHAIN_UNREACHABLE",
40
44
  "INTERNAL"
41
45
  ];
42
46
 
@@ -505,16 +509,36 @@ function createEmbeddedWebLinkTransport(options) {
505
509
  }
506
510
  };
507
511
  }
512
+ /** Reads the private demo key from a pairing fragment or asks the operator once per tab. */
513
+ function browserSessionKey(serviceUrl, host = window, fragmentKey = "relayKey") {
514
+ const storageKey = `coordination-session-key:${fragmentKey}:${new URL(serviceUrl).origin}`;
515
+ const current = new URL(host.location.href);
516
+ const fragment = new URLSearchParams(current.hash.slice(1));
517
+ const paired = fragment.get(fragmentKey);
518
+ if (paired !== null) {
519
+ fragment.delete(fragmentKey);
520
+ current.hash = fragment.toString();
521
+ host.history.replaceState(null, "", current.toString());
522
+ }
523
+ const token = paired ??
524
+ host.sessionStorage.getItem(storageKey) ??
525
+ host.prompt(fragmentKey === "memberKey" ? "Member connection key" : "Demo connection key")?.trim();
526
+ if (!token || !/^[0-9a-f]{64}$/i.test(token))
527
+ throw new Error("A demo connection key is required");
528
+ host.sessionStorage.setItem(storageKey, token);
529
+ return token;
530
+ }
508
531
  /** Carries unchanged link URLs through the local demo relay between independently opened pages. */
509
532
  function createRelayWebLinkTransport(options) {
510
533
  const fetcher = options.fetcher ?? fetch;
511
534
  const relayUrl = new URL(options.relayUrl).origin;
535
+ const headers = { authorization: `Bearer ${options.token}` };
512
536
  let after = 0;
513
537
  return {
514
538
  async openUrl(url) {
515
539
  const response = await fetcher(`${relayUrl}/v1/requests`, {
516
540
  method: "POST",
517
- headers: { "content-type": "application/json" },
541
+ headers: { "content-type": "application/json", ...headers },
518
542
  body: JSON.stringify({ sender: options.sender, url })
519
543
  });
520
544
  if (!response.ok)
@@ -528,7 +552,7 @@ function createRelayWebLinkTransport(options) {
528
552
  return;
529
553
  polling = true;
530
554
  try {
531
- const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`);
555
+ const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`, { headers });
532
556
  if (!response.ok)
533
557
  return;
534
558
  const messages = (await response.json());
@@ -536,6 +560,9 @@ function createRelayWebLinkTransport(options) {
536
560
  after = Math.max(after, message.seq);
537
561
  listener(message.url);
538
562
  }
563
+ if (messages.length > 0) {
564
+ await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`, { method: "DELETE", headers });
565
+ }
539
566
  }
540
567
  catch {
541
568
  // The next poll retries while the local demo stack starts or restarts.
@@ -554,6 +581,7 @@ function createRelayWebLinkTransport(options) {
554
581
  };
555
582
  }
556
583
 
584
+ exports.browserSessionKey = browserSessionKey;
557
585
  exports.createEmbeddedWebLinkTransport = createEmbeddedWebLinkTransport;
558
586
  exports.createRelayWebLinkTransport = createRelayWebLinkTransport;
559
587
  exports.createWebLinkTransport = createWebLinkTransport;
package/dist/web.js CHANGED
@@ -35,6 +35,10 @@ const LINK_ERROR_CODES = [
35
35
  "PROOF_FAILED",
36
36
  "ISSUER_UNREACHABLE",
37
37
  "VERIFIER_UNREACHABLE",
38
+ /** The credential's issuer key is not among those the chain lists for the member the caller named. */
39
+ "ISSUER_NOT_APPROVED",
40
+ /** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
41
+ "CHAIN_UNREACHABLE",
38
42
  "INTERNAL"
39
43
  ];
40
44
 
@@ -503,16 +507,36 @@ function createEmbeddedWebLinkTransport(options) {
503
507
  }
504
508
  };
505
509
  }
510
+ /** Reads the private demo key from a pairing fragment or asks the operator once per tab. */
511
+ function browserSessionKey(serviceUrl, host = window, fragmentKey = "relayKey") {
512
+ const storageKey = `coordination-session-key:${fragmentKey}:${new URL(serviceUrl).origin}`;
513
+ const current = new URL(host.location.href);
514
+ const fragment = new URLSearchParams(current.hash.slice(1));
515
+ const paired = fragment.get(fragmentKey);
516
+ if (paired !== null) {
517
+ fragment.delete(fragmentKey);
518
+ current.hash = fragment.toString();
519
+ host.history.replaceState(null, "", current.toString());
520
+ }
521
+ const token = paired ??
522
+ host.sessionStorage.getItem(storageKey) ??
523
+ host.prompt(fragmentKey === "memberKey" ? "Member connection key" : "Demo connection key")?.trim();
524
+ if (!token || !/^[0-9a-f]{64}$/i.test(token))
525
+ throw new Error("A demo connection key is required");
526
+ host.sessionStorage.setItem(storageKey, token);
527
+ return token;
528
+ }
506
529
  /** Carries unchanged link URLs through the local demo relay between independently opened pages. */
507
530
  function createRelayWebLinkTransport(options) {
508
531
  const fetcher = options.fetcher ?? fetch;
509
532
  const relayUrl = new URL(options.relayUrl).origin;
533
+ const headers = { authorization: `Bearer ${options.token}` };
510
534
  let after = 0;
511
535
  return {
512
536
  async openUrl(url) {
513
537
  const response = await fetcher(`${relayUrl}/v1/requests`, {
514
538
  method: "POST",
515
- headers: { "content-type": "application/json" },
539
+ headers: { "content-type": "application/json", ...headers },
516
540
  body: JSON.stringify({ sender: options.sender, url })
517
541
  });
518
542
  if (!response.ok)
@@ -526,7 +550,7 @@ function createRelayWebLinkTransport(options) {
526
550
  return;
527
551
  polling = true;
528
552
  try {
529
- const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`);
553
+ const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`, { headers });
530
554
  if (!response.ok)
531
555
  return;
532
556
  const messages = (await response.json());
@@ -534,6 +558,9 @@ function createRelayWebLinkTransport(options) {
534
558
  after = Math.max(after, message.seq);
535
559
  listener(message.url);
536
560
  }
561
+ if (messages.length > 0) {
562
+ await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`, { method: "DELETE", headers });
563
+ }
537
564
  }
538
565
  catch {
539
566
  // The next poll retries while the local demo stack starts or restarts.
@@ -552,4 +579,4 @@ function createRelayWebLinkTransport(options) {
552
579
  };
553
580
  }
554
581
 
555
- export { createEmbeddedWebLinkTransport, createRelayWebLinkTransport, createWebLinkTransport, respondToWebLinkRequest };
582
+ export { browserSessionKey, createEmbeddedWebLinkTransport, createRelayWebLinkTransport, createWebLinkTransport, respondToWebLinkRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coordination-network/user-app-link",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Deep-link protocol between the user app (keys, credentials, proving) and network member apps on the same device.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/handler.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { decodeRequest, encodeResponse } from "./codec"
2
2
  import { LinkError } from "./errors"
3
+ import { LINK_ERROR_MESSAGES } from "./messages"
3
4
  import { paramsSchemas, resultSchemas } from "./schemas"
4
5
  import {
5
6
  LINK_OPS,
@@ -38,8 +39,12 @@ type AnyLinkHandler = (params: unknown, context: { caller: Caller }) => Promise<
38
39
  export function createRequestHandler(options: RequestHandlerOptions): RequestHandler {
39
40
  const { handlers, consent } = options
40
41
 
41
- function respond(request: DecodedLinkRequest, code: LinkErrorCode, message?: string): string {
42
- const cappedMessage = message?.slice(0, MAX_RESPONSE_MESSAGE_LENGTH)
42
+ function respond(
43
+ request: DecodedLinkRequest,
44
+ code: LinkErrorCode,
45
+ message: string = LINK_ERROR_MESSAGES[code]
46
+ ): string {
47
+ const cappedMessage = message.slice(0, MAX_RESPONSE_MESSAGE_LENGTH)
43
48
  return encodeResponse(request.callback, {
44
49
  id: request.id,
45
50
  version: "v1",
@@ -86,7 +91,7 @@ export function createRequestHandler(options: RequestHandlerOptions): RequestHan
86
91
  return encodeResponse(request.callback, response)
87
92
  } catch (error) {
88
93
  if (error instanceof LinkError) {
89
- return respond(request, error.code, error.message)
94
+ return respond(request, error.code)
90
95
  }
91
96
  return respond(request, "INTERNAL")
92
97
  }
package/src/index.ts CHANGED
@@ -4,3 +4,5 @@ export * from "./errors"
4
4
  export * from "./codec"
5
5
  export * from "./client"
6
6
  export * from "./handler"
7
+ export * from "./messages"
8
+ export * from "./presentation-qr"
@@ -0,0 +1,18 @@
1
+ import type { LinkErrorCode } from "./types"
2
+
3
+ /** The only error text that leaves the wallet, bar the zod text for malformed params; other detail stays on the device. */
4
+ export const LINK_ERROR_MESSAGES: Record<LinkErrorCode, string> = {
5
+ USER_DENIED: "The person declined the request.",
6
+ CONSENT_DENIED: "The person declined to share.",
7
+ UNSUPPORTED_OP: "This wallet does not support that operation.",
8
+ INVALID_REQUEST: "The request was not valid.",
9
+ NOT_ONBOARDED: "The wallet has no identity yet.",
10
+ NO_CREDENTIAL: "No matching credential.",
11
+ CREDENTIAL_REVOKED: "The matching credential has been revoked.",
12
+ PROOF_FAILED: "The wallet could not build the proof.",
13
+ ISSUER_UNREACHABLE: "The wallet could not use the issuer's server.",
14
+ VERIFIER_UNREACHABLE: "The wallet could not use the verifier's server.",
15
+ ISSUER_NOT_APPROVED: "The credential's issuer key is not approved on chain.",
16
+ CHAIN_UNREACHABLE: "The wallet could not read the chain.",
17
+ INTERNAL: "The wallet failed."
18
+ }
@@ -0,0 +1,79 @@
1
+ import { z } from "zod"
2
+ import { base64UrlDecode, base64UrlEncode, MAX_URL_LENGTH } from "./codec"
3
+ import { presentationCreateParamsSchema } from "./schemas"
4
+
5
+ const PREFIX = "cn-present:v1:"
6
+ const publicBase = z
7
+ .string()
8
+ .regex(/^https?:\/\/[^/?#@\s]+(?:\/[^?#@\s]*)?$/, "Server base must not contain credentials, query or fragment")
9
+ const schema = z
10
+ .object({
11
+ params: presentationCreateParamsSchema,
12
+ expiresAt: z.number().int().positive()
13
+ })
14
+ .strict()
15
+
16
+ export type PresentationQr = z.infer<typeof schema>
17
+
18
+ /** Public challenge only. No credential, proof, callback or member bearer token crosses devices. */
19
+ export function encodePresentationQr(value: PresentationQr): string {
20
+ const parsed = schema.parse(value)
21
+ const { params, expiresAt } = parsed
22
+ const base = params.responseUrl.replace(/\/v1\/verify$/, "")
23
+ if (
24
+ base === params.responseUrl ||
25
+ params.issuersUrl !== `${base}/v1/issuers` ||
26
+ params.revocationsUrl !== `${base}/v1/revocations/snapshot`
27
+ )
28
+ throw new Error("Unsupported presentation endpoints")
29
+ publicBase.parse(base)
30
+ // Compact keys and one shared endpoint keep the QR readable at phone-screen sizes.
31
+ const wire = {
32
+ r: params.requestId,
33
+ c: params.challenge,
34
+ a: params.audience,
35
+ t: params.currentTime,
36
+ p: params.programme,
37
+ k: params.country,
38
+ u: base,
39
+ e: expiresAt
40
+ }
41
+ const encoded = PREFIX + base64UrlEncode(new TextEncoder().encode(JSON.stringify(wire)))
42
+ if (encoded.length > MAX_URL_LENGTH) throw new Error("Presentation QR is too large")
43
+ return encoded
44
+ }
45
+
46
+ export function decodePresentationQr(raw: string): PresentationQr {
47
+ if (!raw.startsWith(PREFIX) || raw.length > MAX_URL_LENGTH) throw new Error("Not a presentation QR")
48
+ try {
49
+ const wire = z
50
+ .object({
51
+ r: z.string(),
52
+ c: z.string(),
53
+ a: z.string(),
54
+ t: z.number(),
55
+ p: z.string(),
56
+ k: z.string(),
57
+ u: publicBase,
58
+ e: z.number()
59
+ })
60
+ .strict()
61
+ .parse(JSON.parse(new TextDecoder().decode(base64UrlDecode(raw.slice(PREFIX.length)))))
62
+ return schema.parse({
63
+ expiresAt: wire.e,
64
+ params: {
65
+ requestId: wire.r,
66
+ challenge: wire.c,
67
+ audience: wire.a,
68
+ currentTime: wire.t,
69
+ programme: wire.p,
70
+ country: wire.k,
71
+ issuersUrl: `${wire.u}/v1/issuers`,
72
+ revocationsUrl: `${wire.u}/v1/revocations/snapshot`,
73
+ responseUrl: `${wire.u}/v1/verify`
74
+ }
75
+ })
76
+ } catch {
77
+ throw new Error("Invalid presentation QR")
78
+ }
79
+ }
package/src/types.ts CHANGED
@@ -160,6 +160,10 @@ export const LINK_ERROR_CODES = [
160
160
  "PROOF_FAILED",
161
161
  "ISSUER_UNREACHABLE",
162
162
  "VERIFIER_UNREACHABLE",
163
+ /** The credential's issuer key is not among those the chain lists for the member the caller named. */
164
+ "ISSUER_NOT_APPROVED",
165
+ /** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
166
+ "CHAIN_UNREACHABLE",
163
167
  "INTERNAL"
164
168
  ] as const
165
169
 
package/src/web.ts CHANGED
@@ -271,7 +271,29 @@ export function createEmbeddedWebLinkTransport(options: EmbeddedWebLinkTransport
271
271
  }
272
272
  }
273
273
 
274
+ /** Reads the private demo key from a pairing fragment or asks the operator once per tab. */
275
+ export function browserSessionKey(serviceUrl: string, host: Window = window, fragmentKey = "relayKey"): string {
276
+ const storageKey = `coordination-session-key:${fragmentKey}:${new URL(serviceUrl).origin}`
277
+ const current = new URL(host.location.href)
278
+ const fragment = new URLSearchParams(current.hash.slice(1))
279
+ const paired = fragment.get(fragmentKey)
280
+ if (paired !== null) {
281
+ fragment.delete(fragmentKey)
282
+ current.hash = fragment.toString()
283
+ host.history.replaceState(null, "", current.toString())
284
+ }
285
+ const token =
286
+ paired ??
287
+ host.sessionStorage.getItem(storageKey) ??
288
+ host.prompt(fragmentKey === "memberKey" ? "Member connection key" : "Demo connection key")?.trim()
289
+ if (!token || !/^[0-9a-f]{64}$/i.test(token)) throw new Error("A demo connection key is required")
290
+ host.sessionStorage.setItem(storageKey, token)
291
+ return token
292
+ }
293
+
274
294
  export interface RelayWebLinkTransportOptions {
295
+ /** Private key for this demo relay session; never put it in stack.json. */
296
+ token: string
275
297
  relayUrl: string
276
298
  sender: string
277
299
  pollIntervalMs?: number
@@ -284,12 +306,13 @@ export function createRelayWebLinkTransport(
284
306
  ): Pick<LinkClientOptions, "openUrl" | "onUrl"> {
285
307
  const fetcher = options.fetcher ?? fetch
286
308
  const relayUrl = new URL(options.relayUrl).origin
309
+ const headers = { authorization: `Bearer ${options.token}` }
287
310
  let after = 0
288
311
  return {
289
312
  async openUrl(url) {
290
313
  const response = await fetcher(`${relayUrl}/v1/requests`, {
291
314
  method: "POST",
292
- headers: { "content-type": "application/json" },
315
+ headers: { "content-type": "application/json", ...headers },
293
316
  body: JSON.stringify({ sender: options.sender, url })
294
317
  })
295
318
  if (!response.ok) throw new Error("wallet request could not be delivered")
@@ -302,7 +325,8 @@ export function createRelayWebLinkTransport(
302
325
  polling = true
303
326
  try {
304
327
  const response = await fetcher(
305
- `${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`
328
+ `${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`,
329
+ { headers }
306
330
  )
307
331
  if (!response.ok) return
308
332
  const messages = (await response.json()) as Array<{ seq: number; url: string }>
@@ -310,6 +334,12 @@ export function createRelayWebLinkTransport(
310
334
  after = Math.max(after, message.seq)
311
335
  listener(message.url)
312
336
  }
337
+ if (messages.length > 0) {
338
+ await fetcher(
339
+ `${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`,
340
+ { method: "DELETE", headers }
341
+ )
342
+ }
313
343
  } catch {
314
344
  // The next poll retries while the local demo stack starts or restarts.
315
345
  } finally {
package/types/index.d.ts CHANGED
@@ -139,7 +139,7 @@ interface DecodedLinkRequest {
139
139
  op: string;
140
140
  params: unknown;
141
141
  }
142
- declare const LINK_ERROR_CODES: readonly ["USER_DENIED", "CONSENT_DENIED", "UNSUPPORTED_OP", "INVALID_REQUEST", "NOT_ONBOARDED", "NO_CREDENTIAL", "CREDENTIAL_REVOKED", "PROOF_FAILED", "ISSUER_UNREACHABLE", "VERIFIER_UNREACHABLE", "INTERNAL"];
142
+ declare const LINK_ERROR_CODES: readonly ["USER_DENIED", "CONSENT_DENIED", "UNSUPPORTED_OP", "INVALID_REQUEST", "NOT_ONBOARDED", "NO_CREDENTIAL", "CREDENTIAL_REVOKED", "PROOF_FAILED", "ISSUER_UNREACHABLE", "VERIFIER_UNREACHABLE", "ISSUER_NOT_APPROVED", "CHAIN_UNREACHABLE", "INTERNAL"];
143
143
  type LinkErrorCode = (typeof LINK_ERROR_CODES)[number];
144
144
  /** The response envelope. Unlike the request, it carries no `op`: the caller already knows which op it asked for. */
145
145
  type LinkResponse<Op extends LinkOp = LinkOp> = (Op extends LinkOp ? {
@@ -348,6 +348,8 @@ declare const linkErrorCodeSchema: z.ZodEnum<{
348
348
  PROOF_FAILED: "PROOF_FAILED";
349
349
  ISSUER_UNREACHABLE: "ISSUER_UNREACHABLE";
350
350
  VERIFIER_UNREACHABLE: "VERIFIER_UNREACHABLE";
351
+ ISSUER_NOT_APPROVED: "ISSUER_NOT_APPROVED";
352
+ CHAIN_UNREACHABLE: "CHAIN_UNREACHABLE";
351
353
  INTERNAL: "INTERNAL";
352
354
  }>;
353
355
  declare const linkResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -370,6 +372,8 @@ declare const linkResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
370
372
  PROOF_FAILED: "PROOF_FAILED";
371
373
  ISSUER_UNREACHABLE: "ISSUER_UNREACHABLE";
372
374
  VERIFIER_UNREACHABLE: "VERIFIER_UNREACHABLE";
375
+ ISSUER_NOT_APPROVED: "ISSUER_NOT_APPROVED";
376
+ CHAIN_UNREACHABLE: "CHAIN_UNREACHABLE";
373
377
  INTERNAL: "INTERNAL";
374
378
  }>;
375
379
  message: z.ZodOptional<z.ZodString>;
@@ -436,5 +440,27 @@ interface RequestHandler {
436
440
  }
437
441
  declare function createRequestHandler(options: RequestHandlerOptions): RequestHandler;
438
442
 
439
- 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 };
440
- export type { ApprovalScope, Caller, CredentialIssueParams, CredentialIssueResult, DecodedLinkRequest, Hex, Hex20, Hex31, Hex32, Hex65, Hex66, Jwk, LinkClient, LinkClientOptions, LinkErrorCode, LinkHandlers, LinkOp, LinkOpMap, LinkRequest, LinkResponse, Params, PresentationCreateParams, PresentationCreateResult, RegistrationSignParams, RegistrationSignResult, RequestHandler, RequestHandlerOptions, Result, SecurityLevel, SignedRegistrationRequest };
443
+ /** The only error text that leaves the wallet, bar the zod text for malformed params; other detail stays on the device. */
444
+ declare const LINK_ERROR_MESSAGES: Record<LinkErrorCode, string>;
445
+
446
+ declare const schema: z.ZodObject<{
447
+ params: z.ZodObject<{
448
+ requestId: z.ZodString;
449
+ challenge: z.ZodPipe<z.ZodString, z.ZodTransform<`0x${string}`, string>>;
450
+ audience: z.ZodString;
451
+ currentTime: z.ZodNumber;
452
+ programme: z.ZodString;
453
+ country: z.ZodString;
454
+ issuersUrl: z.ZodString;
455
+ revocationsUrl: z.ZodString;
456
+ responseUrl: z.ZodString;
457
+ }, z.core.$strict>;
458
+ expiresAt: z.ZodNumber;
459
+ }, z.core.$strict>;
460
+ type PresentationQr = z.infer<typeof schema>;
461
+ /** Public challenge only. No credential, proof, callback or member bearer token crosses devices. */
462
+ declare function encodePresentationQr(value: PresentationQr): string;
463
+ declare function decodePresentationQr(raw: string): PresentationQr;
464
+
465
+ export { ALLOWED_ENDPOINT_URL_SCHEMES, DENIED_CALLBACK_SCHEMES, LINK_ERROR_CODES, LINK_ERROR_MESSAGES, LINK_OPS, LinkDecodeError, LinkError, LinkPayloadTooLargeError, MAX_URL_LENGTH, SECURITY_LEVELS, USER_APP_SCHEME, approvalScopeSchema, base64UrlDecode, base64UrlEncode, callbackSchema, callerSchema, createLinkClient, createRequestHandler, credentialIssueParamsSchema, credentialIssueResultSchema, decodePresentationQr, decodeRequest, decodeResponse, encodePresentationQr, encodeRequest, encodeResponse, hex20Schema, hex31Schema, hex32Schema, hex65Schema, hex66Schema, linkErrorCodeSchema, linkRequestEnvelopeSchema, linkRequestSchema, linkResponseSchema, paramsSchemas, presentationCreateParamsSchema, presentationCreateResultSchema, registrationSignParamsSchema, registrationSignResultSchema, resultSchemas, securityLevelSchema, signedRegistrationRequestSchema };
466
+ export type { ApprovalScope, Caller, CredentialIssueParams, CredentialIssueResult, DecodedLinkRequest, Hex, Hex20, Hex31, Hex32, Hex65, Hex66, Jwk, LinkClient, LinkClientOptions, LinkErrorCode, LinkHandlers, LinkOp, LinkOpMap, LinkRequest, LinkResponse, Params, PresentationCreateParams, PresentationCreateResult, PresentationQr, RegistrationSignParams, RegistrationSignResult, RequestHandler, RequestHandlerOptions, Result, SecurityLevel, SignedRegistrationRequest };
package/types/web.d.ts CHANGED
@@ -68,7 +68,11 @@ interface EmbeddedWebLinkTransportOptions {
68
68
  }
69
69
  /** Carries the browser protocol through an in-page iframe, avoiding popup-blocker behavior. */
70
70
  declare function createEmbeddedWebLinkTransport(options: EmbeddedWebLinkTransportOptions): WebLinkTransport;
71
+ /** Reads the private demo key from a pairing fragment or asks the operator once per tab. */
72
+ declare function browserSessionKey(serviceUrl: string, host?: Window, fragmentKey?: string): string;
71
73
  interface RelayWebLinkTransportOptions {
74
+ /** Private key for this demo relay session; never put it in stack.json. */
75
+ token: string;
72
76
  relayUrl: string;
73
77
  sender: string;
74
78
  pollIntervalMs?: number;
@@ -77,5 +81,5 @@ interface RelayWebLinkTransportOptions {
77
81
  /** Carries unchanged link URLs through the local demo relay between independently opened pages. */
78
82
  declare function createRelayWebLinkTransport(options: RelayWebLinkTransportOptions): Pick<LinkClientOptions, "openUrl" | "onUrl">;
79
83
 
80
- export { createEmbeddedWebLinkTransport, createRelayWebLinkTransport, createWebLinkTransport, respondToWebLinkRequest };
84
+ export { browserSessionKey, createEmbeddedWebLinkTransport, createRelayWebLinkTransport, createWebLinkTransport, respondToWebLinkRequest };
81
85
  export type { EmbeddedWebLinkTransportOptions, RelayWebLinkTransportOptions, WebLinkResponderOptions, WebLinkTransport, WebLinkTransportOptions };