@v3finance/core-sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +397 -7
  2. package/README.md +215 -72
  3. package/dist/cjs/client.d.ts +67 -22
  4. package/dist/cjs/client.d.ts.map +1 -1
  5. package/dist/cjs/client.js +38 -2
  6. package/dist/cjs/client.js.map +1 -1
  7. package/dist/cjs/core/errors.d.ts +30 -10
  8. package/dist/cjs/core/errors.d.ts.map +1 -1
  9. package/dist/cjs/core/errors.js +47 -15
  10. package/dist/cjs/core/errors.js.map +1 -1
  11. package/dist/cjs/core/gated.d.ts +21 -3
  12. package/dist/cjs/core/gated.d.ts.map +1 -1
  13. package/dist/cjs/core/gated.js +12 -3
  14. package/dist/cjs/core/gated.js.map +1 -1
  15. package/dist/cjs/core/http.d.ts +12 -8
  16. package/dist/cjs/core/http.d.ts.map +1 -1
  17. package/dist/cjs/core/http.js +130 -40
  18. package/dist/cjs/core/http.js.map +1 -1
  19. package/dist/cjs/core/pagination.d.ts +28 -1
  20. package/dist/cjs/core/pagination.d.ts.map +1 -1
  21. package/dist/cjs/core/pagination.js +36 -1
  22. package/dist/cjs/core/pagination.js.map +1 -1
  23. package/dist/cjs/generated/operations.js +1 -1
  24. package/dist/cjs/generated/types.d.ts +948 -172
  25. package/dist/cjs/generated/types.d.ts.map +1 -1
  26. package/dist/cjs/index.d.ts +32 -8
  27. package/dist/cjs/index.d.ts.map +1 -1
  28. package/dist/cjs/index.js +12 -0
  29. package/dist/cjs/index.js.map +1 -1
  30. package/dist/cjs/resources/addresses.d.ts +22 -8
  31. package/dist/cjs/resources/addresses.d.ts.map +1 -1
  32. package/dist/cjs/resources/addresses.js +7 -1
  33. package/dist/cjs/resources/addresses.js.map +1 -1
  34. package/dist/cjs/resources/admin.d.ts +150 -25
  35. package/dist/cjs/resources/admin.d.ts.map +1 -1
  36. package/dist/cjs/resources/admin.js +94 -23
  37. package/dist/cjs/resources/admin.js.map +1 -1
  38. package/dist/cjs/resources/approvals.d.ts +46 -5
  39. package/dist/cjs/resources/approvals.d.ts.map +1 -1
  40. package/dist/cjs/resources/approvals.js +27 -4
  41. package/dist/cjs/resources/approvals.js.map +1 -1
  42. package/dist/cjs/resources/balances.d.ts +152 -16
  43. package/dist/cjs/resources/balances.d.ts.map +1 -1
  44. package/dist/cjs/resources/balances.js +9 -5
  45. package/dist/cjs/resources/balances.js.map +1 -1
  46. package/dist/cjs/resources/categories.d.ts +93 -21
  47. package/dist/cjs/resources/categories.d.ts.map +1 -1
  48. package/dist/cjs/resources/categories.js +65 -18
  49. package/dist/cjs/resources/categories.js.map +1 -1
  50. package/dist/cjs/resources/counterparties.d.ts +26 -3
  51. package/dist/cjs/resources/counterparties.d.ts.map +1 -1
  52. package/dist/cjs/resources/counterparties.js +17 -0
  53. package/dist/cjs/resources/counterparties.js.map +1 -1
  54. package/dist/cjs/resources/folders.d.ts +51 -10
  55. package/dist/cjs/resources/folders.d.ts.map +1 -1
  56. package/dist/cjs/resources/folders.js +36 -5
  57. package/dist/cjs/resources/folders.js.map +1 -1
  58. package/dist/cjs/resources/history.d.ts +20 -2
  59. package/dist/cjs/resources/history.d.ts.map +1 -1
  60. package/dist/cjs/resources/history.js.map +1 -1
  61. package/dist/cjs/resources/me.d.ts +27 -4
  62. package/dist/cjs/resources/me.d.ts.map +1 -1
  63. package/dist/cjs/resources/me.js +22 -1
  64. package/dist/cjs/resources/me.js.map +1 -1
  65. package/dist/cjs/resources/members.d.ts +19 -4
  66. package/dist/cjs/resources/members.d.ts.map +1 -1
  67. package/dist/cjs/resources/members.js +15 -6
  68. package/dist/cjs/resources/members.js.map +1 -1
  69. package/dist/cjs/resources/operations.d.ts +64 -8
  70. package/dist/cjs/resources/operations.d.ts.map +1 -1
  71. package/dist/cjs/resources/operations.js +26 -2
  72. package/dist/cjs/resources/operations.js.map +1 -1
  73. package/dist/cjs/resources/registries.d.ts +87 -9
  74. package/dist/cjs/resources/registries.d.ts.map +1 -1
  75. package/dist/cjs/resources/registries.js +54 -11
  76. package/dist/cjs/resources/registries.js.map +1 -1
  77. package/dist/cjs/resources/reports.d.ts +300 -41
  78. package/dist/cjs/resources/reports.d.ts.map +1 -1
  79. package/dist/cjs/resources/reports.js +135 -7
  80. package/dist/cjs/resources/reports.js.map +1 -1
  81. package/dist/cjs/resources/sharing.d.ts +61 -10
  82. package/dist/cjs/resources/sharing.d.ts.map +1 -1
  83. package/dist/cjs/resources/sharing.js +51 -4
  84. package/dist/cjs/resources/sharing.js.map +1 -1
  85. package/dist/cjs/resources/transfers.d.ts +26 -4
  86. package/dist/cjs/resources/transfers.d.ts.map +1 -1
  87. package/dist/cjs/resources/transfers.js +23 -1
  88. package/dist/cjs/resources/transfers.js.map +1 -1
  89. package/dist/cjs/resources/types.d.ts +20 -0
  90. package/dist/cjs/resources/types.d.ts.map +1 -1
  91. package/dist/cjs/units.d.ts.map +1 -1
  92. package/dist/cjs/units.js +18 -2
  93. package/dist/cjs/units.js.map +1 -1
  94. package/dist/cjs/version.d.ts +1 -1
  95. package/dist/cjs/version.js +1 -1
  96. package/dist/esm/client.d.ts +67 -22
  97. package/dist/esm/client.d.ts.map +1 -1
  98. package/dist/esm/client.js +38 -2
  99. package/dist/esm/client.js.map +1 -1
  100. package/dist/esm/core/errors.d.ts +30 -10
  101. package/dist/esm/core/errors.d.ts.map +1 -1
  102. package/dist/esm/core/errors.js +47 -15
  103. package/dist/esm/core/errors.js.map +1 -1
  104. package/dist/esm/core/gated.d.ts +21 -3
  105. package/dist/esm/core/gated.d.ts.map +1 -1
  106. package/dist/esm/core/gated.js +12 -3
  107. package/dist/esm/core/gated.js.map +1 -1
  108. package/dist/esm/core/http.d.ts +12 -8
  109. package/dist/esm/core/http.d.ts.map +1 -1
  110. package/dist/esm/core/http.js +131 -41
  111. package/dist/esm/core/http.js.map +1 -1
  112. package/dist/esm/core/pagination.d.ts +28 -1
  113. package/dist/esm/core/pagination.d.ts.map +1 -1
  114. package/dist/esm/core/pagination.js +36 -1
  115. package/dist/esm/core/pagination.js.map +1 -1
  116. package/dist/esm/generated/operations.js +1 -1
  117. package/dist/esm/generated/types.d.ts +948 -172
  118. package/dist/esm/generated/types.d.ts.map +1 -1
  119. package/dist/esm/index.d.ts +32 -8
  120. package/dist/esm/index.d.ts.map +1 -1
  121. package/dist/esm/index.js +17 -5
  122. package/dist/esm/index.js.map +1 -1
  123. package/dist/esm/resources/addresses.d.ts +22 -8
  124. package/dist/esm/resources/addresses.d.ts.map +1 -1
  125. package/dist/esm/resources/addresses.js +7 -1
  126. package/dist/esm/resources/addresses.js.map +1 -1
  127. package/dist/esm/resources/admin.d.ts +150 -25
  128. package/dist/esm/resources/admin.d.ts.map +1 -1
  129. package/dist/esm/resources/admin.js +94 -23
  130. package/dist/esm/resources/admin.js.map +1 -1
  131. package/dist/esm/resources/approvals.d.ts +46 -5
  132. package/dist/esm/resources/approvals.d.ts.map +1 -1
  133. package/dist/esm/resources/approvals.js +27 -4
  134. package/dist/esm/resources/approvals.js.map +1 -1
  135. package/dist/esm/resources/balances.d.ts +152 -16
  136. package/dist/esm/resources/balances.d.ts.map +1 -1
  137. package/dist/esm/resources/balances.js +9 -5
  138. package/dist/esm/resources/balances.js.map +1 -1
  139. package/dist/esm/resources/categories.d.ts +93 -21
  140. package/dist/esm/resources/categories.d.ts.map +1 -1
  141. package/dist/esm/resources/categories.js +65 -18
  142. package/dist/esm/resources/categories.js.map +1 -1
  143. package/dist/esm/resources/counterparties.d.ts +26 -3
  144. package/dist/esm/resources/counterparties.d.ts.map +1 -1
  145. package/dist/esm/resources/counterparties.js +17 -0
  146. package/dist/esm/resources/counterparties.js.map +1 -1
  147. package/dist/esm/resources/folders.d.ts +51 -10
  148. package/dist/esm/resources/folders.d.ts.map +1 -1
  149. package/dist/esm/resources/folders.js +36 -5
  150. package/dist/esm/resources/folders.js.map +1 -1
  151. package/dist/esm/resources/history.d.ts +20 -2
  152. package/dist/esm/resources/history.d.ts.map +1 -1
  153. package/dist/esm/resources/history.js.map +1 -1
  154. package/dist/esm/resources/me.d.ts +27 -4
  155. package/dist/esm/resources/me.d.ts.map +1 -1
  156. package/dist/esm/resources/me.js +22 -1
  157. package/dist/esm/resources/me.js.map +1 -1
  158. package/dist/esm/resources/members.d.ts +19 -4
  159. package/dist/esm/resources/members.d.ts.map +1 -1
  160. package/dist/esm/resources/members.js +15 -6
  161. package/dist/esm/resources/members.js.map +1 -1
  162. package/dist/esm/resources/operations.d.ts +64 -8
  163. package/dist/esm/resources/operations.d.ts.map +1 -1
  164. package/dist/esm/resources/operations.js +26 -2
  165. package/dist/esm/resources/operations.js.map +1 -1
  166. package/dist/esm/resources/registries.d.ts +87 -9
  167. package/dist/esm/resources/registries.d.ts.map +1 -1
  168. package/dist/esm/resources/registries.js +54 -11
  169. package/dist/esm/resources/registries.js.map +1 -1
  170. package/dist/esm/resources/reports.d.ts +300 -41
  171. package/dist/esm/resources/reports.d.ts.map +1 -1
  172. package/dist/esm/resources/reports.js +135 -7
  173. package/dist/esm/resources/reports.js.map +1 -1
  174. package/dist/esm/resources/sharing.d.ts +61 -10
  175. package/dist/esm/resources/sharing.d.ts.map +1 -1
  176. package/dist/esm/resources/sharing.js +51 -4
  177. package/dist/esm/resources/sharing.js.map +1 -1
  178. package/dist/esm/resources/transfers.d.ts +26 -4
  179. package/dist/esm/resources/transfers.d.ts.map +1 -1
  180. package/dist/esm/resources/transfers.js +23 -1
  181. package/dist/esm/resources/transfers.js.map +1 -1
  182. package/dist/esm/resources/types.d.ts +20 -0
  183. package/dist/esm/resources/types.d.ts.map +1 -1
  184. package/dist/esm/units.d.ts.map +1 -1
  185. package/dist/esm/units.js +18 -2
  186. package/dist/esm/units.js.map +1 -1
  187. package/dist/esm/version.d.ts +1 -1
  188. package/dist/esm/version.js +1 -1
  189. package/docs/concepts.md +191 -0
  190. package/docs/errors.md +471 -0
  191. package/package.json +7 -6
  192. package/src/client.ts +89 -25
  193. package/src/core/errors.ts +49 -15
  194. package/src/core/gated.ts +22 -4
  195. package/src/core/http.ts +156 -46
  196. package/src/core/pagination.ts +38 -1
  197. package/src/generated/operations.ts +1 -1
  198. package/src/generated/types.ts +983 -173
  199. package/src/index.ts +64 -7
  200. package/src/resources/addresses.ts +23 -8
  201. package/src/resources/admin.ts +186 -33
  202. package/src/resources/approvals.ts +55 -9
  203. package/src/resources/balances.ts +186 -14
  204. package/src/resources/categories.ts +125 -30
  205. package/src/resources/counterparties.ts +23 -6
  206. package/src/resources/folders.ts +74 -18
  207. package/src/resources/history.ts +27 -2
  208. package/src/resources/me.ts +30 -5
  209. package/src/resources/members.ts +26 -7
  210. package/src/resources/operations.ts +73 -8
  211. package/src/resources/registries.ts +90 -11
  212. package/src/resources/reports.ts +348 -67
  213. package/src/resources/sharing.ts +71 -18
  214. package/src/resources/transfers.ts +33 -5
  215. package/src/resources/types.ts +20 -0
  216. package/src/units.ts +19 -2
  217. package/src/version.ts +1 -1
package/src/client.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { HttpClient, seg, type ClientOptions } from './core/http.js'
2
- import type { CreateVaultBody, Health, VaultDetail } from './generated/types.js'
2
+ import type {
3
+ BuildPathBody,
4
+ BuildPathResponse,
5
+ CreateVaultBody,
6
+ Health,
7
+ ParsePathBody,
8
+ ParsedPathResponse,
9
+ UserProfile,
10
+ VaultDetail,
11
+ VaultListItem,
12
+ } from './generated/types.js'
3
13
  import { AddressesResource } from './resources/addresses.js'
4
14
  import { ApprovalsResource } from './resources/approvals.js'
5
15
  import { BalancesResource } from './resources/balances.js'
@@ -73,22 +83,31 @@ export interface VaultScope {
73
83
  readonly operations: OperationsResource
74
84
  }
75
85
 
76
- export interface VaultSummary {
77
- id: string
78
- code: string
79
- name: string
80
- [extra: string]: unknown
81
- }
86
+ /**
87
+ * A vault as `vaults.list()` returns it.
88
+ *
89
+ * Listing and detail are DIFFERENT shapes, so this name can only stand for
90
+ * one of them. A list item carries `counts` (groups, invitations, policies,
91
+ * members) and a stripped-down `networks` (id, code, name, coinType); the
92
+ * detail response carries full `Network` records and the whole `routes` tree,
93
+ * and no counts at all. `vaults.get()` is therefore typed `VaultDetail`, not
94
+ * this.
95
+ *
96
+ * @deprecated Use `VaultListItem` directly. The name survives only because
97
+ * dropping an exported one breaks consumers: it used to be a hand-written
98
+ * interface that declared `id`, `code` and `name` and swept everything else
99
+ * into an index signature, which described neither shape — `counts` and
100
+ * `routes` alike arrived as `unknown`.
101
+ */
102
+ export type VaultSummary = VaultListItem
82
103
 
83
- export interface Profile {
84
- id: string
85
- name: string | null
86
- username: string | null
87
- email: string | null
88
- /** The account came from an invitation link and has no real email yet. */
89
- needsEmail: boolean
90
- [extra: string]: unknown
91
- }
104
+ /**
105
+ * The caller's own profile — what `me.profile()` and `me.updateProfile()`
106
+ * both answer with.
107
+ *
108
+ * @deprecated Use `UserProfile` directly; the name is kept for consumers.
109
+ */
110
+ export type Profile = UserProfile
92
111
 
93
112
  export class CoreClient {
94
113
  /** Low-level access for endpoints the resources do not cover yet. */
@@ -124,11 +143,25 @@ export class CoreClient {
124
143
  }
125
144
 
126
145
  readonly vaults = {
127
- /** Administrators only: a plain member sees their own vault through `me.vault()`. */
128
- list: (): Promise<VaultSummary[]> => this.http.request<VaultSummary[]>('GET', '/api/v1/vaults'),
146
+ /**
147
+ * Administrators only: a plain member sees their own vault through
148
+ * `me.vault()`. Not paginated and not filterable — the whole list comes
149
+ * back, newest first.
150
+ */
151
+ list: (): Promise<VaultListItem[]> =>
152
+ this.http.request<VaultListItem[]>('GET', '/api/v1/vaults'),
129
153
 
130
- get: (code: string): Promise<VaultSummary> =>
131
- this.http.request<VaultSummary>('GET', `/api/v1/vaults/${seg(code)}`),
154
+ /**
155
+ * Full configuration of one vault: every connected network and the whole
156
+ * `routes` tree, disabled variants included.
157
+ *
158
+ * Open to any MEMBER of the vault, unlike `list()`, which is
159
+ * administrator-only (an administrator passes the membership check too).
160
+ * The richer payload is also why the return type differs from a list
161
+ * item: see `VaultSummary`.
162
+ */
163
+ get: (code: string): Promise<VaultDetail> =>
164
+ this.http.request<VaultDetail>('GET', `/api/v1/vaults/${seg(code)}`),
132
165
 
133
166
  /** Administrators only. */
134
167
  create: (input: CreateVaultBody): Promise<VaultDetail> =>
@@ -140,15 +173,46 @@ export class CoreClient {
140
173
  * Use these rather than building the string yourself: the path is written
141
174
  * WITHOUT apostrophes (`m/44/195/…`, not `m/44'/…`), and the segment
142
175
  * order comes from the vault's own configuration, not from a convention.
176
+ *
177
+ * Both are open to any MEMBER of the vault; they are not
178
+ * administrator-only.
179
+ *
180
+ * `values` is keyed by `Route.code` and the generated type says only
181
+ * `unknown` per entry, because the schema's `string | number` union does
182
+ * not survive generation. What the server accepts: for a static route a
183
+ * variant `code` or its numeric `pathId`, for a dynamic route an integer
184
+ * in `[0, 2^31-1]`. A missing route, an unknown variant or a disabled one
185
+ * is a 400, not a silently skipped segment.
143
186
  */
144
- buildDerivationPath: (code: string, body: unknown): Promise<unknown> =>
145
- this.http.request('POST', `/api/v1/vaults/${seg(code)}/derivation-path/build`, { body }),
187
+ buildDerivationPath: (code: string, body: BuildPathBody): Promise<BuildPathResponse> =>
188
+ this.http.request<BuildPathResponse>(
189
+ 'POST',
190
+ `/api/v1/vaults/${seg(code)}/derivation-path/build`,
191
+ { body },
192
+ ),
146
193
 
147
- parseDerivationPath: (code: string, body: unknown): Promise<unknown> =>
148
- this.http.request('POST', `/api/v1/vaults/${seg(code)}/derivation-path/parse`, { body }),
194
+ /**
195
+ * Breaks a path back into the network and one entry per route.
196
+ *
197
+ * `segments[].variant` is absent for a dynamic route — that is the normal
198
+ * case, not a lookup failure. For a static route it is present even when
199
+ * the variant is disabled, so that legacy addresses still parse.
200
+ */
201
+ parseDerivationPath: (code: string, body: ParsePathBody): Promise<ParsedPathResponse> =>
202
+ this.http.request<ParsedPathResponse>(
203
+ 'POST',
204
+ `/api/v1/vaults/${seg(code)}/derivation-path/parse`,
205
+ { body },
206
+ ),
149
207
  }
150
208
 
151
- /** Liveness probe. Needs no authentication. */
209
+ /**
210
+ * Liveness probe. Needs no authentication.
211
+ *
212
+ * It answers "is the worker deployed at all?" and nothing more: the handler
213
+ * never touches the database, so a healthy answer does NOT mean D1 is
214
+ * reachable. Use a real read for that.
215
+ */
152
216
  health(): Promise<Health> {
153
217
  return this.http.request<Health>('GET', '/')
154
218
  }
@@ -5,14 +5,30 @@
5
5
  * wrapper has three gaps the client has to close itself — otherwise a precise
6
6
  * error turns into "something went wrong":
7
7
  *
8
- * 1. Bodies without an `error` key pass through untouched. Batch sharing
9
- * endpoints answer 400 with `{results:[…]}`, which is not a failure of
10
- * the whole call but "some subjects were rejected", and has to be read
11
- * item by item.
12
- * 2. Validation failures arrive as a plain string — `{error: "path: msg"}`
13
- * — with no code and no details.
14
- * 3. Inside a batch result `message` equals the code (`{code:"X",
15
- * message:"X"}`), so it must never be shown to a person as-is.
8
+ * 1. Bodies without an `error` key pass through untouched — the wrapper
9
+ * keys off that one field. Batch sharing endpoints answer 400 with
10
+ * `{results:[…]}`, which is not a failure of the whole call but "some
11
+ * subjects were rejected", and has to be read item by item.
12
+ * 2. Non-JSON bodies have no wrapper at all: the layer leaves by
13
+ * content-type, and a thrown `HTTPException` carries its own response
14
+ * past it. So a 502 from the edge, a WAF 403 or a platform 429 arrive as
15
+ * text. `core/http.ts` reduces a short one to a flat `{error: "…"}`
16
+ * (dropping HTML pages) before it reaches this parser, which is why the
17
+ * flat string shape below is still read — and why the per-status
18
+ * fallback wording here is what a person ends up seeing.
19
+ * 3. `message` sometimes IS the code. Handlers answer `{error: "<CODE>",
20
+ * code: "<CODE>"}` and batch items carry `{code:"X", message:"X"}`; the
21
+ * wrapper copies the former into `message` faithfully. A code is not a
22
+ * sentence, so it must never be shown to a person as-is.
23
+ *
24
+ * What is NOT a gap any more: zod validation failures. The route hook does
25
+ * build a flat `{error: "amount: Required; …"}` (backend
26
+ * `openapi/common.ts`), but that is an intermediate form.
27
+ * `middleware/errorEnvelope` rewraps every JSON body carrying a string
28
+ * `error` into the canonical envelope, `code` derived from the status —
29
+ * `BAD_REQUEST` here, because the hook sets none of its own. The same layer
30
+ * answers an unmatched path and an uncaught throw. Reading `body.error` as a
31
+ * string against today's API therefore hands you an object.
16
32
  *
17
33
  * Because of that `code` is always present here: either the server sent one,
18
34
  * or it is derived from the HTTP status. Branch on `code`, display `message`.
@@ -73,29 +89,37 @@ export function hasCode(e: unknown, ...codes: readonly string[]): boolean {
73
89
  }
74
90
 
75
91
  /**
76
- * Status to code, mirroring the mapping the API uses, so that both sides call
77
- * the same thing by the same name.
92
+ * Status to code, used only when a body carried no code of its own: a proxy's
93
+ * HTML 502, a platform 429, an empty body.
94
+ *
95
+ * The names are the API's own names for the same statuses, so that one
96
+ * condition has one name whichever side produced it. 502 and 504 are the
97
+ * exception — the API has no envelope for them, they arrive from the edge.
78
98
  */
79
99
  export function codeFromStatus(status: number): string {
80
100
  switch (status) {
81
101
  case 400:
82
102
  return 'BAD_REQUEST'
83
103
  case 401:
84
- return 'UNAUTHORIZED'
104
+ return 'UNAUTHENTICATED'
85
105
  case 403:
86
106
  return 'FORBIDDEN'
87
107
  case 404:
88
108
  return 'NOT_FOUND'
89
109
  case 409:
90
110
  return 'CONFLICT'
111
+ case 413:
112
+ return 'PAYLOAD_TOO_LARGE'
91
113
  case 422:
92
- return 'UNPROCESSABLE'
114
+ return 'UNPROCESSABLE_ENTITY'
93
115
  case 429:
94
116
  return 'RATE_LIMITED'
117
+ case 501:
118
+ return 'NOT_IMPLEMENTED'
95
119
  case 502:
96
120
  return 'BAD_GATEWAY'
97
121
  case 503:
98
- return 'UNAVAILABLE'
122
+ return 'SERVICE_UNAVAILABLE'
99
123
  case 504:
100
124
  return 'GATEWAY_TIMEOUT'
101
125
  default:
@@ -139,7 +163,12 @@ export function parseErrorBody(status: number, body: unknown, endpoint?: string)
139
163
  return make(message, code, o.details)
140
164
  }
141
165
 
142
- // Shape 2 — a validation failure as a string: {error: "path: msg; …"}
166
+ // Shape 2 — a flat string `error`. No longer how validation failures reach
167
+ // a client (the envelope rewraps those into Shape 1), so the live producer
168
+ // is `core/http.ts`, which folds a short non-JSON error body into this
169
+ // shape. The sibling `code` is still read: handlers do put one next to a
170
+ // string `error`, and an older deployment without the envelope layer would
171
+ // deliver exactly that.
143
172
  if (typeof inner === 'string' && inner) {
144
173
  const flat = (body as { code?: unknown }).code
145
174
  const code = typeof flat === 'string' && flat ? flat : fallbackCode
@@ -195,7 +224,12 @@ export const timeoutError = (ms: number, endpoint?: string): CoreError =>
195
224
  kind: 'timeout',
196
225
  code: 'TIMEOUT',
197
226
  status: null,
198
- message: `The server did not answer within ${Math.round(ms / 1000)}s`,
227
+ // Sub-second timeouts rounded to `0s`, which reads as "no time was
228
+ // allowed" rather than "400ms was". Below a second the unit changes
229
+ // instead of the number; a whole second keeps the shorter wording.
230
+ message: `The server did not answer within ${
231
+ ms < 1000 ? `${Math.round(ms)}ms` : `${Math.round(ms / 1000)}s`
232
+ }`,
199
233
  ...(endpoint === undefined ? {} : { endpoint }),
200
234
  })
201
235
 
package/src/core/gated.ts CHANGED
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * 201 — applied immediately (policy allowed it without signatures);
6
6
  * 202 — queued for approval, funds are reserved, nothing has moved yet;
7
- * 200 — a replay of the same idempotency key: nothing happened now, the
8
- * existing operation came back (no second broadcast).
7
+ * 200 — on a transfer: a replay of the same idempotency key, nothing
8
+ * happened now and the existing operation came back (no second
9
+ * broadcast). Everywhere else: an ordinary applied success, because
10
+ * an edit is not a creation and answers 200 (see `replayOn200`).
9
11
  *
10
12
  * The body can be identical: a transfer returns `{transaction}` in all three.
11
13
  * A client that reads only the body will say "sent" where the truth is
@@ -19,8 +21,17 @@
19
21
 
20
22
  export type GatedOutcome = 'applied' | 'pending' | 'replayed'
21
23
 
24
+ /**
25
+ * `status` is the status the server ACTUALLY answered, which is why `applied`
26
+ * carries `200 | 201` rather than 201 alone: an ordinary 200 success is an
27
+ * applied operation too (updating a counterparty, editing a label — anything
28
+ * gated that is not a creation). The field used to be hard-coded to 201 for
29
+ * those, so every gated method except `transfers.send` reported a status the
30
+ * server never sent, into logs and error reports where it is the one thing
31
+ * worth trusting. Branch on `outcome`; read `status` when you need the fact.
32
+ */
22
33
  export type Gated<TApplied, TPending = TApplied> =
23
- | { readonly outcome: 'applied'; readonly status: 201; readonly data: TApplied }
34
+ | { readonly outcome: 'applied'; readonly status: 200 | 201; readonly data: TApplied }
24
35
  | { readonly outcome: 'replayed'; readonly status: 200; readonly data: TApplied }
25
36
  | { readonly outcome: 'pending'; readonly status: 202; readonly data: TPending }
26
37
 
@@ -33,6 +44,13 @@ export type Gated<TApplied, TPending = TApplied> =
33
44
  * success. Telling a person "already sent" after a plain edit would be as
34
45
  * wrong as telling them "done" after a replay, so each caller states which
35
46
  * of the two it is instead of the helper guessing.
47
+ *
48
+ * Which also means `outcome: 'replayed'` is UNREACHABLE for every gated
49
+ * method other than `transfers.send`: replay protection needs an idempotency
50
+ * key, and the transfer body is the only place the API takes one. The
51
+ * three-way `switch` in `docs/concepts.md` is right for a transfer; beside a
52
+ * counterparty edit its `case 'replayed'` is dead, and it is not evidence
53
+ * that such an edit can come back as a replay.
36
54
  */
37
55
  export function toGated<TApplied, TPending>(
38
56
  status: number,
@@ -43,7 +61,7 @@ export function toGated<TApplied, TPending>(
43
61
  case 200:
44
62
  return options.replayOn200
45
63
  ? { outcome: 'replayed', status: 200, data: data as TApplied }
46
- : { outcome: 'applied', status: 201, data: data as TApplied }
64
+ : { outcome: 'applied', status: 200, data: data as TApplied }
47
65
  case 201:
48
66
  return { outcome: 'applied', status: 201, data: data as TApplied }
49
67
  case 202:
package/src/core/http.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  abortedError,
3
+ isCoreError,
3
4
  networkError,
4
5
  parseError,
5
6
  parseErrorBody,
@@ -88,15 +89,19 @@ export interface RequestOptions {
88
89
  signal?: AbortSignal | undefined
89
90
  timeoutMs?: number | undefined
90
91
  /**
91
- * Attempt key, sent as the `idempotency-key` header.
92
+ * Attempt key. ACCEPTED AND IGNORED BY THE TRANSPORT — the working place
93
+ * for it is the request BODY, the way `transfers.send` passes it.
92
94
  *
93
- * THE SERVER DOES NOT READ THIS HEADER TODAY. Replay protection on money
94
- * paths is resolved from the `idempotencyKey` FIELD OF THE BODY — that is
95
- * what `transfers.send` uses, and that is what protects it. The header is
96
- * here for the day the API starts honouring it; until then, calling a
97
- * money endpoint through the low-level client and passing the key only
98
- * here buys you nothing, and a retry after a timeout sends the money
99
- * twice. Put the key in the body.
95
+ * It used to go out as an `idempotency-key` header. The server has never
96
+ * read that header, which by itself merely bought nothing; in a browser it
97
+ * cost the whole request. The API's CORS preflight allows exactly
98
+ * `Content-Type` and `Authorization` (`allowHeaders`, backend
99
+ * `src/index.ts`), so the extra header failed the preflight and the call
100
+ * was never sent at all — a money request that could not reach the server,
101
+ * and a "network error" that no retry explains. Replay protection is
102
+ * resolved from the `idempotencyKey` FIELD of the body and nowhere else.
103
+ *
104
+ * @deprecated The transport sends this nowhere. Put the key in the body.
100
105
  */
101
106
  idempotencyKey?: string | undefined
102
107
  headers?: Record<string, string> | undefined
@@ -174,13 +179,20 @@ export class HttpClient {
174
179
  * time. Read it with `.blob()`, `.text()` or `.arrayBuffer()`.
175
180
  */
176
181
  async fetchRaw(method: string, path: string, options: RequestOptions = {}): Promise<Response> {
177
- const { response, endpoint } = await this.#send(method, path, options)
182
+ // The body is touched only when the call FAILED — and then by the same
183
+ // reader a JSON call uses, so one 502 from the edge produces one message
184
+ // whichever method asked for it. The two used to disagree: this one fell
185
+ // back to neutral wording while `requestRaw` put the proxy's HTML page
186
+ // into `message`.
187
+ //
188
+ // On success the response is handed over unread, which also means the
189
+ // timeout ends at the headers here: the file body is the caller's to read
190
+ // and to bound, with their own signal.
191
+ const { response, endpoint, payload } = await this.#send(method, path, options, (r, ep) =>
192
+ r.ok ? Promise.resolve(undefined) : readJsonBody(r, ep),
193
+ )
178
194
  if (!response.ok) {
179
- const body = await response
180
- .clone()
181
- .json()
182
- .catch(() => undefined)
183
- const error = parseErrorBody(response.status, body, endpoint)
195
+ const error = parseErrorBody(response.status, payload, endpoint)
184
196
  if (response.status === 401 && this.#options.onUnauthorized) {
185
197
  await this.#options.onUnauthorized(error)
186
198
  }
@@ -195,26 +207,36 @@ export class HttpClient {
195
207
  path: string,
196
208
  options: RequestOptions = {},
197
209
  ): Promise<RawResponse<T>> {
198
- const { response, endpoint } = await this.#send(method, path, options)
199
-
200
- const data = await readBody(response, endpoint)
210
+ const { response, endpoint, payload } = await this.#send(method, path, options, readJsonBody)
201
211
 
202
212
  if (!response.ok) {
203
- const error = parseErrorBody(response.status, data, endpoint)
213
+ const error = parseErrorBody(response.status, payload, endpoint)
204
214
  if (response.status === 401 && this.#options.onUnauthorized) {
205
215
  await this.#options.onUnauthorized(error)
206
216
  }
207
217
  throw error
208
218
  }
209
219
 
210
- return { status: response.status, data: data as T, headers: response.headers }
220
+ return { status: response.status, data: payload as T, headers: response.headers }
211
221
  }
212
222
 
223
+ /**
224
+ * One attempt: headers out, body in, cleanup.
225
+ *
226
+ * The body is read HERE, through `readPayload`, instead of by the caller
227
+ * afterwards — because the timeout and the caller's signal have to stay
228
+ * armed until the body is in hand. Headers arrive first and the body streams
229
+ * after; with the timer cleared at the headers, a server that answers and
230
+ * then stalls mid-body left a request hanging with nothing to end it. On a
231
+ * money path that is a transfer whose outcome is never learned, and no
232
+ * retry decision either.
233
+ */
213
234
  async #send(
214
235
  method: string,
215
236
  path: string,
216
237
  options: RequestOptions,
217
- ): Promise<{ response: Response; endpoint: string }> {
238
+ readPayload: (response: Response, endpoint: string) => Promise<unknown>,
239
+ ): Promise<{ response: Response; endpoint: string; payload: unknown }> {
218
240
  const endpoint = `${method.toUpperCase()} ${path}`
219
241
  const url = `${this.baseUrl}${path}${serializeQuery(options.query)}`
220
242
  const timeoutMs = options.timeoutMs ?? this.#timeoutMs
@@ -227,7 +249,14 @@ export class HttpClient {
227
249
  ...options.headers,
228
250
  }
229
251
  if (options.body !== undefined) headers['content-type'] = 'application/json'
230
- if (options.idempotencyKey) headers['idempotency-key'] = options.idempotencyKey
252
+ // NOTHING ELSE IS ADDED BY DEFAULT, and that is a browser constraint
253
+ // rather than minimalism: the API's CORS preflight allows exactly
254
+ // `Content-Type` and `Authorization`, so any further header of ours makes
255
+ // the preflight fail and the request is never sent. These three are safe —
256
+ // `accept` is CORS-safelisted, and `user-agent` is a forbidden header name
257
+ // the browser drops before it can be preflighted (it still reaches the API
258
+ // from Node and React Native, which is where it is useful). A header
259
+ // passed in through `headers` is the caller's own risk.
231
260
 
232
261
  // A timer of our own on top of the caller's signal: `AbortSignal.timeout`
233
262
  // is missing in some React Native runtimes, and "the caller cancelled" has
@@ -242,47 +271,128 @@ export class HttpClient {
242
271
  const onExternalAbort = () => controller.abort()
243
272
  options.signal?.addEventListener('abort', onExternalAbort, { once: true })
244
273
 
245
- let response: Response
274
+ const classify = (cause: unknown): CoreError =>
275
+ transportError(cause, { timedOut, timeoutMs, endpoint, signal: options.signal })
276
+
277
+ // The cleanup is an OUTER finally, so it also runs when `onTokenRotated`
278
+ // throws — a caller's storage failure should not leave our timer armed,
279
+ // ready to abort an unrelated stream seconds later.
246
280
  try {
247
- response = await this.#fetch(url, {
248
- method: method.toUpperCase(),
249
- headers,
250
- ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
251
- ...(this.#options.credentials === undefined
252
- ? {}
253
- : { credentials: this.#options.credentials }),
254
- signal: controller.signal,
255
- })
256
- } catch (cause) {
257
- if (timedOut) throw timeoutError(timeoutMs, endpoint)
258
- if (options.signal?.aborted) throw abortedError(endpoint)
259
- throw networkError(cause, endpoint)
281
+ let response: Response
282
+ try {
283
+ response = await this.#fetch(url, {
284
+ method: method.toUpperCase(),
285
+ headers,
286
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
287
+ ...(this.#options.credentials === undefined
288
+ ? {}
289
+ : { credentials: this.#options.credentials }),
290
+ signal: controller.signal,
291
+ })
292
+ } catch (cause) {
293
+ throw classify(cause)
294
+ }
295
+
296
+ // Rotation arrives on successful responses too, and is taken BEFORE the
297
+ // body: the token is valid whatever the body turns out to be, and a
298
+ // rotation we drop signs the client out at the next one.
299
+ const rotated = response.headers.get(ROTATED_TOKEN_HEADER)
300
+ if (rotated && this.#options.onTokenRotated) await this.#options.onTokenRotated(rotated)
301
+
302
+ let payload: unknown
303
+ try {
304
+ payload = await readPayload(response, endpoint)
305
+ } catch (cause) {
306
+ throw classify(cause)
307
+ }
308
+
309
+ return { response, endpoint, payload }
260
310
  } finally {
261
311
  clearTimeout(timer)
262
312
  options.signal?.removeEventListener('abort', onExternalAbort)
263
313
  }
264
-
265
- // Rotation arrives on successful responses too.
266
- const rotated = response.headers.get(ROTATED_TOKEN_HEADER)
267
- if (rotated && this.#options.onTokenRotated) await this.#options.onTokenRotated(rotated)
268
-
269
- return { response, endpoint }
270
314
  }
271
315
  }
272
316
 
317
+ /**
318
+ * Names a transport failure, of the request or of the body behind it.
319
+ *
320
+ * The distinction is not cosmetic on a money path: a timeout may have been
321
+ * applied server-side (retry only with the same idempotency key), a cancel is
322
+ * the caller's own decision and must not be shown as a failure, and a network
323
+ * error never left the device. A `CoreError` arrives already named — the body
324
+ * reader raises one when a successful response is not the JSON the contract
325
+ * promises — and is passed through, so "the contract is wrong" is not reported
326
+ * as "the network is down".
327
+ *
328
+ * Our own timeout wins over the caller's signal, and both win over the cause:
329
+ * an abort mid-body surfaces as the abort that caused it, not as the opaque
330
+ * `AbortError` the stream rejects with.
331
+ */
332
+ function transportError(
333
+ cause: unknown,
334
+ ctx: {
335
+ timedOut: boolean
336
+ timeoutMs: number
337
+ endpoint: string
338
+ signal?: AbortSignal | undefined
339
+ },
340
+ ): CoreError {
341
+ if (ctx.timedOut) return timeoutError(ctx.timeoutMs, ctx.endpoint)
342
+ if (ctx.signal?.aborted) return abortedError(ctx.endpoint)
343
+ if (isCoreError(cause)) return cause
344
+ return networkError(cause, ctx.endpoint)
345
+ }
346
+
347
+ /** The longest fragment of a non-JSON error body worth carrying into a message. */
348
+ const MAX_PLAIN_ERROR_CHARS = 200
349
+
273
350
  /**
274
351
  * An empty body (204, DELETE) is `undefined`, not a parse failure. Invalid
275
352
  * JSON on a SUCCESSFUL status is a failure: the contract is not what we think
276
- * it is, and returning `undefined` quietly is worse than throwing. On an error
277
- * status the text is kept as-is for `parseErrorBody` to read.
353
+ * it is, and returning `undefined` quietly is worse than throwing.
354
+ *
355
+ * A read that fails halfway is no longer swallowed. It used to be caught into
356
+ * an empty string, which turned a truncated response — a dropped connection,
357
+ * our own timeout firing while the body streamed — into `data: undefined`
358
+ * typed as the shape the caller asked for: a failure wearing the face of a
359
+ * successful empty answer, and on a balance or a transfer the worst possible
360
+ * disguise. The rejection now travels out to `transportError`, which names it
361
+ * a timeout, a cancel or a network failure.
278
362
  */
279
- async function readBody(response: Response, endpoint: string): Promise<unknown> {
280
- const text = await response.text().catch(() => '')
363
+ async function readJsonBody(response: Response, endpoint: string): Promise<unknown> {
364
+ const text = await response.text()
281
365
  if (!text) return undefined
282
366
  try {
283
367
  return JSON.parse(text) as unknown
284
368
  } catch (cause) {
285
369
  if (response.ok) throw parseError(cause, endpoint)
286
- return { error: text }
370
+ return plainTextError(text)
371
+ }
372
+ }
373
+
374
+ /**
375
+ * A non-JSON error body reduced to something showable — or to nothing.
376
+ *
377
+ * Who sends these: everything in front of the API, never the API itself. A
378
+ * 502/504 while the worker is being replaced, a 403 from a WAF, a platform
379
+ * 429 — they answer with a whole HTML page (2318 bytes in one measured case).
380
+ * Passing that on as `{error: text}` put the page into `message`, which is the
381
+ * one field meant to be read by a person.
382
+ *
383
+ * Markup is therefore dropped and `parseErrorBody` falls back to its own
384
+ * wording for the status. A short line of plain text is kept instead of being
385
+ * dropped with it, because a proxy occasionally says something the status does
386
+ * not ("upstream connect error", "no healthy upstream").
387
+ */
388
+ function plainTextError(text: string): unknown {
389
+ // A leading `<` catches HTML and XML error documents alike; neither is ours.
390
+ const collapsed = text.trim().replace(/\s+/g, ' ')
391
+ if (!collapsed || collapsed.startsWith('<')) return undefined
392
+ return {
393
+ error:
394
+ collapsed.length > MAX_PLAIN_ERROR_CHARS
395
+ ? `${collapsed.slice(0, MAX_PLAIN_ERROR_CHARS)}…`
396
+ : collapsed,
287
397
  }
288
398
  }
@@ -1,5 +1,32 @@
1
1
  /**
2
- * Keyset pagination. Every list endpoint returns `{items, nextCursor}`.
2
+ * Keyset pagination — for the eleven endpoints that have it.
3
+ *
4
+ * "Every list returns `{items, nextCursor}`" is what this file used to claim,
5
+ * and it is not what the API does. Counted over the spec snapshot
6
+ * (`openapi/spec.json`), the reading endpoints fall into three shapes:
7
+ *
8
+ * • 11 GETs take `cursor`/`limit` and answer a `nextCursor` — addresses,
9
+ * history, approval requests, members, counterparties, counterparty
10
+ * groups, operation categories, operation relations, ledger entries, a
11
+ * vault's policies, balances. These are what `paginate` is for;
12
+ * • 18 answer `{items}` with NO cursor parameter at all — folders and their
13
+ * grants, address shares, shared-with-me / shared-by-me, visibility
14
+ * grants, category assignments and their versions, report templates and
15
+ * snapshots, user groups and their members, status history, the
16
+ * admin-level `/policies` list and one policy's versions (18 paths, two
17
+ * of them the older `/address-groups` prefix of the folder endpoints).
18
+ * What you get is everything there is; `paginate` over one of them yields
19
+ * that single page and stops, because an absent `nextCursor` is the
20
+ * end-of-list signal;
21
+ * • 8 answer a BARE ARRAY rather than an object: `/networks`, `/tokens`,
22
+ * `/vaults`, a vault's `/policy-groups` and `/invitations`, one
23
+ * operation's `/relations`, a policy group's `/policies`, a user's
24
+ * `/policy-groups`. There is no `items` to walk, so `paginate` refuses
25
+ * them by name instead of quietly yielding nothing.
26
+ *
27
+ * `/balances` is the one cursored endpoint `paginate` CANNOT walk: its page is
28
+ * `{vaultId, totals, accounts, nextCursor}` and the array is called
29
+ * `accounts`. Pass the cursor by hand there.
3
30
  *
4
31
  * The cursor is opaque: do not parse it, build it or compare it. A
5
32
  * `nextCursor` of `null` is the only end-of-list signal — an empty page is
@@ -41,6 +68,16 @@ export async function* paginate<T>(
41
68
  ...(options.limit === undefined ? {} : { limit: options.limit }),
42
69
  ...(cursor === undefined ? {} : { cursor }),
43
70
  })
71
+ // A bare-array endpoint (or `/balances`, whose array is `accounts`) has no
72
+ // `items`, and `for…of undefined` would report that as an unexplained
73
+ // TypeError from inside the SDK. Say what is wrong instead.
74
+ if (!Array.isArray(result.items)) {
75
+ throw new Error(
76
+ '@v3finance/core-sdk: this response has no `items` array, so it cannot be paged. ' +
77
+ 'Endpoints answering a bare array, and /balances (its array is `accounts`), ' +
78
+ 'are read with a single call — see the note at the top of core/pagination.ts.',
79
+ )
80
+ }
44
81
  for (const item of result.items) yield item
45
82
  seen += result.items.length
46
83
 
@@ -9,7 +9,7 @@
9
9
  // the API does not slip in unclassified.
10
10
  //
11
11
  // Operations: 171 · admin: 40 · both: 1 · member: 127 · service: 3
12
- // Spec sha256: 1b67f22c80335e08be9a9ecf1351324e01b6003a96dde1253ef0032b3ab6a6fc
12
+ // Spec sha256: a03bd2e06186734bd8816c3d764dd90a48c1c31d7edda2743a3ae29550601cd1
13
13
 
14
14
  export type Audience = 'member' | 'admin' | 'service' | 'both' | 'unknown'
15
15