@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/client.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { decodeResponse, encodeRequest } from "./codec"
2
+ import { LinkError } from "./errors"
3
+ import { resultSchemas } from "./schemas"
4
+ import type { Caller, LinkOp, LinkRequest, Params, Result } from "./types"
5
+
6
+ export interface LinkClientOptions {
7
+ caller: Caller
8
+ /** URL the user app opens with the response; must route back into this member app. */
9
+ callback: string
10
+ openUrl: (url: string) => Promise<void>
11
+ /** Subscribes to incoming URLs (e.g. `Linking.addEventListener`); returns an unsubscribe function. */
12
+ onUrl: (listener: (url: string) => void) => () => void
13
+ /** Milliseconds before a request is abandoned. Default 120000. */
14
+ timeoutMs?: number
15
+ /** Overrides the request `id` generator; tests pass a deterministic one. */
16
+ randomId?: () => string
17
+ scheme?: string
18
+ }
19
+
20
+ export interface LinkClient {
21
+ request<Op extends LinkOp>(op: Op, params: Params<Op>): Promise<Result<Op>>
22
+ }
23
+
24
+ const DEFAULT_TIMEOUT_MS = 120_000
25
+
26
+ function defaultRandomId(): string {
27
+ const cryptoObj = globalThis.crypto
28
+ if (typeof cryptoObj?.randomUUID === "function") {
29
+ return cryptoObj.randomUUID()
30
+ }
31
+
32
+ const bytes = new Uint8Array(16)
33
+ cryptoObj.getRandomValues(bytes)
34
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
35
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
36
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
37
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
38
+ }
39
+
40
+ interface PendingRequest {
41
+ id: string
42
+ op: LinkOp
43
+ resolve: (result: unknown) => void
44
+ reject: (error: unknown) => void
45
+ unsubscribe: () => void
46
+ timer: ReturnType<typeof setTimeout>
47
+ }
48
+
49
+ /** One member-app-side client per callback URL: exactly one request in flight, matched by id. */
50
+ export function createLinkClient(options: LinkClientOptions): LinkClient {
51
+ const randomId = options.randomId ?? defaultRandomId
52
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
53
+ let pending: PendingRequest | null = null
54
+
55
+ // Handles a stray/duplicate/unrelated URL (pending already cleared, or some other deep link)
56
+ // by returning early; every other exit clears `pending` itself rather than sharing a "settle"
57
+ // helper, since by the time those exits run they already hold their own non-null references.
58
+ function handleUrl(url: string): void {
59
+ if (!pending) {
60
+ return
61
+ }
62
+
63
+ let response
64
+ try {
65
+ response = decodeResponse(url)
66
+ } catch {
67
+ return // Not a response this client understands - some other deep link, ignore it.
68
+ }
69
+
70
+ if (response.id !== pending.id) {
71
+ return // A response to a different (already-settled or stale) request.
72
+ }
73
+
74
+ const { op, resolve, reject, timer, unsubscribe } = pending
75
+ pending = null
76
+ clearTimeout(timer)
77
+ unsubscribe()
78
+
79
+ if (response.ok) {
80
+ // Mirrors handler.ts's own check on the responder side: a result that decoded fine but
81
+ // has the wrong shape for its op is a responder bug, not a transport glitch.
82
+ const parsedResult = resultSchemas[op].safeParse(response.result)
83
+ if (parsedResult.success) {
84
+ resolve(parsedResult.data)
85
+ } else {
86
+ reject(new LinkError("INTERNAL", `response result failed its schema: ${parsedResult.error.message}`))
87
+ }
88
+ } else {
89
+ reject(new LinkError(response.code, response.message))
90
+ }
91
+ }
92
+
93
+ return {
94
+ request<Op extends LinkOp>(op: Op, params: Params<Op>): Promise<Result<Op>> {
95
+ if (pending) {
96
+ return Promise.reject(new Error("createLinkClient: a request is already in flight"))
97
+ }
98
+
99
+ return new Promise<Result<Op>>((resolve, reject) => {
100
+ // randomId/encodeRequest can throw (a caller-supplied randomId, a callback failing
101
+ // its own schema, an oversized payload); thrown here, before resolve/reject has
102
+ // been called, it rejects this promise automatically instead of escaping request()
103
+ // as a synchronous throw.
104
+ const id = randomId()
105
+ // op and params are correlated by this call's own Op, but a deferred conditional
106
+ // type like LinkRequest<Op> doesn't resolve for an unsubstituted generic - assert
107
+ // it here.
108
+ const request = {
109
+ id,
110
+ version: "v1",
111
+ caller: options.caller,
112
+ callback: options.callback,
113
+ op,
114
+ params
115
+ } as LinkRequest<Op>
116
+ const url = encodeRequest(request, { scheme: options.scheme })
117
+
118
+ const unsubscribe = options.onUrl(handleUrl)
119
+ const timer = setTimeout(() => {
120
+ pending = null
121
+ unsubscribe()
122
+ reject(new Error(`createLinkClient: request timed out after ${timeoutMs}ms`))
123
+ }, timeoutMs)
124
+
125
+ // resolve only ever receives this call's own response, matched by id in handleUrl above;
126
+ // the cast to `(result: unknown) => void` just erases that per-call Op for storage below.
127
+ pending = { id, op, resolve: resolve as (result: unknown) => void, reject, unsubscribe, timer }
128
+
129
+ options.openUrl(url).catch((error: unknown) => {
130
+ pending = null
131
+ clearTimeout(timer)
132
+ unsubscribe()
133
+ reject(error)
134
+ })
135
+ })
136
+ }
137
+ }
138
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { LinkDecodeError, LinkError, LinkPayloadTooLargeError } from "./errors"
2
+ import {
3
+ callbackSchema,
4
+ callerSchema,
5
+ DENIED_CALLBACK_SCHEMES,
6
+ linkRequestEnvelopeSchema,
7
+ linkResponseSchema
8
+ } from "./schemas"
9
+ import type { DecodedLinkRequest, LinkOp, LinkRequest, LinkResponse } from "./types"
10
+
11
+ export const USER_APP_SCHEME = "userapp"
12
+
13
+ /** Above this, a platform's `Linking.openURL` (and some OSes) start truncating or refusing the URL. */
14
+ export const MAX_URL_LENGTH = 2000
15
+
16
+ /** Encodes bytes with the base64url alphabet (RFC 4648 §5), unpadded. No Node `Buffer`: `atob`/`btoa` only. */
17
+ export function base64UrlEncode(bytes: Uint8Array): string {
18
+ let binary = ""
19
+ for (const byte of bytes) {
20
+ binary += String.fromCharCode(byte)
21
+ }
22
+ return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
23
+ }
24
+
25
+ export function base64UrlDecode(value: string): Uint8Array {
26
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/")
27
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=")
28
+ const binary = globalThis.atob(padded)
29
+ const bytes = new Uint8Array(binary.length)
30
+ for (let i = 0; i < binary.length; i += 1) {
31
+ bytes[i] = binary.charCodeAt(i)
32
+ }
33
+ return bytes
34
+ }
35
+
36
+ function encodePayload(value: unknown): string {
37
+ return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)))
38
+ }
39
+
40
+ function decodePayload(payload: string): unknown {
41
+ try {
42
+ return JSON.parse(new TextDecoder().decode(base64UrlDecode(payload)))
43
+ } catch {
44
+ throw new LinkDecodeError("payload is not valid base64url-encoded JSON")
45
+ }
46
+ }
47
+
48
+ function checkUrlLength(url: string): void {
49
+ if (url.length > MAX_URL_LENGTH) {
50
+ throw new LinkPayloadTooLargeError(`link URL is ${url.length} characters, over the ${MAX_URL_LENGTH} limit`)
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Reads a query parameter by hand rather than via the `URL`/`URLSearchParams` globals, which
56
+ * Hermes (React Native's JS engine) doesn't provide without a polyfill.
57
+ */
58
+ function getQueryParam(url: string, key: string): string | null {
59
+ const queryIndex = url.indexOf("?")
60
+ if (queryIndex === -1) {
61
+ return null
62
+ }
63
+ for (const pair of url.slice(queryIndex + 1).split("&")) {
64
+ const [rawKey, rawValue = ""] = pair.split("=")
65
+ if (decodeURIComponent(rawKey) === key) {
66
+ return decodeURIComponent(rawValue)
67
+ }
68
+ }
69
+ return null
70
+ }
71
+
72
+ export function encodeRequest<Op extends LinkOp>(request: LinkRequest<Op>, options: { scheme?: string } = {}): string {
73
+ const scheme = options.scheme ?? USER_APP_SCHEME
74
+ // The request URL is built as `${scheme}://...`, so a `?`/`#` in scheme would corrupt it the
75
+ // same way a bad callback corrupts the response URL below.
76
+ if (scheme.includes("?") || scheme.includes("#")) {
77
+ throw new LinkError("INVALID_REQUEST", "scheme must not contain a query string or fragment")
78
+ }
79
+ if ((DENIED_CALLBACK_SCHEMES as readonly string[]).includes(scheme.toLowerCase())) {
80
+ throw new LinkError("INVALID_REQUEST", `scheme must not be one of: ${DENIED_CALLBACK_SCHEMES.join(", ")}`)
81
+ }
82
+ const parsedCaller = callerSchema.safeParse(request.caller)
83
+ if (!parsedCaller.success) {
84
+ // Otherwise a caller decodeRequest's own callerSchema would refuse (too long a name, say)
85
+ // gets a URL the user app can't decode, and the member app just waits out its own timeout.
86
+ throw new LinkError("INVALID_REQUEST", `caller ${parsedCaller.error.issues[0].message}`)
87
+ }
88
+ const parsedCallback = callbackSchema.safeParse(request.callback)
89
+ if (!parsedCallback.success) {
90
+ // Surfaces callbackSchema's own issue message so a denied scheme and a query
91
+ // string/fragment report distinct reasons instead of one hardcoded message for both.
92
+ throw new LinkError("INVALID_REQUEST", `callback ${parsedCallback.error.issues[0].message}`)
93
+ }
94
+
95
+ const url = `${scheme}://v1/request?payload=${encodePayload(request)}`
96
+ checkUrlLength(url)
97
+ return url
98
+ }
99
+
100
+ export function decodeRequest(url: string): DecodedLinkRequest {
101
+ const payload = getQueryParam(url, "payload")
102
+ if (payload === null) {
103
+ throw new LinkDecodeError("link URL is missing the payload query parameter")
104
+ }
105
+
106
+ const parsed = linkRequestEnvelopeSchema.safeParse(decodePayload(payload))
107
+ if (!parsed.success) {
108
+ throw new LinkDecodeError(`malformed request payload: ${parsed.error.message}`)
109
+ }
110
+
111
+ return parsed.data
112
+ }
113
+
114
+ export function encodeResponse<Op extends LinkOp>(callback: string, response: LinkResponse<Op>): string {
115
+ const url = `${callback}?payload=${encodePayload(response)}`
116
+ checkUrlLength(url)
117
+ return url
118
+ }
119
+
120
+ export function decodeResponse(url: string): LinkResponse {
121
+ const payload = getQueryParam(url, "payload")
122
+ if (payload === null) {
123
+ throw new LinkDecodeError("link URL is missing the payload query parameter")
124
+ }
125
+
126
+ const parsed = linkResponseSchema.safeParse(decodePayload(payload))
127
+ if (!parsed.success) {
128
+ throw new LinkDecodeError(`malformed response payload: ${parsed.error.message}`)
129
+ }
130
+
131
+ // The envelope carries no `op`, so the schema can only prove `result` is present, not which
132
+ // op's shape it has - the caller (client.ts) knows that from the id it matches the response to.
133
+ return parsed.data as LinkResponse
134
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { LinkErrorCode } from "./types"
2
+
3
+ /** A link-protocol failure, discriminated by the same `code` a wire `{ ok: false }` response carries. */
4
+ export class LinkError extends Error {
5
+ readonly code: LinkErrorCode
6
+
7
+ constructor(code: LinkErrorCode, message?: string) {
8
+ super(message ?? code)
9
+ this.name = "LinkError"
10
+ this.code = code
11
+ }
12
+ }
13
+
14
+ /** Thrown by encodeRequest/encodeResponse when the built URL exceeds MAX_URL_LENGTH. */
15
+ export class LinkPayloadTooLargeError extends LinkError {
16
+ constructor(message: string) {
17
+ super("INVALID_REQUEST", message)
18
+ this.name = "LinkPayloadTooLargeError"
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Thrown by decodeRequest/decodeResponse when a URL can't be decoded into a well-formed v1
24
+ * envelope - the only failure this package ever throws rather than turning into an `ok: false`
25
+ * response, because without a valid `id`/`callback` there is nowhere to send that response.
26
+ */
27
+ export class LinkDecodeError extends LinkError {
28
+ constructor(message: string) {
29
+ super("INVALID_REQUEST", message)
30
+ this.name = "LinkDecodeError"
31
+ }
32
+ }
package/src/handler.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { decodeRequest, encodeResponse } from "./codec"
2
+ import { LinkError } from "./errors"
3
+ import { paramsSchemas, resultSchemas } from "./schemas"
4
+ import {
5
+ LINK_OPS,
6
+ type Caller,
7
+ type DecodedLinkRequest,
8
+ type LinkErrorCode,
9
+ type LinkHandlers,
10
+ type LinkOp,
11
+ type LinkResponse
12
+ } from "./types"
13
+
14
+ export interface RequestHandlerOptions {
15
+ handlers: LinkHandlers
16
+ /** Shows the user what's being requested and by whom; resolves to whether they approved it. */
17
+ consent: (request: DecodedLinkRequest) => Promise<boolean>
18
+ }
19
+
20
+ export interface RequestHandler {
21
+ /** Decodes and dispatches `url`, returning the response URL to open. Never rejects except on {@link LinkDecodeError}. */
22
+ handle(url: string): Promise<string>
23
+ }
24
+
25
+ function isLinkOp(op: string): op is LinkOp {
26
+ return (LINK_OPS as readonly string[]).includes(op)
27
+ }
28
+
29
+ /** A zod error can name every unrecognized key in a params object, easily blowing the response URL's budget. */
30
+ const MAX_RESPONSE_MESSAGE_LENGTH = 256
31
+
32
+ /**
33
+ * Dispatch is inherently dynamic (the op comes from the decoded URL, not a type parameter), so the
34
+ * per-op correlation `LinkHandlers` gives callers has to be widened away here rather than threaded through.
35
+ */
36
+ type AnyLinkHandler = (params: unknown, context: { caller: Caller }) => Promise<unknown>
37
+
38
+ export function createRequestHandler(options: RequestHandlerOptions): RequestHandler {
39
+ const { handlers, consent } = options
40
+
41
+ function respond(request: DecodedLinkRequest, code: LinkErrorCode, message?: string): string {
42
+ const cappedMessage = message?.slice(0, MAX_RESPONSE_MESSAGE_LENGTH)
43
+ return encodeResponse(request.callback, {
44
+ id: request.id,
45
+ version: "v1",
46
+ ok: false,
47
+ code,
48
+ message: cappedMessage
49
+ })
50
+ }
51
+
52
+ return {
53
+ async handle(url: string): Promise<string> {
54
+ const request = decodeRequest(url)
55
+
56
+ if (!isLinkOp(request.op)) {
57
+ return respond(request, "UNSUPPORTED_OP")
58
+ }
59
+
60
+ const handler = handlers[request.op] as AnyLinkHandler | undefined
61
+ if (!handler) {
62
+ return respond(request, "UNSUPPORTED_OP")
63
+ }
64
+
65
+ const parsedParams = paramsSchemas[request.op].safeParse(request.params)
66
+ if (!parsedParams.success) {
67
+ return respond(request, "INVALID_REQUEST", parsedParams.error.message)
68
+ }
69
+
70
+ if (!(await consent(request))) {
71
+ return respond(request, "USER_DENIED")
72
+ }
73
+
74
+ try {
75
+ const result = await handler(parsedParams.data, { caller: request.caller })
76
+
77
+ // A handler's return type isn't checked against Result<Op> at compile time
78
+ // (AnyLinkHandler erased that), so a wrong shape here is a bug in this app's own
79
+ // handler, not the caller's fault - report it as INTERNAL, not INVALID_REQUEST.
80
+ const parsedResult = resultSchemas[request.op].safeParse(result)
81
+ if (!parsedResult.success) {
82
+ return respond(request, "INTERNAL")
83
+ }
84
+
85
+ const response = { id: request.id, version: "v1", ok: true, result: parsedResult.data } as LinkResponse
86
+ return encodeResponse(request.callback, response)
87
+ } catch (error) {
88
+ if (error instanceof LinkError) {
89
+ return respond(request, error.code, error.message)
90
+ }
91
+ return respond(request, "INTERNAL")
92
+ }
93
+ }
94
+ }
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./types"
2
+ export * from "./schemas"
3
+ export * from "./errors"
4
+ export * from "./codec"
5
+ export * from "./client"
6
+ export * from "./handler"
package/src/schemas.ts ADDED
@@ -0,0 +1,261 @@
1
+ import { z } from "zod"
2
+ import { LINK_ERROR_CODES, type Hex20, type Hex31, type Hex32, type Hex65, type Hex66, type LinkOp } from "./types"
3
+
4
+ /**
5
+ * A response must stay on-device, in a custom app scheme: `http(s)` would let it leave the device
6
+ * entirely, and Android additionally resolves `intent`/`file`/`content`/`javascript`/`data` to
7
+ * behavior beyond opening another app (launching an arbitrary component, reading a local file, or
8
+ * running script), none of which this protocol's `callback` is meant to reach.
9
+ *
10
+ * The browser schemes map straight onto `http(s)://host/path` - `googlechrome://evil/cb` fetches
11
+ * `http://evil/cb` - so they defeat the rule exactly as `https` would. This list cannot be
12
+ * exhaustive: any installed app may register a scheme that proxies to the network, and nothing in
13
+ * the string says so. Closing it properly needs the signed response envelope and a consent screen
14
+ * showing the resolved callback, both already tracked as follow-ups.
15
+ */
16
+ export const DENIED_CALLBACK_SCHEMES = [
17
+ "http",
18
+ "https",
19
+ "intent",
20
+ "file",
21
+ "content",
22
+ "javascript",
23
+ "data",
24
+ "googlechrome",
25
+ "googlechromes",
26
+ "x-safari-http",
27
+ "x-safari-https",
28
+ "microsoft-edge-http",
29
+ "microsoft-edge-https",
30
+ "firefox",
31
+ "ftp",
32
+ "ws",
33
+ "wss"
34
+ ] as const
35
+
36
+ /**
37
+ * These fields name server endpoints the app itself fetches or posts to, arriving from an
38
+ * unauthenticated deep-link caller - an allowlist has no unknown-scheme bypass to worry about.
39
+ * TODO: production should require https only; that belongs in configuration, not this schema.
40
+ */
41
+ export const ALLOWED_ENDPOINT_URL_SCHEMES = ["http", "https"] as const
42
+
43
+ /** `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. */
44
+ export const callbackSchema = z
45
+ .string()
46
+ .regex(/^[a-z][a-z0-9+.-]*:\/\/[^\s?#]+$/i, "must be an absolute URL with no query string or fragment")
47
+ .refine(
48
+ (value) =>
49
+ !(DENIED_CALLBACK_SCHEMES as readonly string[]).includes(value.slice(0, value.indexOf(":")).toLowerCase()),
50
+ {
51
+ message: `scheme must not be one of: ${DENIED_CALLBACK_SCHEMES.join(", ")}`
52
+ }
53
+ )
54
+
55
+ /** No `z.string().url()`: it relies on the global `URL` class, which Hermes/React Native lack without a polyfill. */
56
+ const urlLikeSchema = z
57
+ .string()
58
+ .regex(/^[a-z][a-z0-9+.-]*:\/\/\S+$/i, "must be an absolute URL")
59
+ .refine(
60
+ (value) =>
61
+ (ALLOWED_ENDPOINT_URL_SCHEMES as readonly string[]).includes(
62
+ value.slice(0, value.indexOf(":")).toLowerCase()
63
+ ),
64
+ {
65
+ message: `scheme must be one of: ${ALLOWED_ENDPOINT_URL_SCHEMES.join(", ")}`
66
+ }
67
+ )
68
+
69
+ const hexOfLength = <T extends string>(byteLength: number) =>
70
+ z
71
+ .string()
72
+ .regex(new RegExp(`^0x[0-9a-fA-F]{${byteLength * 2}}$`), `must be ${byteLength}-byte 0x-prefixed hex`)
73
+ .transform((value) => value.toLowerCase() as T)
74
+
75
+ export const hex20Schema = hexOfLength<Hex20>(20)
76
+ export const hex31Schema = hexOfLength<Hex31>(31)
77
+ export const hex32Schema = hexOfLength<Hex32>(32)
78
+ export const hex65Schema = hexOfLength<Hex65>(65)
79
+ export const hex66Schema = hexOfLength<Hex66>(66)
80
+
81
+ export const securityLevelSchema = z.enum(["STRONGBOX", "TRUSTED_ENVIRONMENT", "SOFTWARE", "UNKNOWN"])
82
+
83
+ /**
84
+ * `name` and `programme` are both rendered verbatim, on their own line and interpolated into a
85
+ * caption sentence the wallet wrote, so an unbounded or unclean value is a caller reshaping
86
+ * content it doesn't author: past the limit its text crowds the wallet's own closing sentence out
87
+ * of the three-line clip, and a control, formatting, or line/paragraph-separator character can
88
+ * hide text or relayout the line, as a bidi override does.
89
+ */
90
+ const sentenceSafeTextSchema = (maxLength: number) =>
91
+ z
92
+ .string()
93
+ .min(1)
94
+ .max(maxLength)
95
+ .regex(/^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]*$/u, "must not contain control, formatting or line-break characters")
96
+
97
+ export const callerSchema = z
98
+ .object({
99
+ name: sentenceSafeTextSchema(64),
100
+ scheme: z.string().min(1)
101
+ })
102
+ .strict()
103
+
104
+ export const approvalScopeSchema = z
105
+ .object({ programme: sentenceSafeTextSchema(64), country: z.string().regex(/^[A-Z]{3}$/, "ISO 3166-1 alpha-3") })
106
+ .strict()
107
+
108
+ export const registrationSignParamsSchema = z
109
+ .object({ chainId: z.number().int().positive(), verifyingContract: hex20Schema })
110
+ .strict()
111
+
112
+ export 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
+
124
+ export const registrationSignResultSchema = z
125
+ .object({ request: signedRegistrationRequestSchema, securityLevel: securityLevelSchema })
126
+ .strict()
127
+
128
+ /** Scheme, host and port, as `urlLikeSchema` guarantees them: everything before the path. No
129
+ * `new URL` here either, for the same Hermes reason. */
130
+ function originOf(url: string): string {
131
+ const pathStart = url.indexOf("/", url.indexOf("://") + 3)
132
+ return (pathStart === -1 ? url : url.slice(0, pathStart)).toLowerCase()
133
+ }
134
+
135
+ /** The four URLs are one server's, so they must say so. The wallet stores `revocationsUrl` for the
136
+ * credential's life and shows it once, at issuance, as the issuer's server: split origins would
137
+ * let a caller drive a genuine issuance while pointing every later revocation read at itself. */
138
+ const sameOriginEndpoints = ["credentialsUrl", "issuersUrl", "revocationsUrl"] as const
139
+
140
+ export const credentialIssueParamsSchema = z
141
+ .object({
142
+ issuerNetworkId: hex32Schema,
143
+ approvalScope: approvalScopeSchema,
144
+ challengeUrl: urlLikeSchema,
145
+ credentialsUrl: urlLikeSchema,
146
+ issuersUrl: urlLikeSchema,
147
+ revocationsUrl: urlLikeSchema
148
+ })
149
+ .strict()
150
+ .superRefine((params, ctx) => {
151
+ const origin = originOf(params.challengeUrl)
152
+ for (const field of sameOriginEndpoints) {
153
+ if (originOf(params[field]) !== origin) {
154
+ ctx.addIssue({
155
+ code: "custom",
156
+ path: [field],
157
+ message: `${field} must be on the same origin as challengeUrl (${origin})`
158
+ })
159
+ }
160
+ }
161
+ })
162
+
163
+ export const credentialIssueResultSchema = z.discriminatedUnion("status", [
164
+ z.object({ status: z.literal("stored"), credentialId: z.string().min(1) }).strict(),
165
+ z.object({ status: z.literal("alreadyHeld"), credentialId: z.string().min(1) }).strict(),
166
+ z.object({ status: z.literal("refused"), code: z.string().min(1), message: z.string() }).strict()
167
+ ])
168
+
169
+ export const presentationCreateParamsSchema = z
170
+ .object({
171
+ requestId: z.string().min(1).max(128),
172
+ challenge: hex31Schema,
173
+ audience: z.string().min(1),
174
+ currentTime: z.number().int().nonnegative(),
175
+ programme: sentenceSafeTextSchema(64),
176
+ country: z.string().regex(/^[A-Z]{3}$/, "ISO 3166-1 alpha-3"),
177
+ issuersUrl: urlLikeSchema,
178
+ revocationsUrl: urlLikeSchema,
179
+ responseUrl: urlLikeSchema
180
+ })
181
+ .strict()
182
+ .superRefine((params, ctx) => {
183
+ // The verifier serves all three, and it is the party that must accept the roots the proof
184
+ // carries. A split origin would let it send the wallet to read somebody else's tree.
185
+ const origin = originOf(params.responseUrl)
186
+ for (const field of ["issuersUrl", "revocationsUrl"] as const) {
187
+ if (originOf(params[field]) !== origin) {
188
+ ctx.addIssue({
189
+ code: "custom",
190
+ path: [field],
191
+ message: `${field} must be on the same origin as responseUrl (${origin})`
192
+ })
193
+ }
194
+ }
195
+ })
196
+
197
+ export const presentationCreateResultSchema = z.object({ verificationId: z.string().min(1) }).strict()
198
+
199
+ /** Looked up by the decoded `op` string, so the same schema can be resolved dynamically at runtime. */
200
+ export const paramsSchemas: Record<LinkOp, z.ZodTypeAny> = {
201
+ "registration.sign": registrationSignParamsSchema,
202
+ "credential.issue": credentialIssueParamsSchema,
203
+ "presentation.create": presentationCreateParamsSchema
204
+ }
205
+
206
+ export const resultSchemas: Record<LinkOp, z.ZodTypeAny> = {
207
+ "registration.sign": registrationSignResultSchema,
208
+ "credential.issue": credentialIssueResultSchema,
209
+ "presentation.create": presentationCreateResultSchema
210
+ }
211
+
212
+ /** 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. */
213
+ export const linkRequestEnvelopeSchema = z
214
+ .object({
215
+ id: z.string().min(1),
216
+ version: z.literal("v1"),
217
+ caller: callerSchema,
218
+ callback: callbackSchema,
219
+ op: z.string().min(1),
220
+ params: z.unknown()
221
+ })
222
+ .strict()
223
+
224
+ const requestEnvelopeFields = {
225
+ id: z.string().min(1),
226
+ version: z.literal("v1"),
227
+ caller: callerSchema,
228
+ callback: callbackSchema
229
+ }
230
+
231
+ /** 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. */
232
+ export const linkRequestSchema = z.discriminatedUnion("op", [
233
+ z
234
+ .object({ ...requestEnvelopeFields, op: z.literal("registration.sign"), params: registrationSignParamsSchema })
235
+ .strict(),
236
+ z
237
+ .object({ ...requestEnvelopeFields, op: z.literal("credential.issue"), params: credentialIssueParamsSchema })
238
+ .strict(),
239
+ z
240
+ .object({
241
+ ...requestEnvelopeFields,
242
+ op: z.literal("presentation.create"),
243
+ params: presentationCreateParamsSchema
244
+ })
245
+ .strict()
246
+ ])
247
+
248
+ export const linkErrorCodeSchema = z.enum(LINK_ERROR_CODES)
249
+
250
+ export const linkResponseSchema = z.discriminatedUnion("ok", [
251
+ z.object({ id: z.string().min(1), version: z.literal("v1"), ok: z.literal(true), result: z.unknown() }).strict(),
252
+ z
253
+ .object({
254
+ id: z.string().min(1),
255
+ version: z.literal("v1"),
256
+ ok: z.literal(false),
257
+ code: linkErrorCodeSchema,
258
+ message: z.string().optional()
259
+ })
260
+ .strict()
261
+ ])