@shuttle.technology/authorization 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/README.md +1124 -2
- package/dist/accountLists.d.ts +28 -0
- package/dist/accountLists.js +27 -0
- package/dist/aesGcmSiv.d.ts +14 -0
- package/dist/aesGcmSiv.js +160 -0
- package/dist/auth.d.ts +68 -0
- package/dist/auth.js +189 -0
- package/dist/balances.d.ts +31 -0
- package/dist/balances.js +44 -0
- package/dist/canonicalize.d.ts +19 -0
- package/dist/canonicalize.js +107 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.js +781 -0
- package/dist/clientTypes.d.ts +56 -0
- package/dist/clientTypes.js +5 -0
- package/dist/configuration.d.ts +48 -0
- package/dist/configuration.js +72 -0
- package/dist/consentDebit.d.ts +36 -0
- package/dist/consentDebit.js +95 -0
- package/dist/credentials.d.ts +116 -0
- package/dist/credentials.js +315 -0
- package/dist/debitPermissionWire.d.ts +90 -0
- package/dist/debitPermissionWire.js +118 -0
- package/dist/debitPermissions.d.ts +39 -0
- package/dist/debitPermissions.js +117 -0
- package/dist/deviceKey.d.ts +13 -0
- package/dist/deviceKey.js +28 -0
- package/dist/deviceRecovery.d.ts +40 -0
- package/dist/deviceRecovery.js +143 -0
- package/dist/deviceShare.d.ts +9 -0
- package/dist/deviceShare.js +24 -0
- package/dist/deviceShareEnvelope.d.ts +13 -0
- package/dist/deviceShareEnvelope.js +139 -0
- package/dist/deviceShares.d.ts +91 -0
- package/dist/deviceShares.js +204 -0
- package/dist/errors.d.ts +280 -0
- package/dist/errors.js +85 -0
- package/dist/linkedChecks.d.ts +17 -0
- package/dist/linkedChecks.js +31 -0
- package/dist/linkedRecovery.d.ts +40 -0
- package/dist/linkedRecovery.js +49 -0
- package/dist/linkedRegistrations.d.ts +11 -0
- package/dist/linkedRegistrations.js +109 -0
- package/dist/linkedSendDisplay.d.ts +73 -0
- package/dist/linkedSendDisplay.js +84 -0
- package/dist/linkedWalletSignals.d.ts +22 -0
- package/dist/linkedWalletSignals.js +101 -0
- package/dist/linkedWallets.d.ts +401 -0
- package/dist/linkedWallets.js +436 -0
- package/dist/me.d.ts +27 -0
- package/dist/me.js +30 -0
- package/dist/mediatedOperation.d.ts +27 -0
- package/dist/mediatedOperation.js +68 -0
- package/dist/mobile.d.ts +64 -0
- package/dist/mobile.js +69 -0
- package/dist/modeC.d.ts +131 -0
- package/dist/modeC.js +248 -0
- package/dist/modeCLinked.d.ts +207 -0
- package/dist/modeCLinked.js +270 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +45 -0
- package/dist/poll.d.ts +15 -0
- package/dist/poll.js +76 -0
- package/dist/redirects.d.ts +45 -0
- package/dist/redirects.js +108 -0
- package/dist/requestGrant.d.ts +169 -0
- package/dist/requestGrant.js +243 -0
- package/dist/sends.d.ts +89 -0
- package/dist/sends.js +127 -0
- package/dist/serviceGrants.d.ts +52 -0
- package/dist/serviceGrants.js +46 -0
- package/dist/slice4.d.ts +187 -0
- package/dist/slice4.js +299 -0
- package/dist/streams.d.ts +212 -0
- package/dist/streams.js +282 -0
- package/dist/trackedWallets.d.ts +53 -0
- package/dist/trackedWallets.js +104 -0
- package/dist/transferStatus.d.ts +45 -0
- package/dist/transferStatus.js +43 -0
- package/dist/walletCreateGrant.d.ts +62 -0
- package/dist/walletCreateGrant.js +89 -0
- package/dist/walletDisplayParts.d.ts +17 -0
- package/dist/walletDisplayParts.js +47 -0
- package/dist/walletEvents.d.ts +43 -0
- package/dist/walletEvents.js +97 -0
- package/dist/walletLimits.d.ts +65 -0
- package/dist/walletLimits.js +102 -0
- package/dist/walletOperations.d.ts +209 -0
- package/dist/walletOperations.js +317 -0
- package/dist/webRenewal.d.ts +92 -0
- package/dist/webRenewal.js +179 -0
- package/package.json +65 -3
package/README.md
CHANGED
|
@@ -1,3 +1,1125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shuttle.technology/authorization
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The client of a person's session for web and mobile apps. With this one
|
|
4
|
+
package and the public configuration the operator gives you, your app runs the
|
|
5
|
+
whole user side: the person signs in on the provider's hosted page, creates a
|
|
6
|
+
wallet, connects it to your service, sets its limits, sends from it, lets your
|
|
7
|
+
backend forward the transfers it signs (Mode C) or debit the wallet without the
|
|
8
|
+
device, as a card is charged, and withdraws your service's access again.
|
|
9
|
+
|
|
10
|
+
The app never holds a service key, a Wallet credential or an identity
|
|
11
|
+
provider's credentials. Access tokens stay in memory; on the web the refresh
|
|
12
|
+
lives in the provider's HTTP-only cookie, on mobile in the secure storage the
|
|
13
|
+
app supplies. Your backend reads the wallets people connect to it with its own
|
|
14
|
+
Tenant Access key; that side is not part of this package.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install @shuttle.technology/authorization@0.2.0
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
It is ES modules with TypeScript declarations and no runtime dependencies. It
|
|
23
|
+
needs `fetch` and WebCrypto: a current browser, Node 20 or later, or a mobile
|
|
24
|
+
runtime that provides both (`examples/mobile` shows React Native).
|
|
25
|
+
|
|
26
|
+
| Import | What it holds |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `@shuttle.technology/authorization` | `initializeAuthorizationClient`, the client, errors and their table |
|
|
29
|
+
| `@shuttle.technology/authorization/device-key` | `generateDeviceKey`: the device key a wallet's share is sealed to, for an app that keeps its own share |
|
|
30
|
+
| `@shuttle.technology/authorization/device-share` | `resealDeviceShare`: the app's own share resealed for one transfer |
|
|
31
|
+
| `@shuttle.technology/authorization/mode-c-linked` | Mode C on a person's wallet: enrollment and signed requests |
|
|
32
|
+
| `@shuttle.technology/authorization/linked-wallets` | the linked-wallet reads and sends, also reachable as `client.linkedWallets()` |
|
|
33
|
+
| `@shuttle.technology/authorization/debit-permissions` | the permissions a service debits a wallet by, also reachable as `client.linkedWallets().debitPermissions()` |
|
|
34
|
+
| `@shuttle.technology/authorization/streams` | the Streams reads, also reachable as `client.streams()` |
|
|
35
|
+
| `@shuttle.technology/authorization/mode-c` | Mode C on a wallet bound in the provider |
|
|
36
|
+
|
|
37
|
+
## Configure
|
|
38
|
+
|
|
39
|
+
The operator registers your app as a public client and gives you:
|
|
40
|
+
|
|
41
|
+
- `publishableClientId`, `tenantId`, `applicationId`, `environmentId`;
|
|
42
|
+
- the exact HTTPS origins and redirects registered for it (web), or the app
|
|
43
|
+
link redirect and the app's bundle or package identifier (mobile);
|
|
44
|
+
- the sign-in methods the hosted page offers for it;
|
|
45
|
+
- the `service_principal_id` of your backend's service and the name people
|
|
46
|
+
see for it on the provider's pages;
|
|
47
|
+
- the API address, `authorizationOrigin`.
|
|
48
|
+
|
|
49
|
+
Keep the API address in your configuration, not in code. Today it is
|
|
50
|
+
`https://demo.shuttle.technology`; after the address move it becomes
|
|
51
|
+
`https://api.shuttle.technology`, and only that value changes.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { initializeAuthorizationClient } from "@shuttle.technology/authorization";
|
|
55
|
+
|
|
56
|
+
const client = await initializeAuthorizationClient({
|
|
57
|
+
mode: "mode_b",
|
|
58
|
+
channel: "web",
|
|
59
|
+
authorizationOrigin: config.apiOrigin, // "https://demo.shuttle.technology"
|
|
60
|
+
publishableClientId: config.publishableClientId,
|
|
61
|
+
tenantId: config.tenantId,
|
|
62
|
+
applicationId: config.applicationId,
|
|
63
|
+
environmentId: config.environmentId,
|
|
64
|
+
methods: ["google", "telegram", "passkey"], // exactly the client's registered methods
|
|
65
|
+
}, { currentOrigin: () => location.origin, openHosted });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
On the web, `applicationOrigin` and `redirectUri` may be left out: they are
|
|
69
|
+
then the current page's origin and the current page, so the page that starts a
|
|
70
|
+
sign-in also completes it. Give them to pin another return page. Both may be
|
|
71
|
+
https on any host or http on `localhost`, `127.0.0.1` or `[::1]`. A mobile app
|
|
72
|
+
gives `redirectUri`.
|
|
73
|
+
|
|
74
|
+
Initialization checks the configuration, then reads
|
|
75
|
+
`GET /v1/public/clients/{id}` and refuses a client whose environment, methods,
|
|
76
|
+
provider origin, redirect or platform differ (`invalid_public_configuration`).
|
|
77
|
+
It creates no session.
|
|
78
|
+
|
|
79
|
+
### The hosted window
|
|
80
|
+
|
|
81
|
+
Every step the person takes on the provider's pages (sign-in, a passkey, a
|
|
82
|
+
confirmation) goes through `openHosted(url)`. It must open a separate
|
|
83
|
+
top-level browsing context, such as a popup, never a frame, and resolve with
|
|
84
|
+
the registered redirect URL the window came back to. Your callback page hands
|
|
85
|
+
that URL to the opener on the same origin and does nothing else:
|
|
86
|
+
|
|
87
|
+
```html
|
|
88
|
+
<!-- https://app.example.com/auth/callback -->
|
|
89
|
+
<script>
|
|
90
|
+
new BroadcastChannel("auth").postMessage({ url: location.href });
|
|
91
|
+
history.replaceState(null, "", location.pathname);
|
|
92
|
+
</script>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
let popup: Window | null = null;
|
|
97
|
+
let lastCallback = "";
|
|
98
|
+
const channel = new BroadcastChannel("auth");
|
|
99
|
+
|
|
100
|
+
// Open the window inside the click handler, before anything is awaited.
|
|
101
|
+
function openWindow() { popup = window.open("about:blank", "auth", "popup,width=520,height=760"); }
|
|
102
|
+
|
|
103
|
+
function openHosted(url: string): Promise<string> {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
channel.onmessage = ({ data }) => { lastCallback = data.url; resolve(data.url); };
|
|
106
|
+
popup!.location.href = url;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`demo/app.js` is a complete host: one window for every step, a timeout, a
|
|
112
|
+
closed window, refusals the provider shows in the window, and the quiet
|
|
113
|
+
session renewal of `demo/renew.js`. Mobile apps supply a
|
|
114
|
+
`MobileBrowserBridge` instead (see "Mobile apps" below).
|
|
115
|
+
|
|
116
|
+
### Confirming an operation
|
|
117
|
+
|
|
118
|
+
Giving or withdrawing a grant, changing limits, enrolling a Mode C key and
|
|
119
|
+
giving or ending a debit permission are operations the person confirms with a passkey on the provider's page, which
|
|
120
|
+
says what the operation does and for whom. Each takes the same four calls:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import type { LocalOperation } from "@shuttle.technology/authorization";
|
|
124
|
+
|
|
125
|
+
async function confirmAndExecute(operation: LocalOperation) {
|
|
126
|
+
await client.challengeOperation(operation.id, credentialId); // opens the provider's page
|
|
127
|
+
const confirmed = await client.completeOperationConfirmation(lastCallback);
|
|
128
|
+
if (confirmed.state !== "authorized") throw new Error("not confirmed");
|
|
129
|
+
const executed = await client.executeOperation(confirmed.id, confirmed.version, crypto.randomUUID());
|
|
130
|
+
return client.operationResult(executed.id); // { result: { kind, targetId, newVersion, state } }
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`credentialId` is the person's passkey: `client.identities()` names it for an
|
|
135
|
+
identity of method `passkey`, and `completePasskeyBootstrap()` returns it for a
|
|
136
|
+
new one. The SDK confirms and executes on its own one operation only, the
|
|
137
|
+
recovery of a wallet's device share onto this device (see "The device share"
|
|
138
|
+
below), always through the provider's page and the person's passkey; it
|
|
139
|
+
never retries a write.
|
|
140
|
+
|
|
141
|
+
Every write takes an `Idempotency-Key`: make one per action the person asked
|
|
142
|
+
for and reuse it only to repeat that same request.
|
|
143
|
+
|
|
144
|
+
## The user side, step by step
|
|
145
|
+
|
|
146
|
+
### 1. Sign in
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
openWindow();
|
|
150
|
+
await client.login(); // or signup() for a new account
|
|
151
|
+
const session = await client.exchangeHandoff(lastCallback);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`login()` and `signup()` open the provider's hosted sign-in page
|
|
155
|
+
(`/v1/auth/start`), which names your app and offers its methods: Google,
|
|
156
|
+
Telegram and passkey, as registered. A first sign-in with an unknown Google or
|
|
157
|
+
Telegram identity creates the account, without a wallet; accounts are never
|
|
158
|
+
merged by email. The browser comes back to your redirect with a one-use `code`
|
|
159
|
+
and the `state` only; `exchangeHandoff` exchanges the code with the S256
|
|
160
|
+
verifier it keeps in memory and refuses any other redirect. No token is ever
|
|
161
|
+
in a URL.
|
|
162
|
+
|
|
163
|
+
On page load, `refreshSession()` takes up a session the provider's cookie
|
|
164
|
+
still holds, with no window (`POST /v1/auth/renew`, "Background renewal"
|
|
165
|
+
below); when it throws `web_renewal_requires_navigation`, the app starts
|
|
166
|
+
`restoreSession()`, the provider's top-level restore, from the person's click,
|
|
167
|
+
or signs the person in again where the restore cannot see the cookie (an app
|
|
168
|
+
on a site of its own). `demo/renew.js`, on the provider's site, runs the
|
|
169
|
+
restore quietly. The access token lives five minutes; the SDK renews it in
|
|
170
|
+
the background when it needs to. `logout()`
|
|
171
|
+
clears the session locally and asks the provider to end it: it resolves with
|
|
172
|
+
`serverDisposition` `server_revoked` when the provider ended it, or
|
|
173
|
+
`server_unconfirmed` when that could not be confirmed, in which case the
|
|
174
|
+
provider's session may live on until it expires.
|
|
175
|
+
|
|
176
|
+
`login(method)` and `signup(method)` still start one method directly
|
|
177
|
+
(`POST /v1/auth/attempts`); their redirect back also names `attempt_id`.
|
|
178
|
+
|
|
179
|
+
### 2. Who is signed in: `me()`
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const me = await client.me();
|
|
183
|
+
// me.account: { accountId, version, setupRequired }, me.sessionId, me.serverTime
|
|
184
|
+
// me.wallets: [{ walletId, kind: "wallet" | "linked_wallet", network, address, state }]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`me()` reads `GET /v1/me`, the live check of the session. `wallets` is every
|
|
188
|
+
wallet of the account: `kind` is `wallet` for a wallet the provider holds for
|
|
189
|
+
the account and `linked_wallet` for one the person created through this SDK,
|
|
190
|
+
`walletId` an identifier of that kind, `state` `active` or `disabled` (a
|
|
191
|
+
listed linked wallet is active). Own wallets come first, then linked ones, at
|
|
192
|
+
most 1000 of each; an account with more is refused with
|
|
193
|
+
`ADAPTER_INTEGRITY_CONFLICT`, never listed in part. A revoked or expired
|
|
194
|
+
session answers `UNAUTHORIZED`. The answer is read strictly: an SDK older
|
|
195
|
+
than the server's `wallets` refuses it.
|
|
196
|
+
|
|
197
|
+
Your backend learns who the user is from the access token. `accessToken()`
|
|
198
|
+
returns it, renewed first when it has less than 30 seconds to live; send it
|
|
199
|
+
to your own backend in the `Authorization` header of each call:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
const response = await fetch("https://backend.example.com/orders", {
|
|
203
|
+
headers: { Authorization: `Bearer ${await client.accessToken()}` },
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The token lives only in the SDK's memory: never store it (no
|
|
208
|
+
`localStorage`, no cookie of yours) and never put it in a URL. Your backend
|
|
209
|
+
verifies it against `/.well-known/jwks.json` on the API address (identity and
|
|
210
|
+
expiry), or calls `GET /v1/me` with it (live state). Neither is consent to any
|
|
211
|
+
wallet operation. Without a session `accessToken()` refuses with
|
|
212
|
+
`session_unavailable`; a renewal that fails refuses as `restoreSession()`
|
|
213
|
+
does. A signal passed to it ends only that caller's wait: callers share one
|
|
214
|
+
renewal, which no caller's signal stops. The same holds for the signal of
|
|
215
|
+
`exchangeHandoff()`, `logout()`, `refreshSession()` and `restoreSession()`:
|
|
216
|
+
what the call already set going (installing or clearing the credentials)
|
|
217
|
+
still completes. A web restore, which holds a window, stops only once every
|
|
218
|
+
caller waiting for it aborted; its host then closes the window, and a call
|
|
219
|
+
made after that starts afresh rather than joining it. A sign-in installs only if no newer sign-in or logout began
|
|
220
|
+
since it started. A renewal or restore installs only while the session it
|
|
221
|
+
renews is still the one installed: a sign-in that installed meanwhile, a
|
|
222
|
+
logout or a definitive refusal ends that. A renewal of the installed session
|
|
223
|
+
while a sign-in is still pending does install, and the sign-in replaces it
|
|
224
|
+
when it completes. Once the provider refuses the session for good (`UNAUTHORIZED` from
|
|
225
|
+
a renewal, or from a read the SDK already renewed the token for), the SDK
|
|
226
|
+
forgets it (unless a newer sign-in replaced that session meanwhile),
|
|
227
|
+
`hasSession` turns false and every call that needs the session,
|
|
228
|
+
`accessToken()` included, refuses with `session_unavailable` until the person
|
|
229
|
+
signs in again; a failure that passes, such as `DEPENDENCY_UNAVAILABLE`, keeps
|
|
230
|
+
the session, but a token a read was refused with is handed out no more: the
|
|
231
|
+
next call renews first, and refuses while the renewal fails. A write the
|
|
232
|
+
provider refuses `UNAUTHORIZED` ends the session it was made with the same
|
|
233
|
+
way, and is never repeated. On mobile the secure storage writes of
|
|
234
|
+
renewals, sign-ins and logouts go one after another: a write that a newer
|
|
235
|
+
sign-in or a logout overtook is taken back, and a stored token that was
|
|
236
|
+
refused or logged out but could not be deleted is never sent again.
|
|
237
|
+
|
|
238
|
+
### 3. The first wallet
|
|
239
|
+
|
|
240
|
+
A passkey is needed before the first wallet. A new account has
|
|
241
|
+
`setupRequired`:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
if (me.account.setupRequired) {
|
|
245
|
+
await client.bootstrapPasskey(); // the provider's passkey page
|
|
246
|
+
credentialId = (await client.completePasskeyBootstrap(lastCallback)).credentialId;
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Then the wallet, confirmed with that passkey:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// The SDK makes the device key (non-extractable P-256) and keeps the share.
|
|
254
|
+
let operation = await client.createWallet({ network: "ethereum" }, crypto.randomUUID());
|
|
255
|
+
await client.challengeWalletOperation(operation.id, credentialId);
|
|
256
|
+
operation = await client.completeWalletOperationConfirmation(lastCallback);
|
|
257
|
+
operation = await client.executeWalletOperation(operation.id, operation.version, crypto.randomUUID());
|
|
258
|
+
while (!["succeeded", "failed", "denied", "canceled", "expired"].includes(operation.state)) {
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
260
|
+
operation = await client.walletOperation(operation.id);
|
|
261
|
+
}
|
|
262
|
+
const { result } = await client.walletOperationResult(operation.id);
|
|
263
|
+
if (!result.wallet) throw new Error("the wallet was not created");
|
|
264
|
+
const created = result.wallet; // the SDK has kept its device share
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The share arrives sealed to the device key; the provider never opens it and
|
|
268
|
+
gives the result only to the account that created the wallet. No service can
|
|
269
|
+
read the result, export, recover or re-own a person's wallet
|
|
270
|
+
(`RECOVERY_UNAVAILABLE`). The SDK keeps the key handle and the sealed share
|
|
271
|
+
together in the origin's IndexedDB (on mobile in the `deviceShares` store the
|
|
272
|
+
app supplies, see "The device share" below), so the app stores nothing.
|
|
273
|
+
`createWallet()` with the app's own `recipientPublicKey` still works: the app
|
|
274
|
+
then keeps the share itself and passes it to every send.
|
|
275
|
+
|
|
276
|
+
The wallet then appears among the account's linked wallets:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
const linked = client.linkedWallets();
|
|
280
|
+
await linked.refresh(); // binds the account's Wallet identity
|
|
281
|
+
const wallet = (await linked.list()).items.find((item) => item.address === created.address);
|
|
282
|
+
// wallet.id is the linkedWalletId every later step names
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
#### Create and connect in one confirmation
|
|
286
|
+
|
|
287
|
+
`createWallet()` can open the new wallet to your service in the same passkey
|
|
288
|
+
confirmation, so the person confirms once instead of twice (creation, then
|
|
289
|
+
step 4). The provider's page names the service and what it will see
|
|
290
|
+
("Создать кошелёк <сеть> и открыть его сервису <name>: кошелёк и баланс,
|
|
291
|
+
операции, события"):
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
let operation = await client.createWallet({
|
|
295
|
+
network: "ethereum",
|
|
296
|
+
grant: { servicePrincipalId: config.servicePrincipalId, scopes: ["wallet:read", "operation:read", "streams:read"], externalRef: yourUserId },
|
|
297
|
+
}, crypto.randomUUID());
|
|
298
|
+
// challengeWalletOperation, completeWalletOperationConfirmation, executeWalletOperation and the wait, as above
|
|
299
|
+
const { result } = await client.walletOperationResult(operation.id);
|
|
300
|
+
switch (result.grant?.state) {
|
|
301
|
+
case "succeeded": break; // your backend reads the wallet now
|
|
302
|
+
case "pending_target_fence": // a first streams:read grant waits for Streams
|
|
303
|
+
// read client.operation(result.grant.operationId!) until it is no longer pending_target_fence
|
|
304
|
+
break;
|
|
305
|
+
case "not_set": // result.grant.code says why; the wallet exists all the same
|
|
306
|
+
if (result.grant.code === "DEPENDENCY_UNAVAILABLE") { /* read the result again */ }
|
|
307
|
+
else { /* step 4: createServiceGrant() alone, with its own passkey confirmation */ }
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The grant follows the rules of step 4 and is checked before anything is
|
|
312
|
+
confirmed: an unknown service is `NOT_FOUND`, a scope it does not hold
|
|
313
|
+
`SCOPE_DENIED`, an inactive service `AUTHORIZATION_STALE`, a server that
|
|
314
|
+
cannot promise the grant `CAPABILITY_UNAVAILABLE`, and a malformed grant
|
|
315
|
+
(no scope, an unknown or repeated one, a long `externalRef`) is refused
|
|
316
|
+
locally with `invalid_operation_create`. The SDK sends the scopes in the
|
|
317
|
+
provider's order and checks that the operation's display names the grant
|
|
318
|
+
back (`display.grant`). Reading the result is what sets the grant, for the
|
|
319
|
+
session that confirmed the creation and within an hour of it: a result read
|
|
320
|
+
later or from another session answers `not_set` with `AUTHORIZATION_STALE` or
|
|
321
|
+
`OPERATION_EXPIRED`. `result.grant.linkedWalletId` is the wallet's linked
|
|
322
|
+
wallet ID.
|
|
323
|
+
|
|
324
|
+
**Debits come with the grant.** When your service holds the platform scope
|
|
325
|
+
`wallet:debit:user`, the same creation passkey also creates your service's
|
|
326
|
+
debit permission on the new wallet (step 8): wallet, grant and debit
|
|
327
|
+
permission in one confirmation. There is no parameter for it: the ceilings
|
|
328
|
+
are your service's setting on the platform, set by the operator, the same for
|
|
329
|
+
every person, and the person sees them on the confirmation page before
|
|
330
|
+
confirming (per asset the most one debit and 24 hours may move, and the
|
|
331
|
+
recipients). `display.debit` names them. Once `walletOperationResult()` reads
|
|
332
|
+
the result, the SDK executes the permission the consent authorized with the
|
|
333
|
+
device share it keeps, sealed to the platform share keeper's key, without
|
|
334
|
+
another passkey:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const { result } = await client.walletOperationResult(operation.id);
|
|
338
|
+
// present when your service holds wallet:debit:user:
|
|
339
|
+
// result.debit: { state: "succeeded", operationId } — operationId is the debit permission's ID
|
|
340
|
+
// or { state: "not_set", operationId?, code } — AUTHORIZATION_STALE, OPERATION_EXPIRED, DEPENDENCY_UNAVAILABLE, …
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Without the scope there is no permission, and a later one costs the person
|
|
344
|
+
another passkey (step 8, `createDebitPermission()`). The SDK must keep the
|
|
345
|
+
device share for this (no `recipientPublicKey` of your own); a result read
|
|
346
|
+
from another session, or more than an hour after the confirmation, says
|
|
347
|
+
`not_set`.
|
|
348
|
+
|
|
349
|
+
### 4. Connect the wallet to your service
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
const connect = await client.createServiceGrant({
|
|
353
|
+
walletId: wallet.id,
|
|
354
|
+
servicePrincipalId: config.servicePrincipalId,
|
|
355
|
+
enabled: true,
|
|
356
|
+
scopes: ["wallet:read", "operation:read", "streams:read"],
|
|
357
|
+
externalRef: yourUserId, // optional, opaque, at most 128 printable ASCII
|
|
358
|
+
}, crypto.randomUUID());
|
|
359
|
+
await confirmAndExecute(connect); // result.kind "service_grant", state "active"
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
A creation that named the grant (above) skips this step. The provider's page
|
|
363
|
+
names your service and what it gets; the person confirms with the passkey.
|
|
364
|
+
When your service holds `wallet:debit:user`, the page also names its debit
|
|
365
|
+
ceilings and the same passkey creates its debit permission on the wallet:
|
|
366
|
+
`client.operationResult(connect.id)` settles it as for a creation, and its
|
|
367
|
+
`result.debit` says `succeeded` or `not_set` with `code`. Withdrawing the
|
|
368
|
+
grant (step 9) or disconnecting the wallet ends the permission.
|
|
369
|
+
From then on your backend reads, with its key, the wallet
|
|
370
|
+
and its balances (`wallet:read`), its operations (`operation:read`) and its
|
|
371
|
+
registrations and events (`streams:read`); it finds the wallet with
|
|
372
|
+
`GET /v1/service/grants?external_ref=…`. Without an active grant every such
|
|
373
|
+
read answers `NOT_FOUND`, as for a wallet that does not exist. A scope your
|
|
374
|
+
service does not hold is refused with `SCOPE_DENIED`, an unknown service or
|
|
375
|
+
someone else's wallet with `NOT_FOUND`. `externalRef` is stored as given and
|
|
376
|
+
returned only to your service; it proves nothing about the account.
|
|
377
|
+
|
|
378
|
+
`serviceGrants()` lists the grants the account gave, newest first, in every
|
|
379
|
+
state, so your app can show them:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
const page = await client.serviceGrants({ limit: 50 }); // then { cursor: page.nextCursor } while page.hasMore
|
|
383
|
+
// [{ servicePrincipalId, walletId, walletKind: "wallet" | "linked_wallet", network, address,
|
|
384
|
+
// scopes, state: "pending_source" | "active" | "revoked" | "stale", grantedAt, externalRef? }]
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`pending_source` is a first `streams:read` grant on a wallet that waits until
|
|
388
|
+
Streams opens the wallet's events to the service; only events from that
|
|
389
|
+
moment on reach it. `stale` is a grant whose wallet or service changed since it was set: the
|
|
390
|
+
service reads nothing under it until the person sets it again. `network` and
|
|
391
|
+
`address` are empty for a wallet that is no longer the one granted.
|
|
392
|
+
|
|
393
|
+
### 5. Limits
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
const limits = await linked.limits(wallet.id); // revision "0": never set, bounds nothing
|
|
397
|
+
const change = await linked.setLimits(wallet.id, {
|
|
398
|
+
ceilings: [{ network: "eip155:1", asset: "native", perTransferRaw: "100000000000000000", perDayRaw: "500000000000000000" }],
|
|
399
|
+
recipients: [], // none: any recipient
|
|
400
|
+
revision: limits.revision,
|
|
401
|
+
}, crypto.randomUUID());
|
|
402
|
+
await confirmAndExecute(change); // result.kind "wallet_limits"
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Per network and asset (`native`, `usdt`, `usdc`): an optional ceiling on one
|
|
406
|
+
transfer and on 24 hours, in that network's minimal units, and one recipient
|
|
407
|
+
list. Every send obeys them, Mode C included. Details under "Limits of a
|
|
408
|
+
linked wallet" below.
|
|
409
|
+
|
|
410
|
+
### 6. Send (Mode B)
|
|
411
|
+
|
|
412
|
+
A send needs no passkey: the live session authorizes it, and the wallet's
|
|
413
|
+
limits bound it. Three calls, one after another:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
let send = await linked.send({ linkedWalletId: wallet.id, network: "eip155:1", asset: "native", recipient, amountRaw: "1000000000000000" }, crypto.randomUUID());
|
|
417
|
+
send = await linked.prepare(send.id); // the fee estimate is in send.display
|
|
418
|
+
await linked.executeSend(send.id, send.version, crypto.randomUUID()); // the SDK seals its kept share
|
|
419
|
+
const status = await linked.transferStatus(send.id); // read until status.final
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
In a browser that holds no share of the wallet (your app on a new domain,
|
|
423
|
+
cleared site data, another browser or a new device) `executeSend()` first
|
|
424
|
+
recovers it: the provider's page asks for the passkey once, as at the
|
|
425
|
+
wallet's creation, and then the send goes on. Nothing else changes for the
|
|
426
|
+
app; see "The device share" below.
|
|
427
|
+
|
|
428
|
+
The operation ends `succeeded` once the Wallet accepted the transfer and named
|
|
429
|
+
its hash; `transferStatus()` then says what the network did with it. See
|
|
430
|
+
"Sending from a linked wallet" below for every state and refusal.
|
|
431
|
+
|
|
432
|
+
### 7. Transfers your backend forwards (Mode C)
|
|
433
|
+
|
|
434
|
+
The person sets bounds once and confirms them with the passkey; then the app
|
|
435
|
+
signs each transfer with a key that never leaves the device, and your backend
|
|
436
|
+
forwards it unchanged with its service key, or refuses to.
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import {
|
|
440
|
+
ModeCServerClock, buildLinkedRequestGrantEnrollment, generateRequestSigningKey,
|
|
441
|
+
pairLinkedRequestGrant, prepareLinkedMediatedTransfer, signLinkedMediatedRequest,
|
|
442
|
+
type LinkedRequestGrantEnrollment,
|
|
443
|
+
} from "@shuttle.technology/authorization/mode-c-linked";
|
|
444
|
+
|
|
445
|
+
const key = await generateRequestSigningKey(crypto); // non-extractable P-256, in memory
|
|
446
|
+
const enrollment: LinkedRequestGrantEnrollment = {
|
|
447
|
+
ownerId: me.account.accountId, servicePrincipalId: config.servicePrincipalId, linkedWalletId: wallet.id,
|
|
448
|
+
network: "eip155:1", assets: [{ asset: "native", maxAmountRaw: "500000000000000000" }],
|
|
449
|
+
maxFeeRaw: "1000000000000000", recipientAllowlist: [],
|
|
450
|
+
};
|
|
451
|
+
const enrolled = await confirmAndExecute(await client.createOperation(buildLinkedRequestGrantEnrollment(key, enrollment), crypto.randomUUID()));
|
|
452
|
+
const grant = enrolled.result.grant; // present for a request_grant result
|
|
453
|
+
if (!grant) throw new Error("no grant");
|
|
454
|
+
const paired = pairLinkedRequestGrant(key, grant, enrollment);
|
|
455
|
+
const clock = new ModeCServerClock(grant.serverTime, Date.now());
|
|
456
|
+
|
|
457
|
+
// Each transfer, with no prompt:
|
|
458
|
+
const prepared = prepareLinkedMediatedTransfer(paired, { asset: "native", recipient, amountRaw: "100000000000000000" });
|
|
459
|
+
const sealed = await linked.deviceShareFor(wallet.id); // the kept share resealed for this transfer
|
|
460
|
+
const signed = await signLinkedMediatedRequest({ grant: paired, prepared, deviceShare: sealed, crypto, serverNowSeconds: clock.nowSeconds(Date.now()) });
|
|
461
|
+
// Hand signed.request and signed.idempotencyKey to your backend. It posts the request unchanged to
|
|
462
|
+
// POST /v1/service/mediated-operations with its key and that Idempotency-Key, within the proof's minute.
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
The grant lives at most 15 minutes and never past the session; the session's
|
|
466
|
+
end on the provider (a `logout()` answered `server_revoked`, or a confirmed
|
|
467
|
+
session revocation) or the removal of the enrolling passkey ends it sooner
|
|
468
|
+
(see step 9). The proof
|
|
469
|
+
binds the wallet, its binding revision, the exact transfer and the share's
|
|
470
|
+
digest: the backend cannot change the amount, recipient, asset or network,
|
|
471
|
+
swap the share or replay the request. The person reads a forwarded transfer
|
|
472
|
+
like any send (`walletOperation()`, `linked.sends()`), with basis
|
|
473
|
+
`user_request_signed`. Details under "Mode C on a linked wallet" below.
|
|
474
|
+
|
|
475
|
+
### 8. Debits your backend makes without the device (for a card)
|
|
476
|
+
|
|
477
|
+
The normal path is the grant: a service that holds `wallet:debit:user` gets
|
|
478
|
+
its debit permission with every wallet the person opens to it, in that
|
|
479
|
+
passkey confirmation, with the ceilings the platform set for it (step 3,
|
|
480
|
+
"Create and connect in one confirmation", and step 4). What follows is the
|
|
481
|
+
separate permission, with its own passkey: for a wallet opened to your
|
|
482
|
+
service before it held the scope, or bounds of the person's own choosing.
|
|
483
|
+
|
|
484
|
+
The person lets one service debit one wallet within bounds, until they revoke
|
|
485
|
+
it: for one to three assets the most one debit and the most 24 hours may move,
|
|
486
|
+
the recipients (none: any) and an optional end. The provider's page says it in
|
|
487
|
+
words and asks for the passkey. The execute hands the wallet's device share to
|
|
488
|
+
the platform's share keeper, sealed to a key the keeper made for this
|
|
489
|
+
operation; the provider never opens it, and the device keeps its own copy, so
|
|
490
|
+
sends from the app go on as before.
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
const debits = linked.debitPermissions();
|
|
494
|
+
const asked = await debits.createDebitPermission({
|
|
495
|
+
walletId: wallet.id, servicePrincipalId: config.servicePrincipalId,
|
|
496
|
+
assets: [{ asset: "usdt", perDebitRaw: "25000000", perDayRaw: "100000000" }], // minimal units; per debit <= per day
|
|
497
|
+
recipients: [], // none: any recipient
|
|
498
|
+
expiresAt: "2027-01-01T00:00:00Z", // left out: until revoked
|
|
499
|
+
}, crypto.randomUUID());
|
|
500
|
+
await client.challengeOperation(asked.id, credentialId);
|
|
501
|
+
const confirmed = await client.completeOperationConfirmation(lastCallback);
|
|
502
|
+
// asked.display.shareKeeperPublicKey present: the keeper needs the share, and the execute reseals the one the SDK keeps.
|
|
503
|
+
const applied = await debits.executeDebitPermission(confirmed.id, confirmed.version, crypto.randomUUID()); // result.kind "debit_permission"
|
|
504
|
+
|
|
505
|
+
const { items } = await debits.listDebitPermissions(); // newest first: active, revoked or expired
|
|
506
|
+
const ending = await debits.revokeDebitPermission(items[0].id, crypto.randomUUID());
|
|
507
|
+
await confirmAndExecute(ending); // result.state "revoked"
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
`executeDebitPermission()` reads the operation first. When its display names
|
|
511
|
+
`shareKeeperPublicKey`, it reseals the share the SDK keeps for the wallet to
|
|
512
|
+
that key and sends it (recovering it first with one passkey confirmation when
|
|
513
|
+
this browser holds none); an app that keeps its own share passes
|
|
514
|
+
`{ devicePrivateKey, deviceShare }` and it is resealed with
|
|
515
|
+
`resealDeviceShare`. Without that key the keeper holds this wallet's share
|
|
516
|
+
already (an earlier permission handed it over) and nothing is sent. The debits themselves are made by your
|
|
517
|
+
backend with its key and the scopes `wallet:debit:user` and `operation:read`
|
|
518
|
+
(`docs/integration/backend.md`); a debit outside every active permission of
|
|
519
|
+
the service on the wallet is `DEBIT_PERMISSION_INVALID`, over its bounds or the
|
|
520
|
+
wallet's limits one of `WalletLimitRefusals`. A revocation refuses every debit
|
|
521
|
+
not yet executed.
|
|
522
|
+
|
|
523
|
+
### 9. Withdraw the access
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
const withdrawal = await client.revokeServiceGrant({ walletId: wallet.id, servicePrincipalId: config.servicePrincipalId }, crypto.randomUUID());
|
|
527
|
+
await confirmAndExecute(withdrawal); // result.state "revoked"
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
`revokeServiceGrant` is `service_grant.set` with `enabled: false` and no
|
|
531
|
+
scopes; a grant listed by `serviceGrants()` can be passed as it is. From the
|
|
532
|
+
next read on, your backend gets `NOT_FOUND` for the wallet and for every read
|
|
533
|
+
under the grant, its events included; the account still lists the grant,
|
|
534
|
+
`revoked`. The area's shared event stream (WebSocket, pull, webhooks) is not
|
|
535
|
+
bound to grants yet and goes on delivering the wallet's events
|
|
536
|
+
(`docs/integration/backend.md`, section 5). Mode C ends when the
|
|
537
|
+
provider ends the session, or when the enrolling passkey is removed: a
|
|
538
|
+
`logout()` that resolves with `serverDisposition` `server_revoked`, or a
|
|
539
|
+
`createSessionRevocation(sessionId, key)` confirmed like the above. A
|
|
540
|
+
`server_unconfirmed` logout proves nothing on the provider; revoke the session
|
|
541
|
+
with `createSessionRevocation` from another session of the account, or let
|
|
542
|
+
the grant run out (at most 15 minutes).
|
|
543
|
+
|
|
544
|
+
## Errors
|
|
545
|
+
|
|
546
|
+
Two kinds of error reach the app:
|
|
547
|
+
|
|
548
|
+
- `AuthorizationError`: the provider refused. `code` is one of
|
|
549
|
+
`AuthorizationErrorCodes`, exactly the ErrorCode enum of the provider's
|
|
550
|
+
OpenAPI document, typed as `AuthorizationErrorCode`; with it come `status`,
|
|
551
|
+
`retryable`, `nextAction`, `requestId`, and `operationId` where the caller
|
|
552
|
+
may look up the operation the refusal names. An answer that does not match
|
|
553
|
+
the table below is refused as a `ClientFlowError` `invalid_error_response`.
|
|
554
|
+
- `ClientFlowError`: the SDK refused locally. Most such refusals come before
|
|
555
|
+
any request, and then nothing changed on the server: a malformed input
|
|
556
|
+
(`invalid_operation_create`, `invalid_page_request`, …), a missing session
|
|
557
|
+
(`session_unavailable`), a redirect that is not the one expected
|
|
558
|
+
(`state_mismatch`, `invalid_callback`). Two come after the request, when the
|
|
559
|
+
answer is outside its closed form: `invalid_server_response` and
|
|
560
|
+
`invalid_error_response`. After a write, such a refusal does not say whether
|
|
561
|
+
the write happened: keep its `Idempotency-Key`, read the operation (or
|
|
562
|
+
repeat the same request with the same key, which answers the first) before
|
|
563
|
+
starting anything new.
|
|
564
|
+
|
|
565
|
+
```ts
|
|
566
|
+
import { isAuthorizationError, WalletLimitRefusals } from "@shuttle.technology/authorization";
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
await linked.executeSend(send.id, send.version, key, share);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (isAuthorizationError(error, ...WalletLimitRefusals)) showLimitRefusal(error.code); // nothing was sent
|
|
572
|
+
else if (isAuthorizationError(error, "UNAUTHORIZED")) await signInAgain();
|
|
573
|
+
else if (isAuthorizationError(error) && error.retryable) retryLater(error.nextAction);
|
|
574
|
+
else throw error;
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Repeat a request only when `retryable` is true, with the same body and the
|
|
579
|
+
same `Idempotency-Key`. A `DEPENDENCY_UNAVAILABLE`, `TENANT_AUTH_UNAVAILABLE`,
|
|
580
|
+
`AUDIT_UNAVAILABLE` or `INTERNAL_ERROR` that carries an `operationId`, with
|
|
581
|
+
`retryable` false and `nextAction` `lookup_operation`, may have committed:
|
|
582
|
+
read that operation before doing anything else. `AuthorizationErrorContracts`
|
|
583
|
+
holds the table in code, and `isAuthorizationErrorCode(value)` tells a code.
|
|
584
|
+
|
|
585
|
+
| Code | Status | Retry | Next action | Meaning |
|
|
586
|
+
|---|---|---|---|---|
|
|
587
|
+
| `INVALID_REQUEST` | 400 | no | none | The request is malformed or names what it may not, such as a service as a wallet's result recipient. |
|
|
588
|
+
| `CURSOR_INVALID` | 400 | no | none | The cursor is not one this read gave this session; read from the start. |
|
|
589
|
+
| `UNSUPPORTED_VERSION` | 400 | no | none | A version the provider does not speak. |
|
|
590
|
+
| `UNAUTHORIZED` | 401 | no | reauthenticate | No live session (a read has already renewed the token once). Sign in again. |
|
|
591
|
+
| `SCOPE_DENIED` | 403 | no | none | The caller lacks the scope, or a grant asks for a scope the service does not hold. |
|
|
592
|
+
| `ORIGIN_NOT_ALLOWED` | 403 | no | none | The origin is not registered for the client. |
|
|
593
|
+
| `LIMIT_PER_TRANSFER_EXCEEDED` | 403 | no | none | Over the wallet's ceiling for one transfer, or a Mode C grant's ceiling. Nothing was sent. |
|
|
594
|
+
| `LIMIT_DAILY_EXCEEDED` | 403 | no | none | Over the wallet's ceiling for 24 hours. Nothing was sent. |
|
|
595
|
+
| `RECIPIENT_NOT_ALLOWED` | 403 | no | none | Not on the wallet's recipient list, or the Mode C grant's. Nothing was sent. |
|
|
596
|
+
| `CONFIRMATION_METHOD_UNAVAILABLE` | 403 | no | use_passkey | The account has no passkey to confirm with. |
|
|
597
|
+
| `USER_PROOF_REQUIRED` | 403 | no | sign_user_request | A Mode C request without the app's signature. |
|
|
598
|
+
| `USER_PROOF_INVALID` | 403 | no | sign_user_request | The signature does not match the request, the share or the key. |
|
|
599
|
+
| `REQUEST_GRANT_INVALID` | 403 | no | use_passkey | The Mode C grant is another service's, has ended or was enrolled on other bounds; enroll again. |
|
|
600
|
+
| `DEBIT_PERMISSION_INVALID` | 403 | no | none | No active debit permission of the service covers this debit on this wallet: none was given, or it was revoked or ended. Nothing was sent. |
|
|
601
|
+
| `NOT_FOUND` | 404 | no | none | No such resource for this caller, including a wallet a service has no active grant on. |
|
|
602
|
+
| `REGISTRATION_SCOPE_SELECTION_REQUIRED` | 409 | no | none | The wallet's Streams history has several scopes: name a registration. |
|
|
603
|
+
| `IDENTITY_NOT_REGISTERED` | 409 | no | start_signup | No account for this identity yet. |
|
|
604
|
+
| `IDENTITY_ALREADY_REGISTERED` | 409 | no | start_login | An account exists for this identity. |
|
|
605
|
+
| `IDENTITY_ALREADY_LINKED` | 409 | no | none | The identity belongs to an account already. |
|
|
606
|
+
| `LAST_FACTOR` | 409 | no | none | The last sign-in method cannot be removed. |
|
|
607
|
+
| `CONFIRMATION_REPLAYED` | 409 | no | none | That confirmation was used already. |
|
|
608
|
+
| `CANNOT_CANCEL` | 409 | no | none | The operation can no longer be canceled. |
|
|
609
|
+
| `VERSION_CONFLICT` | 409 | no | refresh_resource | The resource changed since it was read, or the step is out of order; read it again. |
|
|
610
|
+
| `AUTHORIZATION_STALE` | 409 | no | refresh_resource | An authority the operation was admitted under changed; create a new one. |
|
|
611
|
+
| `IDEMPOTENCY_CONFLICT` | 409 | no | lookup_operation | The key was used with another request; `operationId` names the first. |
|
|
612
|
+
| `USER_PROOF_REPLAYED` | 409 | no | lookup_operation | The signed Mode C request was used already. |
|
|
613
|
+
| `OPERATION_NOT_AUTHORIZED` | 409 | no | use_passkey | Executed before it was confirmed. |
|
|
614
|
+
| `RESULT_NOT_READY` | 409 | yes | wait_for_result | The effect is still running; `operationId` names it. |
|
|
615
|
+
| `TRANSFER_IN_FLIGHT` | 409 | yes | retry_same_request | An earlier transfer of the wallet holds its place in the network's sequence. |
|
|
616
|
+
| `ATTEMPT_EXPIRED` | 410 | no | reauthenticate | The sign-in took too long; start again. |
|
|
617
|
+
| `OPERATION_EXPIRED` | 410 | no | none | The operation's time ran out; nothing was done. |
|
|
618
|
+
| `RESULT_EXPIRED` | 410 | no | none | The result is past its retention. |
|
|
619
|
+
| `RESYNC_REQUIRED` | 410 | no | resync_streams | The events cursor is past its window; `window` says where to read again. |
|
|
620
|
+
| `USER_PROOF_EXPIRED` | 410 | no | sign_user_request | The signed Mode C request is past its minute; sign it again. |
|
|
621
|
+
| `CAPABILITY_UNAVAILABLE` | 422 | no | none | This server does not serve the function. |
|
|
622
|
+
| `RECOVERY_UNAVAILABLE` | 422 | no | none | No way to recover or re-own the wallet: never through a service key or support. |
|
|
623
|
+
| `PAYLOAD_NOT_SUPPORTED` | 422 | no | none | The network, asset or content is not supported, or the Wallet refused the transfer. |
|
|
624
|
+
| `RATE_LIMITED` | 429 | yes | retry_same_request | Too many requests; wait. |
|
|
625
|
+
| `DEPENDENCY_UNAVAILABLE` | 503 | yes | retry_same_request | The Wallet, a node or another dependency did not answer. |
|
|
626
|
+
| `NETWORK_FEE_TOO_HIGH` | 503 | yes | retry_same_request | The network fee is above the ceiling; try later. |
|
|
627
|
+
| `TENANT_AUTH_UNAVAILABLE` | 503 | yes | retry_same_request | Tenant Access did not answer. |
|
|
628
|
+
| `AUDIT_UNAVAILABLE` | 503 | yes | retry_same_request | The provider could not write its audit record. |
|
|
629
|
+
| `ADAPTER_INTEGRITY_CONFLICT` | 503 | no | none | The provider's records disagree; report it with the `requestId`. |
|
|
630
|
+
| `INTERNAL_ERROR` | 500 | yes | retry_same_request | An unexpected failure. |
|
|
631
|
+
|
|
632
|
+
Two groups the app usually explains to the person:
|
|
633
|
+
|
|
634
|
+
- `WalletLimitRefusals`: `LIMIT_PER_TRANSFER_EXCEEDED`, `LIMIT_DAILY_EXCEEDED`,
|
|
635
|
+
`RECIPIENT_NOT_ALLOWED` (`isWalletLimitRefusal(code)`).
|
|
636
|
+
- `ModeCRefusals`: what a forwarded Mode C transfer is refused with besides
|
|
637
|
+
the common answers: the proof (`USER_PROOF_*`, `IDEMPOTENCY_CONFLICT`), the
|
|
638
|
+
grant (`REQUEST_GRANT_INVALID`), the amount and recipient against the
|
|
639
|
+
grant's bounds and the wallet's limits (`LIMIT_PER_TRANSFER_EXCEEDED`,
|
|
640
|
+
`LIMIT_DAILY_EXCEEDED`, `RECIPIENT_NOT_ALLOWED`) and the fee
|
|
641
|
+
(`NETWORK_FEE_TOO_HIGH`) (`isModeCRefusal(code)`). A server that does not
|
|
642
|
+
check the grant's bounds before the Wallet prepares the transfer answers
|
|
643
|
+
`REQUEST_GRANT_INVALID` for an amount or a recipient outside them. Your
|
|
644
|
+
backend receives these and passes the code back to the app.
|
|
645
|
+
|
|
646
|
+
## Mobile apps
|
|
647
|
+
|
|
648
|
+
On the mobile channel `login()` and `signup()` are one call each:
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
const client = await initializeAuthorizationClient({
|
|
652
|
+
mode: "mode_b", channel: "mobile", authorizationOrigin: config.apiOrigin,
|
|
653
|
+
redirectUri: "https://app.example.com/auth/callback", mobileApplicationIdentifier: "com.example.app",
|
|
654
|
+
publishableClientId, tenantId, applicationId, environmentId, methods: ["google", "telegram", "passkey"],
|
|
655
|
+
}, { browserBridge, secureStorage });
|
|
656
|
+
const { session } = await client.login();
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
The SDK has the operating system verify the app link of the redirect, opens
|
|
660
|
+
the hosted page with `MobileBrowserBridge.openSystemBrowser`, takes the app
|
|
661
|
+
link the system browser's authentication session hands back, checks that it
|
|
662
|
+
is exactly the registered redirect with the one-use `code` and this sign-in's
|
|
663
|
+
own `state`, exchanges the code and resolves with the session. A closed
|
|
664
|
+
browser fails with `mobile_redirect_unavailable`, another state with
|
|
665
|
+
`state_mismatch`, anything but the exact app link with `invalid_callback`. The
|
|
666
|
+
app sends no `Origin`: the provider binds a mobile sign-in to the exact
|
|
667
|
+
registered redirect, its state and its S256 verifier, and returns the refresh
|
|
668
|
+
token in the exchange answer for `MobileSecureStorage` only.
|
|
669
|
+
|
|
670
|
+
The app supplies:
|
|
671
|
+
|
|
672
|
+
- `MobileBrowserBridge`: `verifyAssociatedRedirect` asks the operating system
|
|
673
|
+
to validate the HTTPS app link association for the exact redirect and the
|
|
674
|
+
configured bundle or package identifier, at initialization and before every
|
|
675
|
+
sign-in; `openSystemBrowser` opens the authentication session
|
|
676
|
+
(`ASWebAuthenticationSession` with an HTTPS callback on iOS, a Custom Tab on
|
|
677
|
+
Android) and resolves with the URL it ended on. There is no default
|
|
678
|
+
verifier, no custom-scheme fallback and no embedded web view.
|
|
679
|
+
- `MobileSecureStorage`, backed by the Keychain or the Keystore. The refresh
|
|
680
|
+
token's key uses letters, digits, dots, hyphens and underscores only.
|
|
681
|
+
|
|
682
|
+
The bridge's verification result is a contract of the host, not a proof this
|
|
683
|
+
package makes. On iOS no API reports that an associated domain was verified:
|
|
684
|
+
the system checks it when the authentication session with the HTTPS callback
|
|
685
|
+
starts, and does not start one for an app the domain does not name; a bridge
|
|
686
|
+
that answers for that check (as `examples/mobile` does) fails there, before
|
|
687
|
+
any page loads and before any code comes back.
|
|
688
|
+
|
|
689
|
+
If secure storage cannot keep a rotated refresh token, the SDK deletes the
|
|
690
|
+
previous one best-effort, clears its session and reports
|
|
691
|
+
`secure_storage_unavailable`; the way back is a new sign-in.
|
|
692
|
+
|
|
693
|
+
### What the integrator hosts and registers
|
|
694
|
+
|
|
695
|
+
No custom URL scheme is accepted for channel `mobile`, and no embedded web
|
|
696
|
+
view. Registered with the operator, for one public client:
|
|
697
|
+
|
|
698
|
+
- channel (`platform`) `mobile`;
|
|
699
|
+
- the exact redirects: app link URLs such as
|
|
700
|
+
`https://app.example.com/auth/callback` (no wildcard, query or fragment);
|
|
701
|
+
- the origins: the app link's domain, such as `https://app.example.com`;
|
|
702
|
+
- the sign-in methods the hosted page may offer.
|
|
703
|
+
|
|
704
|
+
Hosted on that domain, over HTTPS with a publicly trusted certificate and
|
|
705
|
+
without redirects:
|
|
706
|
+
|
|
707
|
+
- `/.well-known/apple-app-site-association`, served as `application/json`,
|
|
708
|
+
naming the app (`<Team ID>.<bundle ID>`) under `webcredentials` (the iOS
|
|
709
|
+
17.4+ authentication session hands an HTTPS callback back only to an app
|
|
710
|
+
listed there) and under `applinks` for the redirect's path:
|
|
711
|
+
|
|
712
|
+
```json
|
|
713
|
+
{
|
|
714
|
+
"applinks": { "details": [{ "appIDs": ["ABCDE12345.com.example.app"], "components": [{ "/": "/auth/callback*" }] }] },
|
|
715
|
+
"webcredentials": { "apps": ["ABCDE12345.com.example.app"] }
|
|
716
|
+
}
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
- `/.well-known/assetlinks.json`, naming the Android package and its signing
|
|
720
|
+
certificate's SHA-256 fingerprint:
|
|
721
|
+
|
|
722
|
+
```json
|
|
723
|
+
[{
|
|
724
|
+
"relation": ["delegate_permission/common.handle_all_urls"],
|
|
725
|
+
"target": { "namespace": "android_app", "package_name": "com.example.app", "sha256_cert_fingerprints": ["<SHA-256 of the signing certificate>"] }
|
|
726
|
+
}]
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
In the app: the iOS entitlement `com.apple.developer.associated-domains` with
|
|
730
|
+
`applinks:app.example.com` and `webcredentials:app.example.com`; on Android an
|
|
731
|
+
intent filter with `android:autoVerify="true"` for the redirect's host and
|
|
732
|
+
path. `examples/mobile` is a React Native (Expo) app that does all of it.
|
|
733
|
+
|
|
734
|
+
## Reference
|
|
735
|
+
|
|
736
|
+
### Web session renewal
|
|
737
|
+
|
|
738
|
+
`restoreSession()` is a top-level handoff: the SDK keeps a fresh state and
|
|
739
|
+
S256 verifier in memory and calls `openHosted` with the provider's restore URL
|
|
740
|
+
and a signal that aborts once no caller waits for the restore; the host
|
|
741
|
+
returns the registered redirect URL the restore came back to. A popup is one valid host; a hidden frame on the same
|
|
742
|
+
site is another (`demo/renew.js`). A full-page navigation that destroys the
|
|
743
|
+
SDK instance is not supported: when the host cannot return the redirect, the
|
|
744
|
+
restore fails locally with `restore_redirect_unavailable`. The SDK never
|
|
745
|
+
stores restore state, verifier, access token, CSRF token or refresh cookie in
|
|
746
|
+
browser storage.
|
|
747
|
+
|
|
748
|
+
#### Background renewal
|
|
749
|
+
|
|
750
|
+
On the web `refreshSession()`, and every call that finds its access token
|
|
751
|
+
expired or refused, renews in the background: `POST /v1/auth/renew` with
|
|
752
|
+
`credentials: "include"`, no window. The provider's refresh cookie is
|
|
753
|
+
`__Host-authorization_refresh`, `Secure; HttpOnly; SameSite=None;
|
|
754
|
+
Partitioned` (CHIPS), so the browser sends it from the application's own site
|
|
755
|
+
and from no other, and the renewal is answered only for the exact `Origin` of
|
|
756
|
+
one of the client's registered origins. Within a page's life the SDK sends
|
|
757
|
+
the session's CSRF token in `X-CSRF-Token`. A page that holds none, a reload
|
|
758
|
+
or a new tab, renews from the cookie alone (`X-Authorization-Renew: 1`) and
|
|
759
|
+
gets a new token, so on load an application calls `refreshSession()`. A token another
|
|
760
|
+
tab replaced is followed by one renewal from the cookie. The tabs of one
|
|
761
|
+
application renew and restore in turn under one Web Lock, which a restore
|
|
762
|
+
holds until `openHosted` settles or its signal aborts; on abort the host
|
|
763
|
+
closes the window it opened. A renewal or restore that a logout or a newer
|
|
764
|
+
sign-in overtook while it waited for the lock asks the provider nothing, and
|
|
765
|
+
what it answers installs only while the session it renews is still the one
|
|
766
|
+
installed. The web keeps no refresh token: the cookie is the provider's. When the browser sends no cookie the
|
|
767
|
+
SDK throws `ClientFlowError` with code `web_renewal_requires_navigation`
|
|
768
|
+
(`webRenewalRequiresNavigation`) and does not navigate: the application
|
|
769
|
+
calls `restoreSession()` itself, or signs the person in again where the
|
|
770
|
+
restore cannot see the cookie (an application on a site of its own).
|
|
771
|
+
|
|
772
|
+
A renewal or restore answers only the session it renews. The cookie is one
|
|
773
|
+
per site, and a sign-in in another tab, perhaps to another account, puts its
|
|
774
|
+
own session there: the next renewal of this page's session would answer that
|
|
775
|
+
one. The SDK then installs nothing, ends the session it held and throws
|
|
776
|
+
`ClientFlowError` `session_replaced`; the application takes up the session
|
|
777
|
+
the cookie holds with `refreshSession()` (and checks whose it is with
|
|
778
|
+
`me()`), or signs the person in again. A sign-in whose answer a `logout()`
|
|
779
|
+
or a newer sign-in overtook is not installed, and the SDK revokes its
|
|
780
|
+
session, so the cookie it set holds nothing a reload could take up; when
|
|
781
|
+
another tab replaced that session's CSRF token meanwhile, it renews once
|
|
782
|
+
from the cookie under the tabs' lock and revokes with those tokens, only
|
|
783
|
+
while the cookie still holds that session. A `logout()` waits for the
|
|
784
|
+
sign-ins it overtook and answers `server_unconfirmed` unless each such
|
|
785
|
+
revocation was confirmed; a sign-in whose answer was lost, unread or
|
|
786
|
+
malformed counts as unconfirmed, one the provider refused as clean. It does
|
|
787
|
+
not wait when it has nothing of its own to confirm, nor for a revocation
|
|
788
|
+
that must wait for the tabs' lock: it answers `server_unconfirmed` at once
|
|
789
|
+
and the revocation goes on. Logout
|
|
790
|
+
clears the cookie only while it holds the session logged out of: the cookie
|
|
791
|
+
of a newer sign-in stays. A signal passed to `exchangeHandoff()` or
|
|
792
|
+
`logout()` ends only that caller's wait, never the request.
|
|
793
|
+
|
|
794
|
+
One session per origin: two registered origins of one site share the cookie
|
|
795
|
+
but not the lock, so if both renew at the same moment the provider takes it
|
|
796
|
+
as a replay and ends the session. A sign-in's exchange goes out under the
|
|
797
|
+
same lock as a renewal and a restore, so a renewal in another tab is answered
|
|
798
|
+
before it and never puts the older session back in the cookie. A restore in
|
|
799
|
+
another tab holds the lock while its window is open, and the sign-in waits
|
|
800
|
+
for it.
|
|
801
|
+
|
|
802
|
+
### Passkeys and the account's own operations
|
|
803
|
+
|
|
804
|
+
`bootstrapPasskey()` and `verifyIdentity(method)` start provider-hosted flows
|
|
805
|
+
with a fresh state and S256 verifier held in memory; the host delivers the
|
|
806
|
+
redirect to `completePasskeyBootstrap()` or `completeIdentityVerification()`.
|
|
807
|
+
Those redirects carry only one-use `code`, `state` and `flow_id` values, and a
|
|
808
|
+
hosted URL or flow ID alone is never authority.
|
|
809
|
+
|
|
810
|
+
The account's local operations are `identity.link`
|
|
811
|
+
(`createIdentityLink(handle, key)` after `verifyIdentity`), `identity.unlink`
|
|
812
|
+
(`createIdentityUnlink`), `session.revoke` (`createSessionRevocation`),
|
|
813
|
+
`service_grant.set` (`createServiceGrant`, `revokeServiceGrant`),
|
|
814
|
+
`wallet_limits.set` (`linkedWallets().setLimits`) and
|
|
815
|
+
`session.authorize_requests` (Mode C enrollment). Creation, confirmation,
|
|
816
|
+
execution and the protected result read are separate calls; a safe result
|
|
817
|
+
holds only `kind`, `targetId`, `newVersion` and `state`. `identities()` lists
|
|
818
|
+
the account's sign-in methods (an array, not paged); `sessions()` its sessions
|
|
819
|
+
and `wallets()` its own wallets (`kind` `wallet` in `me()`) are paged with
|
|
820
|
+
`{ limit, cursor }`.
|
|
821
|
+
|
|
822
|
+
### Mode C on a linked wallet
|
|
823
|
+
|
|
824
|
+
1. `generateRequestSigningKey(crypto)` makes the key;
|
|
825
|
+
`buildLinkedRequestGrantEnrollment(key, enrollment)` the closed
|
|
826
|
+
`session.authorize_requests` body: the linked wallet, the network the
|
|
827
|
+
transfers are made on (the wallet's own, or for an EVM wallet another EVM
|
|
828
|
+
network), one to three assets (`native`, `usdt`, `usdc`) each with the most
|
|
829
|
+
one transfer may move (`maxAmountRaw`), the most the network fee may be
|
|
830
|
+
(`maxFeeRaw`, compared with the Wallet's estimate when the transfer is
|
|
831
|
+
prepared) and up to ten recipients, none for any (compared as the provider
|
|
832
|
+
compares addresses: EVM without case, TON in its raw form). The executed
|
|
833
|
+
operation's result carries the grant (`result.grant`);
|
|
834
|
+
`pairLinkedRequestGrant` keeps it beside its key and refuses a grant on
|
|
835
|
+
other bounds. The grant survives the person's later sign-ins and
|
|
836
|
+
confirmations with the same passkey.
|
|
837
|
+
2. `prepareLinkedMediatedTransfer(paired, { asset, recipient, amountRaw, comment? })`
|
|
838
|
+
is the transfer the app shows; what the grant does not cover is refused
|
|
839
|
+
locally. A comment is carried on TON only.
|
|
840
|
+
3. The app takes the device share resealed for this transfer
|
|
841
|
+
(`linkedWallets().deviceShareFor()`, which recovers it first in a browser
|
|
842
|
+
that holds none; an app that keeps its own share reseals it to
|
|
843
|
+
`transportKey()` with `resealDeviceShare`), and
|
|
844
|
+
`signLinkedMediatedRequest({ grant, prepared, deviceShare, crypto, serverNowSeconds })`
|
|
845
|
+
signs the 2.0 proof over the transfer and over the SHA-256 of the share
|
|
846
|
+
(`deviceShareDigest`). It returns `request` (`{ operation, user_proof,
|
|
847
|
+
device_share }`) and `idempotencyKey` (the proof's `jti`).
|
|
848
|
+
`examples/backend-client` (`cli.ts mediate <file>`) is a backend that
|
|
849
|
+
forwards it.
|
|
850
|
+
|
|
851
|
+
`ModeCServerClock` carries the provider's time: it is seeded from the grant's
|
|
852
|
+
`serverTime` and moved only by a current `me()`; a proof is signed by it, and
|
|
853
|
+
a clock disagreement is resolved by signing a new proof with a new `jti` while
|
|
854
|
+
the grant is live. The proof shows that the enrolled key acted, not that a
|
|
855
|
+
person was present: compromised app code can misuse a live grant within its
|
|
856
|
+
bounds. Speed-up and cancel are not offered through Mode C.
|
|
857
|
+
|
|
858
|
+
`./mode-c` is the same for a wallet bound in the provider rather than
|
|
859
|
+
linked: `buildRequestGrantEnrollment`, `enrollRequestGrant` (create, passkey
|
|
860
|
+
confirmation, execute and result behind one function), `RequestGrantStore`,
|
|
861
|
+
`prepareMediatedTransfer` and `signMediatedRequest` (proof 1.0, native coin).
|
|
862
|
+
Neither module sends anything: forwarding and refusal are the backend's.
|
|
863
|
+
|
|
864
|
+
### Tracking a linked wallet in Streams
|
|
865
|
+
|
|
866
|
+
Tracking needs no passkey: `linkedWallets().register({ linkedWalletId, network, activityTypes? }, key)`
|
|
867
|
+
comes back `authorized` with basis `session_authorized`, and
|
|
868
|
+
`executeRegistration(operation.id, operation.version, key)` dispatches it. A
|
|
869
|
+
registration covers one network; an EVM wallet has one address on every EVM
|
|
870
|
+
network, so register it once per EVM network, and `events()` returns the
|
|
871
|
+
events of all of them, each with its `networkId`. `activityTypes` names the
|
|
872
|
+
kinds of activity asked for beside deposits (such as `"transaction"`,
|
|
873
|
+
`"erc20.approval"`): at most 16, each matching
|
|
874
|
+
`^[a-z][a-z0-9]*(?:[._][a-z0-9]+)*$`; omitted or empty means deposits only,
|
|
875
|
+
and Streams refuses a kind the network does not have. A Solana wallet is
|
|
876
|
+
registered on `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`; its activity types
|
|
877
|
+
are `transaction`, `native.transfer`, `spl.transfer`, `spl.approval`,
|
|
878
|
+
`contract.raw`, `contract.decoded` and `native.action`.
|
|
879
|
+
|
|
880
|
+
`linkedWallets().signals(linkedWalletIds, { onOpen, onWallets, signal })`
|
|
881
|
+
opens one signal stream for 1 to 32 linked wallets. It carries no event: it
|
|
882
|
+
names the wallets that have new ones, which are read with `events()`. Read
|
|
883
|
+
every wallet once at `onOpen`, so nothing falls between that read and the
|
|
884
|
+
first signal. The promise resolves with why the stream ended (`expired`,
|
|
885
|
+
`server_draining`, `connection_closed`); a stream lives no longer than an
|
|
886
|
+
access token, so open the next one. A signal is a hint: keep a slower timed
|
|
887
|
+
read for the times no stream is open (`demo/live.js`).
|
|
888
|
+
|
|
889
|
+
### Sending from a linked wallet
|
|
890
|
+
|
|
891
|
+
1. `send(input, key)` admits the send: `authorized`, basis
|
|
892
|
+
`session_authorized`, `nextAction` `none`; nothing is asked of the Wallet
|
|
893
|
+
yet. `replaceSend({ linkedWalletId, operationId, action }, key)` admits a
|
|
894
|
+
speed-up (`speed_up`) or a cancel (`cancel`) of an EVM send the network has
|
|
895
|
+
not included, as a send of its own.
|
|
896
|
+
2. `prepare(operation.id)` has the Wallet prepare the transfer and estimate
|
|
897
|
+
the fee, and answers the operation with what was prepared; calling it again
|
|
898
|
+
answers the same prepared send. The display then carries the network,
|
|
899
|
+
asset, amount, recipient, `comment` where there is one, `preparedSummary`
|
|
900
|
+
and `feeEstimate`: `estimated` with `estimatedFeeRaw` in minimal units of
|
|
901
|
+
the network's coin, paid on top of the amount, or `unavailable`, which is
|
|
902
|
+
never a fee of zero.
|
|
903
|
+
3. `executeSend(operation.id, operation.version, key)`: the SDK reads the
|
|
904
|
+
transport key and reseals the share it keeps, recovering it first in a
|
|
905
|
+
browser that holds none. An app that keeps its own share passes it as a
|
|
906
|
+
fourth argument, resealed (`transportKey()`, `resealDeviceShare()`), and
|
|
907
|
+
the SDK then recovers nothing.
|
|
908
|
+
|
|
909
|
+
The preparation caps the operation at 150 seconds: an `executeSend()` after
|
|
910
|
+
that is `OPERATION_EXPIRED`, and the send is started again. An `executeSend()`
|
|
911
|
+
of a send never prepared is `VERSION_CONFLICT`. `prepare()` can be refused
|
|
912
|
+
`TRANSFER_IN_FLIGHT` (an earlier transfer of the wallet is not in a block yet;
|
|
913
|
+
Ethereum, BNB Smart Chain and TON) or `NETWORK_FEE_TOO_HIGH`, both retryable
|
|
914
|
+
with nothing prepared, and `DEPENDENCY_UNAVAILABLE` when the Wallet or a node
|
|
915
|
+
did not answer in time. An execution the Wallet refuses for good ends the
|
|
916
|
+
operation `failed` with `effectState` `no_effect`.
|
|
917
|
+
|
|
918
|
+
`transferStatus(operationId)` answers for the one transfer the operation
|
|
919
|
+
prepared: `prepared`, `executing`, `broadcasting`, `submitted`, `confirmed`,
|
|
920
|
+
`failed` or `expired`, `final` for the last three. `transactionHash` is
|
|
921
|
+
present once it is signed; `failureReason` is the network's reason, or
|
|
922
|
+
`expired` (never executed, moved nothing), `nonce_used` (another transaction
|
|
923
|
+
took its place: `replacedByOperationId` names the account's own send that did,
|
|
924
|
+
when it was one) or `not_sent` (signed only after its authorization ran out).
|
|
925
|
+
`authorizationExpiresAt` is the deadline of the owner's authorization while
|
|
926
|
+
the transfer is not final; a transfer `submitted` past it ends `confirmed`, or
|
|
927
|
+
`failed` once another transaction of the wallet takes its place. Read it until
|
|
928
|
+
`final`: the first final state is recorded and answered from the record
|
|
929
|
+
afterwards. On Ethereum `confirmed` may wait twelve blocks, on BNB Smart Chain
|
|
930
|
+
a hundred. A send never prepared is `NOT_FOUND`; one whose fate was not read
|
|
931
|
+
before the 30-day material retention is `RESULT_EXPIRED`.
|
|
932
|
+
|
|
933
|
+
A send on TON can carry a comment: 1 to 120 bytes of UTF-8 with no control,
|
|
934
|
+
line-separator or reordering character (`transferComment(value)`,
|
|
935
|
+
`MaxTransferCommentBytes`), sent exactly as given and written into the signed
|
|
936
|
+
transaction for the coin and for a token alike. A comment on any other network
|
|
937
|
+
is refused locally and with `PAYLOAD_NOT_SUPPORTED` by the provider.
|
|
938
|
+
|
|
939
|
+
A replacement spends the same place in the wallet's sequence as the send it
|
|
940
|
+
replaces, so the network includes at most one: read `transferStatus()` of
|
|
941
|
+
both. It counts against the limits like a new transfer, except that a speed-up
|
|
942
|
+
adds nothing to the daily total and a cancel is judged by no limit. Refusals:
|
|
943
|
+
`NOT_FOUND` for an operation that is not the account's send from that wallet,
|
|
944
|
+
`VERSION_CONFLICT` for a send the Wallet has not accepted or whose fate is
|
|
945
|
+
recorded, `PAYLOAD_NOT_SUPPORTED` for a network that is not EVM or a
|
|
946
|
+
replacement the Wallet refuses, `RESULT_EXPIRED` past the material retention.
|
|
947
|
+
|
|
948
|
+
`sends({ limit, cursor })` is the account's history of sends, newest first:
|
|
949
|
+
state, network, asset, recipient, amount, the sending address, the
|
|
950
|
+
`transactionHash` once accepted and `transfer`, the recorded final fate (a row
|
|
951
|
+
with a hash and no `transfer` has not been read final yet). A replacement is
|
|
952
|
+
a row of its own with `replacesOperationId` and `replaceAction`; past the
|
|
953
|
+
material retention a send stays listed with `materialPurged`.
|
|
954
|
+
`sendEvents(send, events)` picks the events of a listed send's transaction on
|
|
955
|
+
the EVM networks, TRON and Solana.
|
|
956
|
+
|
|
957
|
+
### The device share
|
|
958
|
+
|
|
959
|
+
A wallet's key is three shares, any two of which sign; the device share is
|
|
960
|
+
this device's. The SDK keeps it, with the non-extractable device key it is
|
|
961
|
+
sealed to, in the origin's IndexedDB, under the client's configuration, for
|
|
962
|
+
every wallet it made the key for (`createWallet()` without a key) or
|
|
963
|
+
recovered onto this device. On mobile pass a `deviceShares` store in the
|
|
964
|
+
dependencies (`DeviceShareStore`: `load(walletId)` and `save(record)`, with
|
|
965
|
+
records `{ walletId, privateKey, publicKeyHex, deviceShare }` keyed by the
|
|
966
|
+
Wallet's wallet ID, the linked wallet's `externalWalletId`, or a pending
|
|
967
|
+
recovery's `{ walletId, privateKey, publicKeyHex, pendingOperationId,
|
|
968
|
+
executeKey, pendingLinkedWalletId }`; keep every member as given); a web app
|
|
969
|
+
may pass one too.
|
|
970
|
+
|
|
971
|
+
A browser that holds no share of a wallet recovers it the first time a call
|
|
972
|
+
needs it: `executeSend()` without a share, `deviceShareFor()` (Mode C), or
|
|
973
|
+
`recoverDevice()`. The SDK makes a new device key, admits a `wallet.recover`
|
|
974
|
+
of the linked wallet (`linked_wallet_id` and the new key), opens the
|
|
975
|
+
provider's page through `openHosted`, where the person confirms with the
|
|
976
|
+
passkey (the page says "Вернуть доступ к кошельку <address> на этом
|
|
977
|
+
устройстве"), executes it, waits for the Wallet, checks that the recovered
|
|
978
|
+
share opens with the new key and keeps it. The session alone never yields a
|
|
979
|
+
share. Once the recovery succeeds, the share the wallet had in any other
|
|
980
|
+
browser or device stops working. Each kept share carries its device key's
|
|
981
|
+
public key, and before it uses one the SDK reads the device key of the
|
|
982
|
+
wallet's latest creation or recovery from the provider
|
|
983
|
+
(`deviceKey(linkedWalletId)`, `GET /v1/linked-wallets/{id}/device-key`): a
|
|
984
|
+
kept share sealed to another key was replaced elsewhere, and that browser
|
|
985
|
+
recovers too, with one passkey confirmation. A share is current while the
|
|
986
|
+
provider names its key, or names none. The tabs of one browser take turns
|
|
987
|
+
under one Web Lock per wallet: a second tab waits and uses the share the
|
|
988
|
+
first one recovered.
|
|
989
|
+
|
|
990
|
+
A confirmed recovery is kept as pending, with its device key, operation and
|
|
991
|
+
execute key, before it is executed. A recovery whose end the SDK could not
|
|
992
|
+
learn (the execute answer was lost, or the Wallet has not answered within
|
|
993
|
+
about five minutes) fails the call with `device_recovery_failed` and stays
|
|
994
|
+
pending; the next call, in this tab or another, resumes that same operation
|
|
995
|
+
rather than starting another one, also after a reconnection gave the wallet
|
|
996
|
+
another linked wallet ID, and a new recovery starts only once the pending one
|
|
997
|
+
ended without effect, or can have none: never executed, and past its deadline
|
|
998
|
+
or no longer claimable by this sign-in. The provider refuses to execute a recovery
|
|
999
|
+
while another recovery of the wallet is unresolved (`DEPENDENCY_UNAVAILABLE`,
|
|
1000
|
+
retryable): the call fails with that refusal, the recovery stays pending,
|
|
1001
|
+
and a later call executes it with its own key. A share resumed or recovered
|
|
1002
|
+
is read against the provider's device key once more; when another device
|
|
1003
|
+
recovered meanwhile the call recovers once more, then gives up with
|
|
1004
|
+
`device_recovery_failed` rather than chase the other device. A creation's
|
|
1005
|
+
result read late never replaces a recovery another tab kept for the wallet.
|
|
1006
|
+
|
|
1007
|
+
The SDK needs the redirect back from `openHosted` (resolve it with the
|
|
1008
|
+
callback URL, as the hosted window above does) and confirms with the
|
|
1009
|
+
account's newest passkey; `recoverDevice(linkedWalletId, { credentialId })`
|
|
1010
|
+
names another. A press that may lead to a recovery should open the hosted
|
|
1011
|
+
window inside the click: `hasDeviceShare(linkedWalletId)` tells whether this
|
|
1012
|
+
browser holds a current share. `recoverDevice()` recovers only when it holds
|
|
1013
|
+
none and answers whether it did; call it ahead of a transfer to have the
|
|
1014
|
+
passkey asked before the send is prepared (a prepared send lasts 150
|
|
1015
|
+
seconds). `recoverDevice(linkedWalletId, { force: true })` recovers even
|
|
1016
|
+
when a current share is kept.
|
|
1017
|
+
|
|
1018
|
+
The provider's refusals of the recovery come as `AuthorizationError`:
|
|
1019
|
+
`NOT_FOUND` (not the account's linked wallet), `SCOPE_DENIED` (the app is not
|
|
1020
|
+
one the wallet profile serves), `AUTHORIZATION_STALE` (the wallet is no longer
|
|
1021
|
+
active), `CAPABILITY_UNAVAILABLE` (the server does not recover); a refusal of
|
|
1022
|
+
the device-key read surfaces as it comes. The SDK's own
|
|
1023
|
+
are `ClientFlowError`: `device_share_unavailable` (no store),
|
|
1024
|
+
`device_recovery_requires_passkey`, `hosted_callback_unavailable`,
|
|
1025
|
+
`device_recovery_not_confirmed`, `device_recovery_failed`. Nothing is sent
|
|
1026
|
+
after any of them.
|
|
1027
|
+
|
|
1028
|
+
### Limits of a linked wallet
|
|
1029
|
+
|
|
1030
|
+
`limits(linkedWalletId)` reads them with the session alone.
|
|
1031
|
+
`setLimits(linkedWalletId, { ceilings, recipients, revision }, key)` replaces
|
|
1032
|
+
them as a whole (empty `ceilings` and `recipients` remove every limit) and is
|
|
1033
|
+
confirmed with a passkey; the result kind is `wallet_limits`. A ceiling names
|
|
1034
|
+
its network because a token's decimals differ between networks (USDT has 6 on
|
|
1035
|
+
Ethereum and 18 on BNB Smart Chain). `revision` is the revision the change was
|
|
1036
|
+
edited from: when the limits changed since, the change is refused with
|
|
1037
|
+
`VERSION_CONFLICT` and overwrites nothing. A ceiling on a network the wallet
|
|
1038
|
+
does not send on, a pair named twice or a malformed amount is refused before
|
|
1039
|
+
anything is admitted.
|
|
1040
|
+
|
|
1041
|
+
`send()` and `replaceSend()` are refused `LIMIT_PER_TRANSFER_EXCEEDED` or
|
|
1042
|
+
`RECIPIENT_NOT_ALLOWED` at once, unless the same key repeats a send already
|
|
1043
|
+
created; `executeSend()` is judged again against the limits as they are then,
|
|
1044
|
+
with the daily total including sends that may have moved, and may answer any
|
|
1045
|
+
of the three. The Wallet's own signing policies apply on top.
|
|
1046
|
+
|
|
1047
|
+
### Balances
|
|
1048
|
+
|
|
1049
|
+
`linkedWallets().balances(linkedWalletId)` answers, for each network the
|
|
1050
|
+
wallet's key has an address on, the network's coin and the tokens the stand
|
|
1051
|
+
names there, in minimal units with their decimals, read from the provider's
|
|
1052
|
+
own nodes. A network whose node gave nothing is `unavailable`, or `partial`
|
|
1053
|
+
with some amounts; an unread asset has no `amountRaw` and is never zero.
|
|
1054
|
+
Without nodes the read is `CAPABILITY_UNAVAILABLE`. Stellar is not read.
|
|
1055
|
+
|
|
1056
|
+
### Streams reads and wallet events 3.0
|
|
1057
|
+
|
|
1058
|
+
`client.streams()` binds the account's Streams reads to the session
|
|
1059
|
+
(`/v1/wallets/{wallet_id}/registrations` and `/events`); a backend constructs
|
|
1060
|
+
`StreamsReads` from `./streams` with `scope: "service"` and its own
|
|
1061
|
+
authorized GET. Events are read in one shape, wallet event 3.0
|
|
1062
|
+
(`WalletEvent`): `kind` `deposit`, `withdrawal` or `activity` (with
|
|
1063
|
+
`activity_type`), `status` `completed` (in a block), `finalized` (the block is
|
|
1064
|
+
final) or `reverted`, `revision` counting a subject's statuses from `"1"`, and
|
|
1065
|
+
`cursor`, the event's place in the stream. Every subject is told at least
|
|
1066
|
+
twice, completed and then finalized; where a block is final as soon as it
|
|
1067
|
+
exists the two arrive together. `eventTypes` filters a page after it is read
|
|
1068
|
+
without changing its cursor, so a filtered page may be short or empty and
|
|
1069
|
+
still continue: follow `hasMore`.
|
|
1070
|
+
|
|
1071
|
+
`resumeCursor(page)` is the cursor to keep between rounds: `nextCursor` while
|
|
1072
|
+
`hasMore`, then `window.latestCursor`, which returns only what came after the
|
|
1073
|
+
end of the stream as that read saw it. A cursor ends with its session and with
|
|
1074
|
+
a change of the wallet's registrations (`CURSOR_INVALID`: read from the start).
|
|
1075
|
+
An expired boundary is `RESYNC_REQUIRED` with its `window`; the restart is a
|
|
1076
|
+
fresh read without a cursor. `pollStreamsDeposits` and `pollAuthoritative`
|
|
1077
|
+
poll at least two seconds apart and stop at sixty.
|
|
1078
|
+
|
|
1079
|
+
### A backend client of an organization (Mode A)
|
|
1080
|
+
|
|
1081
|
+
A customer backend with a Tenant Access key acts for its organization on the
|
|
1082
|
+
`/v1/service/...` routes (`api/authorization/openapi.yaml`): it creates the
|
|
1083
|
+
organization's wallets, tracks and reads them, sets their limits and sends
|
|
1084
|
+
from them, with no passkey. `examples/backend-client` is such a backend in a
|
|
1085
|
+
small Node program, the counterpart of the demo page; it reuses this
|
|
1086
|
+
package's `resealDeviceShare`.
|
|
1087
|
+
|
|
1088
|
+
## Demo
|
|
1089
|
+
|
|
1090
|
+
`demo/` is a static page that runs the whole user side against a provider:
|
|
1091
|
+
sign-in, the passkey, a wallet, its tracking and events, the connection to the
|
|
1092
|
+
backend's service and its withdrawal, limits, sends with speed-up and cancel,
|
|
1093
|
+
and Mode C. See `demo/README.md`.
|
|
1094
|
+
|
|
1095
|
+
## Developing this package
|
|
1096
|
+
|
|
1097
|
+
```sh
|
|
1098
|
+
pnpm install --frozen-lockfile
|
|
1099
|
+
pnpm run verify # syntax checks, typecheck, build, unit tests
|
|
1100
|
+
npm pack # builds dist and writes shuttle.technology-authorization-<version>.tgz
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
The contract runners `test/http-integration-*.mjs` drive the compiled SDK
|
|
1104
|
+
(`dist/`), the demo page's modules and the backend simulator against the real
|
|
1105
|
+
Authorization HTTP server over TLS, with PostgreSQL, the decision signer,
|
|
1106
|
+
claimer, worker and native Wallet adapter; only the Wallet, Tenant Access's
|
|
1107
|
+
producer side and the stand's nodes are stand-ins, and every answer is checked
|
|
1108
|
+
against the OpenAPI document. The Go tests `TestCompiledSDK*` and
|
|
1109
|
+
`TestBackendSimulator*` in `internal/authorization/store/postgres` and
|
|
1110
|
+
`TestCompiled*` in `internal/authorization/runtime` run them, each building
|
|
1111
|
+
the SDK from the current source. From the repository root:
|
|
1112
|
+
|
|
1113
|
+
```sh
|
|
1114
|
+
TEST_DATABASE_URL=... go test ./internal/authorization/store/postgres -run 'TestCompiledSDK|TestBackendSimulator'
|
|
1115
|
+
TEST_DATABASE_URL=... go test ./internal/authorization/runtime -run 'TestCompiled|TestBuiltSDK'
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
The required local linked-wallet acceptance runs from the repository root with
|
|
1119
|
+
prepared offline tools and a separate SDK dependency isolate (Go 1.26.7,
|
|
1120
|
+
Node 24.19.0, PostgreSQL 18.6); it installs and downloads nothing:
|
|
1121
|
+
|
|
1122
|
+
```sh
|
|
1123
|
+
make authorization-linked-wallet-local GO="$GO_BIN" NODE="$NODE_BIN" \
|
|
1124
|
+
PG_BINDIR="$PG18_BINDIR" AUTH_SDK_DEPENDENCY_ISOLATE="$SDK_DEPENDENCY_ISOLATE"
|
|
1125
|
+
```
|