@shuttle.technology/authorization 0.1.0 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +1082 -666
  3. package/dist/accountLists.d.ts +28 -0
  4. package/dist/accountLists.js +27 -0
  5. package/dist/auth.d.ts +11 -11
  6. package/dist/auth.js +28 -54
  7. package/dist/client.d.ts +62 -174
  8. package/dist/client.js +381 -354
  9. package/dist/clientTypes.d.ts +56 -0
  10. package/dist/clientTypes.js +5 -0
  11. package/dist/configuration.d.ts +48 -0
  12. package/dist/configuration.js +72 -0
  13. package/dist/consentDebit.d.ts +36 -0
  14. package/dist/consentDebit.js +95 -0
  15. package/dist/credentials.d.ts +116 -0
  16. package/dist/credentials.js +315 -0
  17. package/dist/debitPermissionWire.d.ts +90 -0
  18. package/dist/debitPermissionWire.js +118 -0
  19. package/dist/debitPermissions.d.ts +39 -0
  20. package/dist/debitPermissions.js +117 -0
  21. package/dist/deviceRecovery.d.ts +40 -0
  22. package/dist/deviceRecovery.js +143 -0
  23. package/dist/deviceShares.d.ts +91 -0
  24. package/dist/deviceShares.js +204 -0
  25. package/dist/errors.d.ts +280 -0
  26. package/dist/errors.js +85 -0
  27. package/dist/linkedRecovery.d.ts +40 -0
  28. package/dist/linkedRecovery.js +49 -0
  29. package/dist/linkedRegistrations.d.ts +11 -0
  30. package/dist/linkedRegistrations.js +109 -0
  31. package/dist/linkedWallets.d.ts +46 -18
  32. package/dist/linkedWallets.js +85 -120
  33. package/dist/me.d.ts +27 -0
  34. package/dist/me.js +30 -0
  35. package/dist/mobile.d.ts +64 -0
  36. package/dist/mobile.js +69 -0
  37. package/dist/redirects.d.ts +45 -0
  38. package/dist/redirects.js +108 -0
  39. package/dist/serviceGrants.d.ts +52 -0
  40. package/dist/serviceGrants.js +46 -0
  41. package/dist/slice4.d.ts +20 -6
  42. package/dist/slice4.js +22 -8
  43. package/dist/streams.js +1 -1
  44. package/dist/walletCreateGrant.d.ts +62 -0
  45. package/dist/walletCreateGrant.js +89 -0
  46. package/dist/walletDisplayParts.d.ts +2 -0
  47. package/dist/walletDisplayParts.js +4 -0
  48. package/dist/walletOperations.d.ts +23 -6
  49. package/dist/walletOperations.js +31 -22
  50. package/dist/webRenewal.d.ts +92 -0
  51. package/dist/webRenewal.js +179 -0
  52. package/package.json +25 -23
package/README.md CHANGED
@@ -1,709 +1,1125 @@
1
- # Streams Authorization SDK
1
+ # @shuttle.technology/authorization
2
2
 
3
- This package is the framework-independent TypeScript client for the public
4
- Mode B authorization flow. It holds access tokens in memory. Web refresh uses
5
- the provider's secure HTTP-only cookie; mobile refresh tokens are available
6
- only through the required secure-storage port.
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.
7
9
 
8
- ## Installation
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
9
17
 
10
18
  ```sh
11
- npm install @shuttle.technology/authorization
19
+ npm install @shuttle.technology/authorization@0.2.0
12
20
  ```
13
21
 
14
- The package ships compiled ESM and TypeScript declarations. Its public entry
15
- points are:
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.
16
52
 
17
53
  ```ts
18
54
  import { initializeAuthorizationClient } from "@shuttle.technology/authorization";
19
- import { signMediatedRequest } from "@shuttle.technology/authorization/mode-c";
20
- import { signLinkedMediatedRequest } from "@shuttle.technology/authorization/mode-c-linked";
21
- import { StreamsReads } from "@shuttle.technology/authorization/streams";
22
- import { LinkedWallets } from "@shuttle.technology/authorization/linked-wallets";
23
- import { generateDeviceKey } from "@shuttle.technology/authorization/device-key";
24
- import { resealDeviceShare } from "@shuttle.technology/authorization/device-share";
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 });
25
66
  ```
26
67
 
27
- Publishing the package changes only how applications install it. It does not
28
- add a server-managed custody mode. Mode A still requires the customer backend
29
- to retain its device key and the sealed device share, then reseal and submit
30
- that share when a wallet operation is executed.
31
-
32
- ## Web initialization
33
-
34
- `initializeAuthorizationClient` checks the current HTTPS application origin,
35
- then reads `GET /v1/public/clients/{id}`. It accepts only the exact configured
36
- client, environment, method set, provider origin, redirect, and web platform.
37
- Initialization creates no session authority.
38
-
39
- Web restore is a top-level handoff. `restoreSession` keeps a fresh state and
40
- S256 verifier in memory, then calls `openHosted` with the provider restore URL.
41
- The host must open a separate top-level browsing context and return the exact
42
- registered-app redirect URL after completion. A popup is one valid host
43
- implementation. Full-page navigation that destroys the calling SDK instance
44
- is not supported silently: if the host cannot return the redirect,
45
- `restoreSession` fails locally. The SDK never stores restore state, verifier,
46
- access token, CSRF token, or refresh cookie in browser storage.
47
-
48
- ## Mobile host boundary
49
-
50
- Mobile configuration embeds only public provider configuration and a public
51
- bundle/package identifier. The caller must supply:
52
-
53
- - `MobileBrowserBridge`, implemented by the host/native layer;
54
- - `MobileSecureStorage`, backed by platform-protected storage.
55
-
56
- `MobileBrowserBridge.verifyAssociatedRedirect` must ask the operating system
57
- to validate the registered HTTPS app/universal-link association for the exact
58
- redirect URL and configured bundle/package identifier. The same bridge opens
59
- the system browser. There is no default verifier, claimed bundle header,
60
- custom-scheme fallback, or embedded webview.
61
-
62
- The structured verification result is a host-port contract. It is not
63
- cryptographic proof generated by this TypeScript package. A missing bridge,
64
- unavailable or failing OS check, or any redirect/application mismatch fails
65
- locally before an authentication request or browser launch.
66
-
67
- If secure storage cannot persist a rotated mobile refresh successor, the SDK
68
- deletes the predecessor best-effort, clears its in-memory session, and reports
69
- `secure_storage_unavailable`. This prevents the SDK from knowingly retaining a
70
- consumed predecessor. A failing storage provider can prevent proof that the
71
- predecessor was erased; recovery is a new login, and the SDK does not report
72
- server revocation from this local failure.
73
-
74
- ## Passkey proof and local operations
75
-
76
- `bootstrapPasskey` and `verifyIdentity` start provider-hosted flows with a
77
- fresh state and outer S256 verifier held only in memory. The host browser bridge
78
- opens the returned HTTPS location and later delivers the exact registered-app
79
- redirect to `completePasskeyBootstrap` or `completeIdentityVerification`.
80
- Those redirects contain only one-use `code`, `state`, and `flow_id` values. A
81
- hosted URL or flow ID alone is never authority, and neither web refresh cookies
82
- nor mobile refresh storage are used as confirmation authority.
83
-
84
- New passkey verification is a server-hosted registration followed by a
85
- separate possession assertion. Fresh operation confirmation instead uses an
86
- already linked passkey. The SDK requests that challenge, opens the server
87
- confirmation page, validates the returned `state` and `operation_id`, and then
88
- rereads authoritative operation status. It never accepts caller-supplied
89
- WebAuthn options, auto-confirms, or auto-executes an operation.
90
-
91
- The Slice 4 local-operation helpers cover `identity.link`, `identity.unlink`,
92
- `session.revoke`, and `service_grant.set`. Creation, confirmation, execution,
93
- and protected result reads remain separate calls. Write failures are returned
94
- to the caller without automatic retries; `executeOperation` requires the
95
- explicit operation version and idempotency key. Safe results expose only
96
- `kind`, `targetId`, canonical `newVersion`, and `state`.
97
-
98
- `createOperation` accepts the source-defined optional closed
99
- `result_recipient`. Omitting it requests the owner-account default. Supplying
100
- it does not grant result access: the server must resolve and authorize that
101
- account or service before freezing the operation, and later rechecks current
102
- recipient authority on the protected result read.
103
-
104
- ## Mode C: request-key enrollment and typed request proofs
105
-
106
- `src/modeC.ts` is the package's second entry point (`@shuttle.technology/authorization/mode-c`).
107
- It exposes exactly two client responsibilities and nothing else.
108
-
109
- `generateRequestSigningKey` creates the non-extractable P-256 WebCrypto key of
110
- source §2.5 and exports only its `kty/crv/x/y` JWK and RFC 7638 thumbprint. The
111
- private `CryptoKey` never leaves that object, refuses `exportKey`, and is held
112
- only in memory. `buildRequestGrantEnrollment` produces the closed
113
- `session.authorize_requests` payload; `enrollRequestGrant` drives that operation
114
- through the four separate calls the operation contract requires — create, fresh
115
- provider-hosted passkey confirmation, explicit execute, and the protected
116
- result read — behind one function. Enrollment never runs from a refresh, reload
117
- or login callback.
118
-
119
- `RequestGrantStore` retains one protected grant result beside the exact key that
120
- produced it, in memory only. It refuses to pair a result whose RFC 7638
121
- thumbprint, owner, service, wallet, network or bounds disagree with the
122
- enrollment that was submitted, and it reports
123
- `request_grant_reenrollment_required` when the pairing is absent, released or
124
- past its deadline. It never renews, re-enrolls or serializes the grant.
125
-
126
- `prepareMediatedTransfer` derives the displayed typed intent and the exact
127
- closed `OperationCreate` from the grant binding: the caller supplies only the
128
- recipient, amount and fee, and every value outside the frozen allowlist and
129
- ceilings is refused locally. `signMediatedRequest` rederives that intent from
130
- the grant and refuses a display that disagrees with the signed body before any
131
- crypto call; it then canonicalizes with RFC 8785, takes the lowercase SHA-256
132
- `request_hash`, mints a 256-bit `jti` that is also the `Idempotency-Key`, bounds
133
- `exp` to sixty seconds and the grant deadline, and produces the compact ES256
134
- JWS with the closed `{alg,typ,kid}` protected header. It accepts no bytes, no
135
- payload and no proof from a caller, so arbitrary or backend-supplied material is
136
- rejected with `opaque_signing_input_refused` and never signed.
137
-
138
- `ModeCServerClock` carries the authenticated server time: it is seeded from the
139
- grant result's `server_time` and refreshed only from a current `/v1/me`
140
- response. Clock disagreement is resolved by adopting that reading and signing a
141
- new proof with a new `jti` while the grant is still live; no local override or
142
- tolerance exists.
143
-
144
- Forwarding and refusal are the customer backend's, not the SDK's. This package
145
- exports no forward, approve, refuse or backend-signing helper, and proof
146
- generation performs no HTTP. `test/customerBackendFixture.ts` is a test-only
147
- stand-in for that backend. Operation status and result reads use the generic
148
- helpers in `client.ts`.
149
-
150
- `session.authorize_requests` is not served yet, so `enrollRequestGrant` is
151
- exercised against the operation contract rather than the live route; the
152
- runtime harness supplies the protected grant result through its fixture and the
153
- SDK's own payload builder, result parser and pairing rules still run unchanged.
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
+ ```
154
94
 
155
- ### Mode C on a linked wallet
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
+ ```
156
110
 
157
- `src/modeCLinked.ts` (`@shuttle.technology/authorization/mode-c-linked`) is Mode C for the
158
- wallets a person created through Authorization (owner decision of 14 October
159
- 2026). The person allows, once and with the passkey, transfers their app signs
160
- and their app's backend forwards; each transfer then goes with no passkey.
161
-
162
- 1. `generateRequestSigningKey` makes the key; `buildLinkedRequestGrantEnrollment`
163
- makes the closed `session.authorize_requests` body: the linked wallet, the
164
- network the transfers are made on, one to three assets (`native`, `usdt`,
165
- `usdc`) each with the most one transfer may move (`maxAmountRaw`), the most
166
- the network fee may be (`maxFeeRaw`, compared with the Wallet's estimate when
167
- the transfer is prepared) and up to ten recipients, none for any (each
168
- compared as the provider compares addresses: EVM without case, TON in its
169
- raw form). `client.createOperation()` admits it,
170
- `challengeOperation()` and `completeOperationConfirmation()` take the passkey,
171
- `executeOperation()` issues the grant and `operationResult()` returns it
172
- (`result.grant`). `pairLinkedRequestGrant` keeps it beside its key and refuses
173
- a grant on other bounds. The grant lives at most 15 minutes, never past the
174
- session, and survives the person's later sign-ins and confirmations with the
175
- same passkey; removing that passkey ends it.
176
- 2. `prepareLinkedMediatedTransfer(paired, {asset, recipient, amountRaw, comment?})`
177
- is the transfer the app shows; what the grant does not cover is refused
178
- locally. A comment is carried on TON only.
179
- 3. The app reads the Wallet's transport key (`linkedWallets().transportKey()`)
180
- and reseals the device share for this transfer (`resealDeviceShare`), then
181
- `signLinkedMediatedRequest({grant, prepared, deviceShare, crypto,
182
- serverNowSeconds})` signs the 2.0 proof over the transfer and over the
183
- SHA-256 of the share (`deviceShareDigest`), and returns `request`
184
- (`{operation, user_proof, device_share}`) and `idempotencyKey` (the proof's
185
- `jti`). The backend forwards `request` unchanged with its service key and
186
- that Idempotency-Key, within the proof's minute, to
187
- `POST /v1/service/mediated-operations`
188
- (`examples/backend-client`: `cli.ts mediate <file>`).
189
-
190
- The backend cannot change the transfer, swap the share or sign a new request.
191
- The wallet's own limits apply to every transfer.
192
-
193
- The person reads a forwarded transfer on their own session like any send:
194
- `client.walletOperation(id)` and `linkedWallets().sends()` read it in the
195
- mediated projection the provider serves for it (`src/mediatedOperation.ts`):
196
- `authorizationBasis` `user_request_signed`, `linkedWallet` naming the wallet,
197
- and a display with no result recipient (`MediatedLinkedSendDisplay`; a
198
- transfer from a wallet bound in the provider has `walletId` and a
199
- `MediatedSendDisplay`). `transferStatus()` and `replaceSend()` work for it as
200
- for a session send.
201
-
202
- ## Creating a wallet and reaching it
203
-
204
- `src/deviceKey.ts` is the package's device-key entry point
205
- (`@shuttle.technology/authorization/device-key`). `generateDeviceKey` creates a
206
- non-extractable ECDH P-256 key pair and returns its compressed public key, the
207
- `recipientPublicKey` of `createWallet`. The module persists nothing: the
208
- application keeps the private key handle and the sealed device share it later
209
- receives in storage that can hold a non-extractable key.
210
-
211
- A completed `wallet.create` releases a `wallet_created` result through
212
- `walletOperationResult`: the public wallet identity and the device share
213
- exactly as the own-wallet system sealed it. The provider never opens the share.
214
- Where the server has no native Wallet profile the creation stays
215
- `CAPABILITY_UNAVAILABLE`, as before.
216
-
217
- `linkedWallets().refresh()` binds the account to the Wallet identity the
218
- provider owns for it and synchronizes the public wallet inventory. It carries
219
- no Wallet token; `challenge`, `connect` and `sync` with a caller-supplied token
220
- remain for deployments without that profile.
221
-
222
- Tracking a linked wallet needs no passkey: it signs with no wallet key, so the
223
- account's session authorizes it. `linkedWallets().register()` comes back
224
- `authorized` with the basis `session_authorized`, and
225
- `executeRegistration(operation.id, operation.version, key)` dispatches it; no
226
- hosted step takes part. A registration covers one network. An EVM wallet has
227
- one address on every EVM network, so register it once per EVM network;
228
- `events()` then returns the events of all of them, each with its
229
- `networkId`.
230
-
231
- `register()` takes an optional `activityTypes`: the kinds of activity events
232
- asked for beside deposits, for example `"transaction"` or `"erc20.approval"`.
233
- It accepts at most 16 distinct kinds, each matching
234
- `^[a-z][a-z0-9]*(?:[._][a-z0-9]+)*$`, and refuses any other value with
235
- `invalid_operation_create` before a request is sent. `activity_types` is sent
236
- only when the list is not empty; omitted or empty means deposits only. The
237
- server sorts the kinds. Streams decides which kinds a network has and refuses
238
- the registration otherwise. `createRegistration()` accepts the same list as
239
- `payload.activity_types`.
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).
240
115
 
241
- `linkedWallets().signals(linkedWalletIds, { onOpen, onWallets, signal })`
242
- opens one signal stream for 1 to 32 linked wallets and reads it to its end. On
243
- the stream Authorization names the wallets that have new events; it carries no
244
- event, and the wallets named are read with `events()`. `onOpen` says the
245
- stream is live: read every wallet once then, so that nothing falls between
246
- that read and the first signal. `onWallets` receives the wallets to read now.
247
- The promise resolves with why the stream ended: `expired`, `server_draining`
248
- or `connection_closed`. A stream lives no longer than an access token, so the
249
- caller opens the next one; the SDK renews the token when needed. The promise
250
- rejects with the server's refusal, with the abort reason, or when the
251
- connection fails or the answer is not this protocol. A signal is a hint: keep
252
- a timed read, only slower, for the times no stream is open. `demo/live.js`
253
- shows the loop.
254
-
255
- A send from a linked wallet needs no passkey either (owner decision, 14
256
- October 2026): the account's live session authorizes it. A passkey is still
257
- asked at sign-in, when a wallet is created and when its limits change
258
- (below). A send takes three calls, which the application makes one after
259
- another; the demo page makes all three on one press of «Отправить», with no
260
- confirmation step (Tony's decision, 15 October 2026), and says the fee
261
- estimate once the transfer was sent:
262
-
263
- 1. `linkedWallets().send(input, key)` admits it. It comes back `authorized`
264
- with the basis `session_authorized`, `nextAction` `none` and no
265
- `confirmationLocation`; nothing is asked of the own-wallet system yet.
266
- 2. `linkedWallets().prepare(operation.id)` has the own-wallet system prepare
267
- the transfer and name its estimate of the network fee, and answers the
268
- operation with what was prepared. It takes no body, key or version; the
269
- transfer is prepared once, and calling it again answers the same prepared
270
- send. The display then carries `network`, `asset`, `amountRaw`,
271
- `recipient`, `comment` where there is one, `preparedSummary` and the fee.
272
- 3. Read `transportKey()`, reseal the device share to it
273
- (`resealDeviceShare`) and call `executeSend(operation.id,
274
- operation.version, key, share)`.
275
-
276
- The display carries `feeEstimate` from the preparation on: `estimated` with
277
- `estimatedFeeRaw`, in minimal units of the network's own coin, or
278
- `unavailable` where that system could estimate none, which is never a fee of
279
- zero. The fee is paid on top of the amount, also for a token. It is an
280
- estimate: it does not bound what the network takes. The preparation caps the
281
- operation at 150 seconds: an `executeSend()` after that is refused with
282
- `OPERATION_EXPIRED` and the send is started again with `send()`. An `executeSend()` of a send
283
- that was never prepared is refused with `VERSION_CONFLICT` before anything is
284
- claimed. A send an earlier version of the provider admitted that still waits
285
- for its passkey is confirmed the old way (`challengeWalletOperation()`, the hosted
286
- page, `completeWalletOperationConfirmation()`); `prepare()` refuses it with
287
- `VERSION_CONFLICT`.
288
-
289
- A send from a linked wallet (`send()`, `prepare()`, `executeSend()`) ends as
290
- an operation when the own-wallet system
291
- accepted the transfer and named its hash: the operation is `succeeded` and its
292
- result carries the hash as `receiptId`. What the network then did with the
293
- transfer is read with `linkedWallets().transferStatus(operationId)`. It answers
294
- for the one transfer that operation prepared, with the own-wallet system's
295
- state of it: `prepared`, `executing`, `broadcasting`, `submitted`, `confirmed`,
296
- `failed` or `expired`. `final` is true for the last three. `transactionHash` is
297
- present once the transfer is signed; `failureReason` is the network's reason
298
- for a failure, when it gave one: its error code, or the own-wallet system's
299
- `expired`, `nonce_used` or `bounced`. `expired` means the transfer was never
300
- executed and moved no funds. Read it until `final`: while the state can still
301
- change the own-wallet system is asked on every read; the first final state is
302
- recorded by the provider and answered from the record afterwards. A send that
303
- was never prepared has no transfer and answers `NOT_FOUND`; a transfer whose
304
- fate was never read final before the 30-day material retention removed its
305
- identifier answers `RESULT_EXPIRED`; an own-wallet system that did not answer
306
- is `DEPENDENCY_UNAVAILABLE` and worth repeating.
307
-
308
- The preparation of a send can be refused in two ways that pass by themselves;
309
- both are `retryable` with `nextAction` `retry_same_request`, nothing is
310
- prepared, and the same `prepare()` succeeds later:
311
-
312
- - `TRANSFER_IN_FLIGHT` (409): an earlier transfer of the same wallet is not in
313
- a block yet and holds the wallet's place in the network's sequence. It comes
314
- on Ethereum, BNB Smart Chain and TON from an own-wallet system that sends one
315
- transfer of a wallet at a time.
316
- - `NETWORK_FEE_TOO_HIGH` (503): the network fee is above the ceiling the
317
- own-wallet system sends at. The preparation of a replacement answers it too.
318
-
319
- A preparation the own-wallet system or its network node could not serve in time
320
- answers `DEPENDENCY_UNAVAILABLE` (503), also to be repeated. It no longer
321
- answers `CAPABILITY_UNAVAILABLE`, which is left for a network the provider
322
- itself sends nothing on.
323
-
324
- An execution the own-wallet system refuses for good ends the operation
325
- `failed` with `effectState` `no_effect`: another transfer of the wallet took
326
- this one's place before it was signed, the place a replacement would take is
327
- already used (the replaced transfer went through or was replaced), or the
328
- node refused the transaction. Start the send again where it is still wanted.
329
-
330
- `transferStatus()` also tells:
331
-
332
- - `authorizationExpiresAt`, while the transfer is not final: the deadline of
333
- the owner's authorization. Past it the own-wallet system signs and sends
334
- nothing more for this transfer, but a transaction the network already has
335
- can still be included. A transfer that is `submitted` past that instant is
336
- neither done nor safely gone: it ends `confirmed`, or `failed` once another
337
- transaction of the wallet takes its place. On an EVM network it can be sped
338
- up or cancelled (`replaceSend()`).
339
- - `replacedByOperationId`, with `failureReason` `nonce_used`: the account's own
340
- send whose transaction the network included instead, its speed-up or
341
- cancellation or the next transfer of the same wallet. The funds may have
342
- moved by that send. It is absent when the place was taken from outside the
343
- own-wallet system, or by a send older than the account's last hundred. A
344
- listed send carries it in `transfer.replacedByOperationId`.
345
- - `failureReason` `not_sent`: the transfer was signed only after its
346
- authorization ran out and was never sent.
347
-
348
- On Ethereum an own-wallet system may report `confirmed` only when the block is
349
- twelve blocks deep, about two and a half minutes, and on BNB Smart Chain a
350
- hundred blocks deep: keep reading `submitted` for that long.
351
-
352
- A send on TON can carry a comment, the text an exchange or a custodian credits
353
- a deposit by: `linkedWallets().send({ ..., comment })`. It is 1 to 120 bytes
354
- of UTF-8 with no control character, line separator or character that reorders
355
- text (`transferComment(value)` is the rule, `MaxTransferCommentBytes` the
356
- bound), and it is sent exactly as given, spaces included. The own-wallet
357
- system writes it into the signed transaction, for the coin and for a token
358
- alike. The provider accepts the preparation only when it carries exactly that
359
- text and freezes it with the transfer: the display carries `comment` and the
360
- prepared summary quotes it. A comment on any other network is refused before any request by
361
- the SDK and with `PAYLOAD_NOT_SUPPORTED` by the provider. Stellar memos are
362
- not sent. A listed send (`sends()`) carries its `comment` too.
363
-
364
- A send on an EVM network that the network has not included can be replaced:
365
- `linkedWallets().replaceSend({ linkedWalletId, operationId, action }, key)`,
366
- where `operationId` is the account's own send from that wallet and `action` is
367
- `speed_up`, the same transfer again with a higher fee, or `cancel`, nothing
368
- sent to the wallet's own address in its place. The request names nothing of
369
- what is moved: the provider takes it from the transfer frozen in that
370
- operation. The answer is a `wallet.send` operation of its own, whose display
371
- carries `replacesOperationId` and `replaceAction`, and for a cancel
372
- `amountRaw` `"0"` of `native` to the wallet's own address. It is authorized
373
- by the session, prepared with `prepare()` and executed with `executeSend()`
374
- exactly like a send (on the demo page, on one press of «Ускорить» or
375
- «Отменить»); the own-wallet system prepares
376
- the replacement at `prepare()`, and the display then carries its fee
377
- estimate. Both transactions spend the same place
378
- in the wallet's sequence, so the network includes at most one: read
379
- `transferStatus()` of both, the other ends `failed`. The own-wallet system
380
- counts a replacement against the wallet's limits like a new transfer. Refusals:
381
- `NOT_FOUND` for an operation that is not the account's send from that wallet;
382
- `VERSION_CONFLICT` for a send the own-wallet system has not accepted, one whose
383
- final fate is recorded, and, at `prepare()`, one that system says no longer
384
- waits; `PAYLOAD_NOT_SUPPORTED` for a network that is not EVM and for a
385
- replacement that system refuses, for instance when the coin does not cover the
386
- higher fee; `RESULT_EXPIRED` once the send's material retention has passed. A
387
- replacement that waits can itself be replaced the same way.
116
+ ### Confirming an operation
388
117
 
389
- ### Limits of a linked wallet
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:
390
121
 
391
- Every send from a linked wallet obeys the wallet's own limits (owner decision
392
- of 14 October 2026): for each network and asset (`native`, `usdt`, `usdc`) an
393
- optional ceiling on one transfer (`perTransferRaw`) and on what the wallet's
394
- sends of that asset on that network may move in 24 hours (`perDayRaw`), in
395
- that network's minimal units, and a list of recipients, where an empty list
396
- means any recipient. A ceiling names its network because a token's decimals
397
- differ between networks (USDT has 6 on Ethereum and 18 on BNB Smart Chain);
398
- a send is judged only by the ceiling of its own network and asset. A wallet
399
- whose limits were never set (`revision` `"0"`) bounds nothing.
400
-
401
- - `linkedWallets().limits(linkedWalletId)` reads them with the session alone.
402
- - `linkedWallets().setLimits(linkedWalletId, { ceilings, recipients,
403
- revision }, key)` asks to replace them as a whole; empty `ceilings` and
404
- `recipients` remove every limit. `revision` is the revision of the limits
405
- the change was edited from, as `limits()` read it: when the limits changed
406
- since, in another tab for instance, the change is refused with
407
- `VERSION_CONFLICT` when it is created or when it is executed, and never
408
- overwrites them; read them again and redo the change. The answer is a local operation of kind `wallet_limits.set`,
409
- `awaiting_confirmation` with `nextAction` `use_passkey`, whose display is
410
- the wallet's address and the new limits as the provider will keep them
411
- (recipients in the form the network compares addresses by). Nothing changes
412
- until the owner confirms it with a passkey: `challengeOperation(id,
413
- credentialId)` opens the provider's page, which says the new limits in
414
- words, `completeOperationConfirmation(callback)` reads the confirmed
415
- operation, and `executeOperation(id, version, key)` applies it; the result
416
- kind is `wallet_limits`. A ceiling on a network the wallet does not send on,
417
- a network and asset named twice, or a malformed amount is refused before
418
- anything is admitted; another account's wallet is `NOT_FOUND`.
419
-
420
- A send the limits do not allow is refused with `403` and one of
421
- `WalletLimitRefusals`; nothing is sent, and the same send goes through only
422
- after the limits change:
423
-
424
- - `LIMIT_PER_TRANSFER_EXCEEDED`: the amount is over the ceiling of one
425
- transfer of its asset on its network.
426
- - `LIMIT_DAILY_EXCEEDED`: with what the wallet's sends of that asset on that
427
- network moved, or may have moved, in the last 24 hours, the send would pass
428
- the daily ceiling.
429
- - `RECIPIENT_NOT_ALLOWED`: the wallet has a recipient list and the recipient
430
- is not in it.
431
-
432
- `send()` and `replaceSend()` are answered `LIMIT_PER_TRANSFER_EXCEEDED` or
433
- `RECIPIENT_NOT_ALLOWED` at once, unless the same key repeats a send already
434
- created, which answers that send whatever the limits say now;
435
- `executeSend()` is judged again against the limits as they are then, with
436
- the daily total, and may answer any of the three. A speed-up is judged by
437
- the ceiling of one transfer and the list and adds nothing to the daily
438
- total, where the transfer it replaces is counted, but is refused when the
439
- day already holds more than the daily ceiling as it is now; a cancel is
440
- judged by none. `isWalletLimitRefusal(code)` tells these refusals
441
- from the others. The own-wallet system's own signing policies still apply on
442
- top of them.
443
-
444
- `linkedWallets().balances(linkedWalletId)` answers what a linked wallet holds:
445
- for each network its key has an address on, the network's own coin and the
446
- tokens the stand names there, in minimal units with their decimals. The
447
- provider reads its own stand's nodes, so the application hands the address to
448
- no node operator and needs no token list of its own. An EVM wallet has one
449
- entry for each EVM network of the stand. A network whose node gave nothing is
450
- in the answer with `status` `unavailable`, or `partial` when it gave some
451
- amounts, and an unread asset has no `amountRaw`: it is never zero. The assets
452
- are always named. Where the server has no nodes configured the read is
453
- `CAPABILITY_UNAVAILABLE`. Stellar is not read.
454
-
455
- `linkedWallets().sends({ limit, cursor })` is the account's history of sends,
456
- newest first: one page of `GET /v1/operations?kind=wallet.send`. Each row
457
- carries the send's state, its network, asset, recipient and amount in minimal
458
- units, the sending address of a linked wallet, its `transactionHash` once the
459
- own-wallet system accepted it, and `transfer`, the recorded final fate. A row
460
- with a hash and no `transfer` has not been read final yet: `transferStatus()`
461
- reads it and records it. A replacement is a row of its own with
462
- `replacesOperationId` and `replaceAction`. Listing asks nothing of the own-wallet system. Past
463
- the material retention a send stays listed with `materialPurged` and without
464
- details. The route lists every kind of account operation and takes any one
465
- `kind`; the SDK has a typed reader for sends only.
466
-
467
- `sendEvents(send, events)` picks from wallet events those of a listed send's
468
- transaction: its withdrawal and any activity of the same transaction.
469
- `eventOfTransaction(event, networkId, transactionHash)` is the test it uses. It
470
- answers on the EVM networks and TRON, where an event's `sourceReference` is the
471
- transaction hash in hexadecimal, and on Solana, where both are the
472
- transaction's signature in base58; it answers `undefined` elsewhere. On TON an
473
- event names the transaction the network made and the own-wallet system names
474
- the message it sent, so nothing ties the two yet.
475
-
476
- Solana is tracked like every other network once the stand's Streams serves it:
477
- `register({ linkedWalletId, network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" })`
478
- for a Solana wallet, whose address is taken as the own-wallet system gives it.
479
- Its assets are `<network>/slip44:501` for SOL and `<network>/spl:<mint>` for a
480
- token; its activity types are `transaction`, `native.transfer`, `spl.transfer`,
481
- `spl.approval`, `contract.raw`, `contract.decoded` and `native.action`. Its
482
- events are wallet events 3.0 with the transaction's signature as
483
- `sourceReference`.
484
-
485
- `demo/` is a static page that walks the whole path; see `demo/README.md`.
486
-
487
- ## Authorized Streams reads
488
-
489
- `src/streams.ts` is the package's third entry point
490
- (`@shuttle.technology/authorization/streams`). It owns the client half of the four served
491
- reads and nothing else: it builds their exact `/v1` paths, enforces the page
492
- bounds before a request leaves the client, and parses the two closed responses.
493
-
494
- `AuthorizationClient.streams()` binds those reads to the current session, so an
495
- account caller reuses one access-token and refresh path and addresses
496
- `/v1/wallets/{wallet_id}/registrations` and `/events`. A service caller is a
497
- customer backend holding a tenant-access service credential, which this package
498
- never holds: it constructs `StreamsReads` with `scope: "service"` and its own
499
- authorized GET, exactly as the Mode C customer backend supplies its own
500
- forwarding, and addresses the `/v1/service/wallets/{wallet_id}` variants.
501
-
502
- The registrations route releases the permitted set in one page and issues no
503
- cursor, so the helper accepts only a limit and refuses a cursor locally. The
504
- events route accepts a cursor, a limit and the filter `eventTypes`; a wrapper
505
- carries its own original limit, so resuming omits the limit and repeating a
506
- different one is the server's `CURSOR_INVALID`. Every limit outside `[1,100]` is refused before a request
507
- exists, and the default page is 50.
508
-
509
- A page that has no next page still tells where to read on from:
510
- `window.latestCursor` is accepted as `cursor` and returns only what came after
511
- the end of the stream as that read saw it. `resumeCursor(page)` is the cursor
512
- to keep between rounds: `nextCursor` while `hasMore` is true, `latestCursor`
513
- after. A client walks a history once and from then on asks only for what is
514
- new. `window.earliestCursor` is never accepted as a cursor. Every cursor ends
515
- with its session and with a change of the wallet's registrations; a refused
516
- one is `CURSOR_INVALID` and the history is read from its start again. The same
517
- holds for `linkedWallets().events()`.
518
-
519
- The parsers are closed. An unknown or missing member, a noncanonical
520
- `amount_raw`, a nonintegral `revision`, a non-UTC `observed_at`, a `next_cursor`
521
- without `has_more` or the reverse, a `reverted` flag that disagrees with its
522
- `reverted_by_event_id`, and a malformed window are all refused rather than
523
- projected. `revision`, `project_cursor`, the reversion fields and every source
524
- position are released exactly as Streams owns them; no balance or finality is
525
- inferred and no event is reconstructed.
526
-
527
- ### Wallet events 3.0: one shape for deposits, withdrawals and activity
528
-
529
- Events are read in one shape, the wallet event 3.0 (`WalletEvent`,
530
- `parseWalletEvent`, in `src/walletEvents.ts`):
531
- `client.linkedWallets().events(id, options)` for a linked wallet,
532
- `events(walletId, options)` on the account and service reads. There is no
533
- other read of events: the stored shapes the older `deposits()` released
534
- (`"2.0"`, `"activity:1.0"`, `"activity:2.0"`) are gone, and a page that
535
- carries one is refused.
536
-
537
- `eventTypes` filters by kind: `["deposit"]`, `["withdrawal"]`,
538
- `["deposit","withdrawal"]`, `["activity"]`, or `"activity:<type>"` for one
539
- type of activity, in any combination; left out, every kind. The filter is
540
- applied to the page that was read and does not change its cursor, so a
541
- filtered page may hold fewer events than the limit, or none, and still
542
- continue: follow `hasMore`, not the number of events.
543
-
544
- ```json
545
- {"contract_version":"3.0","event_id":"project-event:v1:…:1","cursor":"2.1",
546
- "kind":"deposit","status":"completed","revision":"1",
547
- "subject_id":"deposit-id:v1:…","registration_id":"…","network_id":"eip155:1",
548
- "address":"0x…","asset":"eip155:1/slip44:60","raw_amount":"10000000000000",
549
- "source_reference":"0x…","observed_at":"2026-10-09T09:54:47Z"}
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
+ }
550
132
  ```
551
133
 
552
- - `kind` is `deposit` (value came in), `withdrawal` (value went out) or
553
- `activity` (anything else; it has `activity_type` and may have a body).
554
- - `status` is `completed` (it is in a block), `finalized` (the block is final)
555
- or `reverted` (the block was replaced; `invalidation_reason` may say why).
556
- - `revision` counts a subject's statuses from `"1"`; `subject_id` is what they
557
- are about. completed is always revision 1.
558
- - `cursor` is the event's own place in the stream, an opaque text no two
559
- events share. A stream of wallet events is continued after it.
560
-
561
- Every subject is told at least twice, completed and then finalized, on every
562
- network. Where a block is final as soon as it exists the two arrive together;
563
- on Ethereum a deposit is completed within seconds and finalized thirteen to
564
- nineteen minutes later. A client that acts once a transfer is in a block waits
565
- for `completed` and may ignore what follows it, except `reverted`.
566
-
567
- What is told today: a deposit, a withdrawal and an activity on every network.
568
- On Ethereum all three are completed within seconds and finalized later; an
569
- activity's completed event carries its payload up to 16 KiB. BNB Smart Chain,
570
- TRON and TON are read as each block becomes final, so there both revisions of
571
- any event arrive together.
572
-
573
- The page is `WalletEventPage`. One stored event gives no event, one or two, so
574
- a page holds at most twice the limit asked for.
575
-
576
- Read a deposit's `status` as where the transfer stands, not as whether it
577
- happened. `pending` is the fact: the transfer lies, successful, in a block of
578
- the chain as it stands, and the recipient can already spend it; show it to the
579
- user as received. `confirmed` comes after the fact and adds that the block is
580
- final and cannot be replaced; it is the moment to credit a balance kept outside
581
- the chain. `detected` precedes both, and `reverted` ends a deposit whose block
582
- was replaced before it became final. On Ethereum a block becomes final thirteen
583
- to nineteen minutes after it appears.
584
-
585
- An activity event carries no status. On Ethereum it is released when its block
586
- is final, so an outgoing transfer of a tracked wallet is seen that much later
587
- than it happened; on BNB Smart Chain, TRON and TON the wait is seconds.
588
-
589
- An expired boundary is the server's `RESYNC_REQUIRED`, which the SDK rethrows
590
- unchanged and never retries. `streamsResync(error)` returns the typed authorized
591
- window, and `parseAuthorizationError` already refuses a window on any other code
592
- and a `RESYNC_REQUIRED` without one. Those two values are window wrappers, not
593
- pagination wrappers: the server binds each released wrapper to its own boundary
594
- and answers `CURSOR_INVALID` for a substituted one, so the restart is a fresh
595
- cursorless read. The SDK never converts a window value into a cursor and never
596
- invents a position.
597
-
598
- `pollStreamsDeposits` follows a page until the server itself reports no further
599
- retained work. It adds no bound of its own and delegates to the same
600
- `pollAuthoritative` helper, so at-least-two-second intervals and the
601
- sixty-second ceiling stay enforced in one place, and cancellation or timeout
602
- returns the last observed page with its local disposition rather than a claimed
603
- server outcome.
604
-
605
- ## A backend client of an organization (Mode A)
606
-
607
- This package is the client of a person's session. A customer's backend that
608
- holds a Tenant Access key acts for its organization instead (owner decision,
609
- 14 October 2026): it creates the organization's wallets with a device key it
610
- keeps itself, tracks them, reads them, their balances, events and its own
611
- operations, sets each wallet's limits (per asset a ceiling on one transfer
612
- and on 24 hours, and a recipient list where the empty list means any
613
- recipient) and sends the network coin, USDT and USDC from them with a
614
- prepare and an execute that carries the device share, with no passkey. The
615
- service routes are `/v1/service/operations` (with `/prepare`) and
616
- `/v1/service/wallets` (with `/balances`, `/transport-key`, `/limits`,
617
- `/registrations`, `/events`); see `api/authorization/openapi.yaml`.
618
-
619
- `examples/backend-client` is that backend in a small Node program, the
620
- backend counterpart of the demo page. It reuses this package's device share
621
- crypto (`resealDeviceShare`) and keeps the device key, extractable, in a
622
- private state file. Its README says how to run it and what the stand's
623
- operator must set first. `test/backendClient.test.ts` covers its pure parts.
624
-
625
- ## Local verification
626
-
627
- Use the package-local pinned toolchain:
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.
628
140
 
629
- ```sh
630
- pnpm install --frozen-lockfile
631
- pnpm run verify
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 }]
632
185
  ```
633
186
 
634
- `verify` runs syntax checks, full TypeScript checking (including fixtures), a
635
- clean JavaScript build, and package tests.
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.
636
196
 
637
- Release packaging is checked from an installed tarball with the reviewed npm
638
- CLI. The smoke check imports every public entry point, compiles a TypeScript
639
- consumer against the installed declarations, verifies that consumer installs
640
- do not include TypeScript, and rejects unexpected tarball files:
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:
641
200
 
642
- ```sh
643
- NPM_CLI_JS=/absolute/path/to/npm-cli.js pnpm run package:smoke
201
+ ```ts
202
+ const response = await fetch("https://backend.example.com/orders", {
203
+ headers: { Authorization: `Bearer ${await client.accessToken()}` },
204
+ });
644
205
  ```
645
206
 
646
- The PostgreSQL-backed HTTP integration harness builds first, then invokes:
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`:
647
242
 
648
- ```sh
649
- pnpm run build
650
- pnpm run integration:http -- --fixture /absolute/path/to/sdk-fixture.json
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"
651
360
  ```
652
361
 
653
- The fixture runner accepts only local TLS endpoints rooted in its supplied CA.
654
- It drives the compiled SDK through public initialization, Google signup,
655
- handoff exchange, safe reads, logout and revoked-token denial, then logs in the
656
- same signed provider subject and verifies that it resolves to the same account.
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:
657
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
+ ```
658
421
 
659
- ### Contract runners against the real Authorization server
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.
660
427
 
661
- `test/http-integration-{linked-send,limits,modec-linked,mode-a,demo}.mjs` drive
662
- the compiled SDK (`dist/`), the backend simulator's own modules
663
- (`examples/backend-client/`) and the demo page's (`demo/*.js`, without the DOM)
664
- against the real Authorization HTTP server
665
- over TLS, composed as the runtime composes it, with PostgreSQL, the decision
666
- signer, claimer, worker and native Wallet adapter; only the Wallet, Tenant
667
- Access's producer side (behind the shipped Tenant Access client) and the
668
- stand's nodes are stand-ins. The Go tests `TestCompiledSDK*` and
669
- `TestBackendSimulatorModeA*` in `internal/authorization/store/postgres` run
670
- them, each compiling the SDK from the current source into a scratch directory
671
- with the installed TypeScript compiler (`tests/authorization/tooling/sdkrunner`),
672
- with the Node of `NODE_BIN` or of `PATH`:
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
673
1096
 
674
1097
  ```sh
675
1098
  pnpm install --frozen-lockfile
676
- TEST_DATABASE_URL=... go test ../../internal/authorization/store/postgres -run 'TestCompiledSDK|TestBackendSimulator'
1099
+ pnpm run verify # syntax checks, typecheck, build, unit tests
1100
+ npm pack # builds dist and writes shuttle.technology-authorization-<version>.tgz
677
1101
  ```
678
1102
 
679
- Each runner prints one summary line; the Go side checks that the runner
680
- loads the SDK only from `dist/`, that the build it reports is the build it
681
- compiled, and that every HTTP call it counted came from that build. Every
682
- answer the server gives in these flows is also checked against the committed
683
- OpenAPI document. The joined linked-wallet test
684
- (`internal/authorization/wallethttp`, `test/http-integration-linked-wallets.mjs`)
685
- runs the same way, with the Node of `AUTH_SDK_NODE_BIN` or of `PATH`, and uses
686
- the build of `AUTH_SDK_WORKDIR` when the local acceptance below prepared one.
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:
687
1112
 
688
- ### Required local linked-wallet acceptance
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
+ ```
689
1117
 
690
- From repository root, using already prepared offline tools/dependencies:
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:
691
1121
 
692
1122
  ```sh
693
1123
  make authorization-linked-wallet-local GO="$GO_BIN" NODE="$NODE_BIN" \
694
1124
  PG_BINDIR="$PG18_BINDIR" AUTH_SDK_DEPENDENCY_ISOLATE="$SDK_DEPENDENCY_ISOLATE"
695
1125
  ```
696
-
697
- Provide Go1.26.7, Node24.19.0, PostgreSQL18.6 and an explicit separate SDK dependency
698
- isolate prepared for the current package/lock. No install or download runs. The target
699
- checks dependency manifest/installed lock/version provenance, copies current worktree
700
- SDK source/config/runner to new scratch and builds fresh dist with the prepared tsc.
701
- Unused isolate src/dist never supply execution code. Its dependency symlink is trusted
702
- reuse, not a filesystem read-only sandbox; existing dependencies are not mutated.
703
-
704
- The disposable-PG race run requires the actual SDK/TLS/Auth/Streams test plus direct
705
- and lost-response/store-reopen cases, each present and passed with zero skips. Missing
706
- inputs, stale dependency manifests, omitted tests or a skipped result fail the command.
707
- Generic suites retain optional SDK behavior; this explicit target is mandatory local
708
- proof. Source effects/events are synthetic and passkey is virtual crypto. It needs no
709
- external pilot pins or lookup and makes no deployed-provider/browser/indexer claim.