@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.
- package/CHANGELOG.md +191 -0
- package/README.md +1082 -666
- package/dist/accountLists.d.ts +28 -0
- package/dist/accountLists.js +27 -0
- package/dist/auth.d.ts +11 -11
- package/dist/auth.js +28 -54
- package/dist/client.d.ts +62 -174
- package/dist/client.js +381 -354
- package/dist/clientTypes.d.ts +56 -0
- package/dist/clientTypes.js +5 -0
- package/dist/configuration.d.ts +48 -0
- package/dist/configuration.js +72 -0
- package/dist/consentDebit.d.ts +36 -0
- package/dist/consentDebit.js +95 -0
- package/dist/credentials.d.ts +116 -0
- package/dist/credentials.js +315 -0
- package/dist/debitPermissionWire.d.ts +90 -0
- package/dist/debitPermissionWire.js +118 -0
- package/dist/debitPermissions.d.ts +39 -0
- package/dist/debitPermissions.js +117 -0
- package/dist/deviceRecovery.d.ts +40 -0
- package/dist/deviceRecovery.js +143 -0
- package/dist/deviceShares.d.ts +91 -0
- package/dist/deviceShares.js +204 -0
- package/dist/errors.d.ts +280 -0
- package/dist/errors.js +85 -0
- package/dist/linkedRecovery.d.ts +40 -0
- package/dist/linkedRecovery.js +49 -0
- package/dist/linkedRegistrations.d.ts +11 -0
- package/dist/linkedRegistrations.js +109 -0
- package/dist/linkedWallets.d.ts +46 -18
- package/dist/linkedWallets.js +85 -120
- package/dist/me.d.ts +27 -0
- package/dist/me.js +30 -0
- package/dist/mobile.d.ts +64 -0
- package/dist/mobile.js +69 -0
- package/dist/redirects.d.ts +45 -0
- package/dist/redirects.js +108 -0
- package/dist/serviceGrants.d.ts +52 -0
- package/dist/serviceGrants.js +46 -0
- package/dist/slice4.d.ts +20 -6
- package/dist/slice4.js +22 -8
- package/dist/streams.js +1 -1
- package/dist/walletCreateGrant.d.ts +62 -0
- package/dist/walletCreateGrant.js +89 -0
- package/dist/walletDisplayParts.d.ts +2 -0
- package/dist/walletDisplayParts.js +4 -0
- package/dist/walletOperations.d.ts +23 -6
- package/dist/walletOperations.js +31 -22
- package/dist/webRenewal.d.ts +92 -0
- package/dist/webRenewal.js +179 -0
- package/package.json +25 -23
package/README.md
CHANGED
|
@@ -1,709 +1,1125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shuttle.technology/authorization
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
the
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
`
|
|
635
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
```
|
|
643
|
-
|
|
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
|
|
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
|
-
```
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|