@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ethereum Foundation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# `@coordination-network/user-app-link`
|
|
2
|
+
|
|
3
|
+
The deep-link protocol between a **user app** (the crypto core on a phone: keys, credentials, proving) and any number of **network member apps** (business logic, no keys) on the same device. A member app opens a URL in the user app; the user app shows the person what's being asked, performs the operation, and opens the member app's callback URL with the result. Pure TypeScript, no React Native or Node-only APIs, testable in plain Node.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
yarn add @coordination-network/user-app-link
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Protocol v1
|
|
12
|
+
|
|
13
|
+
**Request.** `${userAppScheme}://v1/request?payload=<base64url(JSON)>`, where `userAppScheme` defaults to `USER_APP_SCHEME` (`"userapp"`). The JSON payload:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
{ id: string, version: "v1", caller: { name: string, scheme: string }, callback: string, op, params }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`id` identifies the request (typically a uuid); `callback` is the URL the user app opens with the response; `op`/`params` are one of the operations below.
|
|
20
|
+
|
|
21
|
+
`callback` (and `scheme`, if a caller overrides the default) must not contain a query string or fragment: `encodeResponse` always appends its own `?payload=`, so an existing `?`/`#` would bury or hide that parameter and the response would never decode — the member app's `request()` call would hang until it times out rather than resolve or reject. `encodeRequest`/`decodeRequest` refuse such a `callback` with `INVALID_REQUEST`/`LinkDecodeError`.
|
|
22
|
+
|
|
23
|
+
**Response.** `${callback}?payload=<base64url(JSON)>`, where the JSON payload is:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
{ id, version: "v1", ok: true, result } | { id, version: "v1", ok: false, code, message? }
|
|
27
|
+
```
|
|
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`.
|
|
30
|
+
|
|
31
|
+
**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
|
+
|
|
33
|
+
All hex strings are `0x`-prefixed and lowercase.
|
|
34
|
+
|
|
35
|
+
### Operations
|
|
36
|
+
|
|
37
|
+
Three ops, one per flow, each behind exactly one consent.
|
|
38
|
+
|
|
39
|
+
| Op | Params | Result |
|
|
40
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| `registration.sign` | `{ chainId: number, verifyingContract: hex20 }` | `{ request: { version: "v1", networkId: hex32, schemeId: 1, metaStealthAddress: hex66, chainId: number, verifyingContract: hex20, signature: hex65 }, securityLevel }` |
|
|
42
|
+
| `credential.issue` | `{ issuerNetworkId: hex32, approvalScope: { programme, country }, challengeUrl, credentialsUrl, issuersUrl, revocationsUrl }` | `{ status: "stored", credentialId } \| { status: "alreadyHeld", credentialId } \| { status: "refused", code, message }` |
|
|
43
|
+
| `presentation.create` | `{ requestId: string, challenge: hex31, audience: string, currentTime: number, programme: string, country: string, issuersUrl: string, responseUrl: string }` | `{ verificationId: string }` |
|
|
44
|
+
|
|
45
|
+
`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
|
+
|
|
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.
|
|
48
|
+
|
|
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.
|
|
50
|
+
|
|
51
|
+
## Using `createLinkClient` (member app side)
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import * as Linking from "expo-linking"
|
|
55
|
+
import { createLinkClient } from "@coordination-network/user-app-link"
|
|
56
|
+
|
|
57
|
+
const client = createLinkClient({
|
|
58
|
+
caller: { name: "Member App", scheme: "memberapp" },
|
|
59
|
+
callback: Linking.createURL("v1/response"),
|
|
60
|
+
openUrl: (url) => Linking.openURL(url),
|
|
61
|
+
onUrl: (listener) => {
|
|
62
|
+
const subscription = Linking.addEventListener("url", ({ url }) => listener(url))
|
|
63
|
+
return () => subscription.remove()
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const signed = await client.request("registration.sign", { chainId, verifyingContract })
|
|
68
|
+
console.log(signed.request.networkId)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`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
|
+
|
|
73
|
+
### Browser transport
|
|
74
|
+
|
|
75
|
+
Member apps choose a transport only when constructing the client; all six operations and the rest of
|
|
76
|
+
their code stay the same when moving between web and mobile:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { createLinkClient } from "@coordination-network/user-app-link"
|
|
80
|
+
import { createWebLinkTransport } from "@coordination-network/user-app-link/web"
|
|
81
|
+
|
|
82
|
+
const client = createLinkClient({
|
|
83
|
+
caller: { name: "Example Member App", scheme: "https://member.example" },
|
|
84
|
+
callback: "examplemember://v1/response",
|
|
85
|
+
...createWebLinkTransport({
|
|
86
|
+
userAppUrl: "https://user.example/link",
|
|
87
|
+
userAppOrigin: "https://user.example"
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The user web app handles the popup URL and responds to the exact member-app origin:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { respondToWebLinkRequest } from "@coordination-network/user-app-link/web"
|
|
96
|
+
|
|
97
|
+
await respondToWebLinkRequest({ handler, allowedMemberOrigins: ["https://member.example"] })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The transport copies the encoded request query into the user web app's URL. The popup authenticates
|
|
101
|
+
its opener with a `postMessage` handshake, checks that origin against `allowedMemberOrigins`, and
|
|
102
|
+
requires the request's `caller.scheme` to match it. The member app likewise validates both the user
|
|
103
|
+
app origin and popup window. The custom-scheme callback remains inside the protocol payload and is
|
|
104
|
+
never navigated to or fetched.
|
|
105
|
+
|
|
106
|
+
## Using `createRequestHandler` (user app side)
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { createRequestHandler } from "@coordination-network/user-app-link"
|
|
110
|
+
|
|
111
|
+
const handler = createRequestHandler({
|
|
112
|
+
handlers: {
|
|
113
|
+
"registration.sign": async (params, { caller }) => signRegistration(params)
|
|
114
|
+
// ...one entry per op this build supports
|
|
115
|
+
},
|
|
116
|
+
consent: async (request) => showConsentScreen(request)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// From the Linking listener that receives an incoming userapp://v1/request URL:
|
|
120
|
+
const responseUrl = await handler.handle(incomingUrl)
|
|
121
|
+
await Linking.openURL(responseUrl)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`handle` decodes the URL, validates `params` against the op's schema, asks `consent`, and dispatches to the matching entry in `handlers`. Every failure — an unsupported op, invalid params, denied consent, or a handler that throws — becomes an `ok: false` response with the right `code`, never a throw. The one exception is a URL that can't be decoded into a well-formed envelope at all (bad base64, bad JSON, missing `id`/`callback`/wrong `version`): `handle` throws `LinkDecodeError` for that, since without a usable `id`/`callback` there is nowhere to send a response.
|
|
125
|
+
|
|
126
|
+
## Security notes
|
|
127
|
+
|
|
128
|
+
- **Responses stay on-device.** `callback` must be a custom app scheme; `encodeRequest`/`decodeRequest` refuse `http`, `https`, `intent`, `file`, `content`, `javascript`, and `data` (case-insensitive) with `INVALID_REQUEST`/`LinkDecodeError` before any response is ever built, so a member app can't point a response at a URL that leaves the device or, on Android, hands off to an arbitrary component instead of opening another app.
|
|
129
|
+
- **Neither side is authenticated.** This protocol doesn't verify which app sent a request or which app is answering it: a custom URL scheme isn't exclusive to one app, so another app claiming the same scheme could intercept a request or send back a forged response. Both apps should register the OS's verified-link mechanism for their scheme (Android App Links, iOS Universal Links) so only the genuine app can claim it, and the user app should show the resolved `callback` on its consent screen so a mismatch is visible before anything is approved. A signed response envelope, authenticating the response itself, is a planned follow-up.
|
|
130
|
+
|
|
131
|
+
## What this protocol deliberately does not do
|
|
132
|
+
|
|
133
|
+
- **No consent semantics.** `consent` is a plain predicate; what it shows, how it decides, and any policy around it (rate limits, remembered approvals, revocation) live in the user app, not this package.
|
|
134
|
+
- **No proofs over links.** A ZK proof is orders of magnitude larger than `MAX_URL_LENGTH`. `presentation.create` only ever carries the inputs to build one and the verifier's yes/no answer; the proof itself goes over an ordinary HTTPS `POST` from the user app to the member server.
|
|
135
|
+
- **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
|
+
- **No responder authentication.** See "Security notes" above: this protocol alone doesn't prove `callback` belongs to the app the user thinks it does.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT, see [`LICENSE`](./LICENSE).
|