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