@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/src/types.ts ADDED
@@ -0,0 +1,176 @@
1
+ /** Protocol v1 types for the user-app <-> network-member-app deep link. */
2
+
3
+ /** A `0x`-prefixed lowercase hexadecimal string. Byte-length aliases below are documentation only. */
4
+ export type Hex = `0x${string}`
5
+
6
+ /** A 20-byte address, e.g. `verifyingContract`. */
7
+ export type Hex20 = Hex
8
+ /** A 31-byte value, sized to fit under the BN254 scalar field for in-circuit use. */
9
+ export type Hex31 = Hex
10
+ /** A 32-byte value, e.g. a `networkId`. */
11
+ export type Hex32 = Hex
12
+ /** A 65-byte uncompressed secp256k1 public key, or a 65-byte signature with a recovery byte. */
13
+ export type Hex65 = Hex
14
+ /** A 66-byte ERC-6538 meta-stealth address (33-byte compressed spending key || 33-byte compressed viewing key). */
15
+ export type Hex66 = Hex
16
+
17
+ /** An EC public key in JWK form (RFC 7517). This protocol uses P-256 only. */
18
+ export interface Jwk {
19
+ kty: "EC"
20
+ crv: "P-256"
21
+ x: string
22
+ y: string
23
+ }
24
+
25
+ /** Where the user app's device key lives in the platform's key hierarchy. */
26
+ export const SECURITY_LEVELS = ["STRONGBOX", "TRUSTED_ENVIRONMENT", "SOFTWARE", "UNKNOWN"] as const
27
+ export type SecurityLevel = (typeof SECURITY_LEVELS)[number]
28
+
29
+ /** Identifies the member app making a request, as declared by that app (not verified by the OS). */
30
+ export interface Caller {
31
+ name: string
32
+ scheme: string
33
+ }
34
+
35
+ export interface ApprovalScope {
36
+ programme: string
37
+ country: string
38
+ }
39
+
40
+ export interface RegistrationSignParams {
41
+ chainId: number
42
+ verifyingContract: Hex20
43
+ }
44
+
45
+ /** Mirrors `RegistrationRequest` from `@coordination-network/user-client`, redeclared to avoid a cross-package dependency. */
46
+ export interface SignedRegistrationRequest {
47
+ version: "v1"
48
+ networkId: Hex32
49
+ schemeId: 1
50
+ metaStealthAddress: Hex66
51
+ chainId: number
52
+ verifyingContract: Hex20
53
+ signature: Hex65
54
+ }
55
+
56
+ /**
57
+ * `request` is posted to the member server verbatim. `securityLevel` rides along because
58
+ * registration is the one op every member app runs first, and it is the only thing a member needs
59
+ * about the wallet that the request itself does not already carry.
60
+ */
61
+ export interface RegistrationSignResult {
62
+ request: SignedRegistrationRequest
63
+ securityLevel: SecurityLevel
64
+ }
65
+
66
+ /**
67
+ * The wallet drives the whole issuance itself from these four endpoints, so a member app hands
68
+ * over the URLs rather than relaying each hop.
69
+ */
70
+ export interface CredentialIssueParams {
71
+ issuerNetworkId: Hex32
72
+ approvalScope: ApprovalScope
73
+ challengeUrl: string
74
+ credentialsUrl: string
75
+ /** The issuer's approved-issuer set, which the wallet checks the credential against before storing. */
76
+ issuersUrl: string
77
+ /** The issuer's revocation snapshot, checked in the same step. */
78
+ revocationsUrl: string
79
+ }
80
+
81
+ /**
82
+ * `refused` is the issuer server declining to issue, in its own error vocabulary — not a
83
+ * {@link LinkErrorCode}. A wallet-side failure throws instead.
84
+ */
85
+ export type CredentialIssueResult =
86
+ | { status: "stored"; credentialId: string }
87
+ | { status: "alreadyHeld"; credentialId: string }
88
+ | { status: "refused"; code: string; message: string }
89
+
90
+ export interface PresentationCreateParams {
91
+ /** From the member server's `POST /v1/presentation-requests`; echoed back in the `/v1/verify` POST. */
92
+ requestId: string
93
+ challenge: Hex31
94
+ audience: string
95
+ currentTime: number
96
+ programme: string
97
+ country: string
98
+ issuersUrl: string
99
+ /** One network-wide tree, so any member serves the same one; the verifier's is the one whose
100
+ * root it will accept. */
101
+ revocationsUrl: string
102
+ responseUrl: string
103
+ }
104
+
105
+ /** The verifier records the outcome under this id; the caller reads it from its own server. */
106
+ export interface PresentationCreateResult {
107
+ verificationId: string
108
+ }
109
+
110
+ /** Every op this protocol version defines, mapped to its params and result shapes. */
111
+ export interface LinkOpMap {
112
+ "registration.sign": { params: RegistrationSignParams; result: RegistrationSignResult }
113
+ "credential.issue": { params: CredentialIssueParams; result: CredentialIssueResult }
114
+ "presentation.create": { params: PresentationCreateParams; result: PresentationCreateResult }
115
+ }
116
+
117
+ export const LINK_OPS = [
118
+ "registration.sign",
119
+ "credential.issue",
120
+ "presentation.create"
121
+ ] as const satisfies readonly (keyof LinkOpMap)[]
122
+
123
+ export type LinkOp = (typeof LINK_OPS)[number]
124
+
125
+ export type Params<Op extends LinkOp> = LinkOpMap[Op]["params"]
126
+ export type Result<Op extends LinkOp> = LinkOpMap[Op]["result"]
127
+
128
+ /** Distributive conditional: without it, `LinkRequest` (Op defaulting to `LinkOp`) would let any op pair with any op's params instead of only its own. */
129
+ export type LinkRequest<Op extends LinkOp = LinkOp> = Op extends LinkOp
130
+ ? {
131
+ id: string
132
+ version: "v1"
133
+ caller: Caller
134
+ callback: string
135
+ op: Op
136
+ params: Params<Op>
137
+ }
138
+ : never
139
+
140
+ /** A request whose envelope decoded successfully but whose `op`/`params` are not yet validated against a schema. */
141
+ export interface DecodedLinkRequest {
142
+ id: string
143
+ version: "v1"
144
+ caller: Caller
145
+ callback: string
146
+ op: string
147
+ params: unknown
148
+ }
149
+
150
+ export const LINK_ERROR_CODES = [
151
+ "USER_DENIED",
152
+ /** Declined at the share step, after the proof was built and the person saw what it reveals. */
153
+ "CONSENT_DENIED",
154
+ "UNSUPPORTED_OP",
155
+ "INVALID_REQUEST",
156
+ "NOT_ONBOARDED",
157
+ "NO_CREDENTIAL",
158
+ /** A credential for this programme and country is held, but its issuer has revoked it. */
159
+ "CREDENTIAL_REVOKED",
160
+ "PROOF_FAILED",
161
+ "ISSUER_UNREACHABLE",
162
+ "VERIFIER_UNREACHABLE",
163
+ "INTERNAL"
164
+ ] as const
165
+
166
+ export type LinkErrorCode = (typeof LINK_ERROR_CODES)[number]
167
+
168
+ /** The response envelope. Unlike the request, it carries no `op`: the caller already knows which op it asked for. */
169
+ export type LinkResponse<Op extends LinkOp = LinkOp> =
170
+ | (Op extends LinkOp ? { id: string; version: "v1"; ok: true; result: Result<Op> } : never)
171
+ | { id: string; version: "v1"; ok: false; code: LinkErrorCode; message?: string }
172
+
173
+ /** The handlers a user app registers with {@link createRequestHandler}, one per op it supports. */
174
+ export type LinkHandlers = {
175
+ [Op in LinkOp]?: (params: Params<Op>, context: { caller: Caller }) => Promise<Result<Op>>
176
+ }
package/src/web.ts ADDED
@@ -0,0 +1,327 @@
1
+ import type { LinkClientOptions } from "./client"
2
+ import { decodeRequest } from "./codec"
3
+ import type { RequestHandler } from "./handler"
4
+
5
+ export interface WebLinkTransportOptions {
6
+ /** Browser URL of the user web app. The encoded request query is appended to this URL. */
7
+ userAppUrl: string
8
+ /** Exact origin trusted to return responses, for example `http://localhost:5173`. */
9
+ userAppOrigin: string
10
+ popupName?: string
11
+ popupFeatures?: string
12
+ /** How often to detect a popup closed before responding. Default 100ms. */
13
+ closePollIntervalMs?: number
14
+ /** Time to wait for an already-posted response after observing a closed popup. Default 250ms. */
15
+ closeGracePeriodMs?: number
16
+ /** Injectable for tests; defaults to the current browser window. */
17
+ window?: Window
18
+ }
19
+
20
+ export type WebLinkTransport = Pick<LinkClientOptions, "openUrl" | "onUrl"> & {
21
+ /** Reserves the popup synchronously inside a click handler before asynchronous flow work begins. */
22
+ prepare(): void
23
+ close(): void
24
+ }
25
+
26
+ const DEFAULT_CLOSE_POLL_INTERVAL_MS = 100
27
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000
28
+ const READY_MESSAGE = "coordination-network:user-app-ready:v1"
29
+ const HELLO_MESSAGE = "coordination-network:member-app-hello:v1"
30
+
31
+ function normalizedOrigin(value: string, option: string): string {
32
+ let origin: string
33
+ try {
34
+ origin = new URL(value).origin
35
+ } catch {
36
+ throw new Error(`${option} must be an absolute browser URL`)
37
+ }
38
+ if (origin === "null") {
39
+ throw new Error(`${option} must have an http or https origin`)
40
+ }
41
+ return origin
42
+ }
43
+
44
+ /**
45
+ * Supplies the two transport functions consumed by `createLinkClient`. The protocol request stays
46
+ * unchanged; only its query string is carried to the browser user app instead of a mobile scheme.
47
+ */
48
+ export function createWebLinkTransport(options: WebLinkTransportOptions): WebLinkTransport {
49
+ const host = options.window ?? window
50
+ const userAppOrigin = normalizedOrigin(options.userAppOrigin, "userAppOrigin")
51
+ const popupUrl = new URL(options.userAppUrl)
52
+ if (popupUrl.origin !== userAppOrigin) {
53
+ throw new Error("userAppUrl must belong to userAppOrigin")
54
+ }
55
+
56
+ let popup: Window | null = null
57
+ let listener: ((url: string) => void) | null = null
58
+ let closeTimer: ReturnType<typeof setInterval> | null = null
59
+ let finishOpen: (() => void) | null = null
60
+ let failOpen: ((error: Error) => void) | null = null
61
+ let closedAt: number | null = null
62
+
63
+ function clearCloseTimer(): void {
64
+ if (closeTimer !== null) {
65
+ clearInterval(closeTimer)
66
+ closeTimer = null
67
+ }
68
+ }
69
+
70
+ function handleMessage(event: MessageEvent): void {
71
+ if (!popup || event.source !== popup || event.origin !== userAppOrigin) {
72
+ return
73
+ }
74
+ if (event.data === READY_MESSAGE) {
75
+ popup.postMessage(HELLO_MESSAGE, userAppOrigin)
76
+ return
77
+ }
78
+ if (typeof event.data !== "string") return
79
+
80
+ const resolve = finishOpen
81
+ finishOpen = null
82
+ failOpen = null
83
+ clearCloseTimer()
84
+ resolve?.()
85
+ listener?.(event.data)
86
+ }
87
+
88
+ return {
89
+ prepare(): void {
90
+ if (popup && !popup.closed) return
91
+ popup = host.open(popupUrl.toString(), options.popupName ?? "coordination-user-app", options.popupFeatures)
92
+ },
93
+
94
+ close(): void {
95
+ popup?.close()
96
+ popup = null
97
+ const fail = failOpen
98
+ finishOpen = null
99
+ failOpen = null
100
+ clearCloseTimer()
101
+ fail?.(new Error("user app popup closed before responding"))
102
+ },
103
+
104
+ onUrl(nextListener): () => void {
105
+ listener = nextListener
106
+ host.addEventListener("message", handleMessage)
107
+ return () => {
108
+ host.removeEventListener("message", handleMessage)
109
+ listener = null
110
+ clearCloseTimer()
111
+ finishOpen?.()
112
+ finishOpen = null
113
+ failOpen = null
114
+ }
115
+ },
116
+
117
+ openUrl(requestUrl): Promise<void> {
118
+ const queryIndex = requestUrl.indexOf("?")
119
+ if (queryIndex === -1) {
120
+ return Promise.reject(new Error("browser link request is missing its encoded query"))
121
+ }
122
+
123
+ const destination = new URL(popupUrl.toString())
124
+ destination.search = requestUrl.slice(queryIndex)
125
+ if (popup && !popup.closed) {
126
+ popup.location.href = destination.toString()
127
+ } else {
128
+ popup = host.open(
129
+ destination.toString(),
130
+ options.popupName ?? "coordination-user-app",
131
+ options.popupFeatures
132
+ )
133
+ }
134
+ if (!popup) {
135
+ return Promise.reject(new Error("user app popup was blocked"))
136
+ }
137
+ closedAt = null
138
+
139
+ return new Promise<void>((resolve, reject) => {
140
+ finishOpen = resolve
141
+ failOpen = reject
142
+ closeTimer = setInterval(() => {
143
+ if (!popup?.closed) return
144
+
145
+ closedAt ??= Date.now()
146
+ if (Date.now() - closedAt < (options.closeGracePeriodMs ?? 250)) return
147
+
148
+ const fail = failOpen
149
+ finishOpen = null
150
+ failOpen = null
151
+ clearCloseTimer()
152
+ fail?.(new Error("user app popup closed before responding"))
153
+ }, options.closePollIntervalMs ?? DEFAULT_CLOSE_POLL_INTERVAL_MS)
154
+ })
155
+ }
156
+ }
157
+ }
158
+
159
+ export interface WebLinkResponderOptions {
160
+ handler: RequestHandler
161
+ /** Member-app origins allowed to open the user app and receive a response. */
162
+ allowedMemberOrigins: readonly string[]
163
+ /** Maximum time to wait for the opener handshake. Default 5000ms. */
164
+ handshakeTimeoutMs?: number
165
+ /** Close the user-app popup after posting. Default true. */
166
+ closeAfterResponding?: boolean
167
+ /** Injectable for tests; defaults to the current browser window. */
168
+ window?: Window
169
+ }
170
+
171
+ function authenticateMemberApp(
172
+ host: Window,
173
+ recipient: Window,
174
+ allowedMemberOrigins: ReadonlySet<string>,
175
+ timeoutMs: number
176
+ ): Promise<string> {
177
+ return new Promise((resolve, reject) => {
178
+ const cleanup = () => {
179
+ clearTimeout(timer)
180
+ host.removeEventListener("message", handleMessage)
181
+ }
182
+ const handleMessage = (event: MessageEvent) => {
183
+ if (event.source !== recipient || event.data !== HELLO_MESSAGE || !allowedMemberOrigins.has(event.origin))
184
+ return
185
+ cleanup()
186
+ resolve(event.origin)
187
+ }
188
+ const timer = setTimeout(() => {
189
+ cleanup()
190
+ reject(new Error("member app origin handshake timed out"))
191
+ }, timeoutMs)
192
+
193
+ host.addEventListener("message", handleMessage)
194
+ recipient.postMessage(READY_MESSAGE, "*")
195
+ })
196
+ }
197
+
198
+ /** Authenticates the opener, dispatches the request, and posts the response to its verified origin. */
199
+ export async function respondToWebLinkRequest(options: WebLinkResponderOptions): Promise<void> {
200
+ const host = options.window ?? window
201
+ const recipient = host.opener ?? (host.parent !== host ? host.parent : null)
202
+ if (!recipient) throw new Error("user app was not opened or embedded by a member app")
203
+ try {
204
+ const allowedMemberOrigins = new Set(
205
+ options.allowedMemberOrigins.map((origin) => normalizedOrigin(origin, "allowedMemberOrigins"))
206
+ )
207
+ if (allowedMemberOrigins.size === 0) throw new Error("allowedMemberOrigins must not be empty")
208
+ const memberAppOrigin = await authenticateMemberApp(
209
+ host,
210
+ recipient,
211
+ allowedMemberOrigins,
212
+ options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS
213
+ )
214
+ const request = decodeRequest(host.location.href)
215
+ if (normalizedOrigin(request.caller.scheme, "caller.scheme") !== memberAppOrigin) {
216
+ throw new Error("request caller does not match the authenticated member app origin")
217
+ }
218
+
219
+ const response = await options.handler.handle(host.location.href)
220
+ recipient.postMessage(response, memberAppOrigin)
221
+ // Give the queued postMessage a task to reach the opener before closing the popup.
222
+ await new Promise((resolve) => setTimeout(resolve, 0))
223
+ } finally {
224
+ if (host.opener && (options.closeAfterResponding ?? true)) host.close()
225
+ }
226
+ }
227
+
228
+ export interface EmbeddedWebLinkTransportOptions {
229
+ userAppUrl: string
230
+ userAppOrigin: string
231
+ frame: HTMLIFrameElement
232
+ window?: Window
233
+ }
234
+
235
+ /** Carries the browser protocol through an in-page iframe, avoiding popup-blocker behavior. */
236
+ export function createEmbeddedWebLinkTransport(options: EmbeddedWebLinkTransportOptions): WebLinkTransport {
237
+ const host = options.window ?? window
238
+ const userAppOrigin = normalizedOrigin(options.userAppOrigin, "userAppOrigin")
239
+ const userAppUrl = new URL(options.userAppUrl)
240
+ if (userAppUrl.origin !== userAppOrigin) throw new Error("userAppUrl must belong to userAppOrigin")
241
+ let listener: ((url: string) => void) | null = null
242
+ const handleMessage = (event: MessageEvent) => {
243
+ if (event.origin !== userAppOrigin || event.source !== options.frame.contentWindow) return
244
+ if (event.data === READY_MESSAGE) {
245
+ options.frame.contentWindow?.postMessage(HELLO_MESSAGE, userAppOrigin)
246
+ return
247
+ }
248
+ if (typeof event.data !== "string") return
249
+ listener?.(event.data)
250
+ }
251
+ return {
252
+ prepare() {},
253
+ close() {
254
+ options.frame.src = "about:blank"
255
+ },
256
+ onUrl(nextListener) {
257
+ listener = nextListener
258
+ host.addEventListener("message", handleMessage)
259
+ return () => {
260
+ listener = null
261
+ host.removeEventListener("message", handleMessage)
262
+ }
263
+ },
264
+ async openUrl(requestUrl) {
265
+ const queryIndex = requestUrl.indexOf("?")
266
+ if (queryIndex === -1) throw new Error("browser link request is missing its encoded query")
267
+ const destination = new URL(userAppUrl)
268
+ destination.search = requestUrl.slice(queryIndex)
269
+ options.frame.src = destination.toString()
270
+ }
271
+ }
272
+ }
273
+
274
+ export interface RelayWebLinkTransportOptions {
275
+ relayUrl: string
276
+ sender: string
277
+ pollIntervalMs?: number
278
+ fetcher?: typeof fetch
279
+ }
280
+
281
+ /** Carries unchanged link URLs through the local demo relay between independently opened pages. */
282
+ export function createRelayWebLinkTransport(
283
+ options: RelayWebLinkTransportOptions
284
+ ): Pick<LinkClientOptions, "openUrl" | "onUrl"> {
285
+ const fetcher = options.fetcher ?? fetch
286
+ const relayUrl = new URL(options.relayUrl).origin
287
+ let after = 0
288
+ return {
289
+ async openUrl(url) {
290
+ const response = await fetcher(`${relayUrl}/v1/requests`, {
291
+ method: "POST",
292
+ headers: { "content-type": "application/json" },
293
+ body: JSON.stringify({ sender: options.sender, url })
294
+ })
295
+ if (!response.ok) throw new Error("wallet request could not be delivered")
296
+ },
297
+ onUrl(listener) {
298
+ let active = true
299
+ let polling = false
300
+ const poll = async () => {
301
+ if (!active || polling) return
302
+ polling = true
303
+ try {
304
+ const response = await fetcher(
305
+ `${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`
306
+ )
307
+ if (!response.ok) return
308
+ const messages = (await response.json()) as Array<{ seq: number; url: string }>
309
+ for (const message of messages) {
310
+ after = Math.max(after, message.seq)
311
+ listener(message.url)
312
+ }
313
+ } catch {
314
+ // The next poll retries while the local demo stack starts or restarts.
315
+ } finally {
316
+ polling = false
317
+ }
318
+ }
319
+ void poll()
320
+ const timer = setInterval(() => void poll(), options.pollIntervalMs ?? 500)
321
+ return () => {
322
+ active = false
323
+ clearInterval(timer)
324
+ }
325
+ }
326
+ }
327
+ }