@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/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.cjs +580 -0
- package/dist/index.js +539 -0
- package/dist/web.cjs +560 -0
- package/dist/web.js +555 -0
- package/package.json +55 -0
- package/src/client.ts +138 -0
- package/src/codec.ts +134 -0
- package/src/errors.ts +32 -0
- package/src/handler.ts +95 -0
- package/src/index.ts +6 -0
- package/src/schemas.ts +261 -0
- package/src/types.ts +176 -0
- package/src/web.ts +327 -0
- package/types/index.d.ts +440 -0
- package/types/web.d.ts +81 -0
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
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
|
+
])
|