@shuttle.technology/authorization 0.0.0-stage → 0.2.0

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