@v3finance/core-sdk 0.1.0 → 0.2.1

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 +441 -7
  2. package/README.md +219 -73
  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 +30 -6
  12. package/dist/cjs/core/gated.d.ts.map +1 -1
  13. package/dist/cjs/core/gated.js +21 -6
  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 +147 -43
  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 +965 -179
  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 +23 -8
  31. package/dist/cjs/resources/addresses.d.ts.map +1 -1
  32. package/dist/cjs/resources/addresses.js +8 -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 +44 -6
  86. package/dist/cjs/resources/transfers.d.ts.map +1 -1
  87. package/dist/cjs/resources/transfers.js +41 -3
  88. package/dist/cjs/resources/transfers.js.map +1 -1
  89. package/dist/cjs/resources/types.d.ts +16 -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 +30 -6
  105. package/dist/esm/core/gated.d.ts.map +1 -1
  106. package/dist/esm/core/gated.js +21 -6
  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 +148 -44
  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 +965 -179
  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 +23 -8
  124. package/dist/esm/resources/addresses.d.ts.map +1 -1
  125. package/dist/esm/resources/addresses.js +8 -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 +44 -6
  179. package/dist/esm/resources/transfers.d.ts.map +1 -1
  180. package/dist/esm/resources/transfers.js +41 -3
  181. package/dist/esm/resources/transfers.js.map +1 -1
  182. package/dist/esm/resources/types.d.ts +16 -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 +193 -0
  190. package/docs/errors.md +491 -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 +31 -7
  195. package/src/core/http.ts +173 -49
  196. package/src/core/pagination.ts +38 -1
  197. package/src/generated/operations.ts +1 -1
  198. package/src/generated/types.ts +1001 -180
  199. package/src/index.ts +64 -7
  200. package/src/resources/addresses.ts +24 -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 +51 -7
  215. package/src/resources/types.ts +16 -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
@@ -13,14 +15,25 @@
13
15
  * frozen.
14
16
  *
15
17
  * `replayed` is kept separate from `applied` on purpose. A replay means the
16
- * previous attempt DID reach the server and only the answer was lost, so the
17
- * person should be told "already sent", not "sent" a second time.
18
+ * previous attempt DID reach the server and only the answer was lost — not
19
+ * that it succeeded. The body is that operation as it stands now: a replayed
20
+ * transfer may have gone out, be waiting for signatures, be still executing
21
+ * or have failed, and only its own status says which.
18
22
  */
19
23
 
20
24
  export type GatedOutcome = 'applied' | 'pending' | 'replayed'
21
25
 
26
+ /**
27
+ * `status` is the status the server ACTUALLY answered, which is why `applied`
28
+ * carries `200 | 201` rather than 201 alone: an ordinary 200 success is an
29
+ * applied operation too (updating a counterparty, editing a label — anything
30
+ * gated that is not a creation). The field used to be hard-coded to 201 for
31
+ * those, so every gated method except `transfers.send` reported a status the
32
+ * server never sent, into logs and error reports where it is the one thing
33
+ * worth trusting. Branch on `outcome`; read `status` when you need the fact.
34
+ */
22
35
  export type Gated<TApplied, TPending = TApplied> =
23
- | { readonly outcome: 'applied'; readonly status: 201; readonly data: TApplied }
36
+ | { readonly outcome: 'applied'; readonly status: 200 | 201; readonly data: TApplied }
24
37
  | { readonly outcome: 'replayed'; readonly status: 200; readonly data: TApplied }
25
38
  | { readonly outcome: 'pending'; readonly status: 202; readonly data: TPending }
26
39
 
@@ -33,6 +46,13 @@ export type Gated<TApplied, TPending = TApplied> =
33
46
  * success. Telling a person "already sent" after a plain edit would be as
34
47
  * wrong as telling them "done" after a replay, so each caller states which
35
48
  * of the two it is instead of the helper guessing.
49
+ *
50
+ * Which also means `outcome: 'replayed'` is UNREACHABLE for every gated
51
+ * method other than `transfers.send`: replay protection needs an idempotency
52
+ * key, and the transfer body is the only place the API takes one. The
53
+ * three-way `switch` in `docs/concepts.md` is right for a transfer; beside a
54
+ * counterparty edit its `case 'replayed'` is dead, and it is not evidence
55
+ * that such an edit can come back as a replay.
36
56
  */
37
57
  export function toGated<TApplied, TPending>(
38
58
  status: number,
@@ -43,7 +63,7 @@ export function toGated<TApplied, TPending>(
43
63
  case 200:
44
64
  return options.replayOn200
45
65
  ? { outcome: 'replayed', status: 200, data: data as TApplied }
46
- : { outcome: 'applied', status: 201, data: data as TApplied }
66
+ : { outcome: 'applied', status: 200, data: data as TApplied }
47
67
  case 201:
48
68
  return { outcome: 'applied', status: 201, data: data as TApplied }
49
69
  case 202:
@@ -56,7 +76,11 @@ export function toGated<TApplied, TPending>(
56
76
  }
57
77
  }
58
78
 
59
- /** Narrowing: the operation ran, now or earlier, and money moved. */
79
+ /**
80
+ * Narrowing: `applied` or `replayed`. For `applied` the operation ran now.
81
+ * For `replayed` it only existed already — whether money moved is in the
82
+ * body's own status, not in this check.
83
+ */
60
84
  export function isApplied<A, P>(
61
85
  r: Gated<A, P>,
62
86
  ): r is Extract<Gated<A, P>, { outcome: 'applied' | 'replayed' }> {
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
@@ -152,13 +157,27 @@ export class HttpClient {
152
157
  constructor(options: ClientOptions & { baseUrl: string }) {
153
158
  this.baseUrl = options.baseUrl.replace(/\/+$/, '')
154
159
  this.#options = options
155
- const f = options.fetch ?? globalThis.fetch
156
- if (typeof f !== 'function') {
160
+ const custom = options.fetch
161
+ if (typeof (custom ?? globalThis.fetch) !== 'function') {
157
162
  throw new Error(
158
163
  '@v3finance/core-sdk: fetch is unavailable in this runtime — pass one to the client options.',
159
164
  )
160
165
  }
161
- this.#fetch = f
166
+ // NEVER store the function and call it as `this.#fetch(…)`. A private
167
+ // field call passes the client as the receiver, and a browser's `fetch`
168
+ // checks its receiver: anything but the window is
169
+ // `TypeError: Illegal invocation`, thrown before a single byte leaves.
170
+ // Node and React Native do not check, which is how every request from a
171
+ // web page failed while the whole test suite passed — and since a
172
+ // `TypeError` out of `fetch` is what a dropped connection looks like, the
173
+ // failure surfaced as a "network error" on a perfectly good network.
174
+ //
175
+ // The wrapper calls with no receiver at all. Without a custom fetch the
176
+ // global one is looked up per call rather than captured here, so whatever
177
+ // patches it later (instrumentation, a mock server) is still honoured.
178
+ this.#fetch = custom
179
+ ? (input, init) => custom(input, init)
180
+ : (input, init) => globalThis.fetch(input, init)
162
181
  this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
163
182
  }
164
183
 
@@ -174,13 +193,20 @@ export class HttpClient {
174
193
  * time. Read it with `.blob()`, `.text()` or `.arrayBuffer()`.
175
194
  */
176
195
  async fetchRaw(method: string, path: string, options: RequestOptions = {}): Promise<Response> {
177
- const { response, endpoint } = await this.#send(method, path, options)
196
+ // The body is touched only when the call FAILED — and then by the same
197
+ // reader a JSON call uses, so one 502 from the edge produces one message
198
+ // whichever method asked for it. The two used to disagree: this one fell
199
+ // back to neutral wording while `requestRaw` put the proxy's HTML page
200
+ // into `message`.
201
+ //
202
+ // On success the response is handed over unread, which also means the
203
+ // timeout ends at the headers here: the file body is the caller's to read
204
+ // and to bound, with their own signal.
205
+ const { response, endpoint, payload } = await this.#send(method, path, options, (r, ep) =>
206
+ r.ok ? Promise.resolve(undefined) : readJsonBody(r, ep),
207
+ )
178
208
  if (!response.ok) {
179
- const body = await response
180
- .clone()
181
- .json()
182
- .catch(() => undefined)
183
- const error = parseErrorBody(response.status, body, endpoint)
209
+ const error = parseErrorBody(response.status, payload, endpoint)
184
210
  if (response.status === 401 && this.#options.onUnauthorized) {
185
211
  await this.#options.onUnauthorized(error)
186
212
  }
@@ -195,26 +221,36 @@ export class HttpClient {
195
221
  path: string,
196
222
  options: RequestOptions = {},
197
223
  ): Promise<RawResponse<T>> {
198
- const { response, endpoint } = await this.#send(method, path, options)
199
-
200
- const data = await readBody(response, endpoint)
224
+ const { response, endpoint, payload } = await this.#send(method, path, options, readJsonBody)
201
225
 
202
226
  if (!response.ok) {
203
- const error = parseErrorBody(response.status, data, endpoint)
227
+ const error = parseErrorBody(response.status, payload, endpoint)
204
228
  if (response.status === 401 && this.#options.onUnauthorized) {
205
229
  await this.#options.onUnauthorized(error)
206
230
  }
207
231
  throw error
208
232
  }
209
233
 
210
- return { status: response.status, data: data as T, headers: response.headers }
234
+ return { status: response.status, data: payload as T, headers: response.headers }
211
235
  }
212
236
 
237
+ /**
238
+ * One attempt: headers out, body in, cleanup.
239
+ *
240
+ * The body is read HERE, through `readPayload`, instead of by the caller
241
+ * afterwards — because the timeout and the caller's signal have to stay
242
+ * armed until the body is in hand. Headers arrive first and the body streams
243
+ * after; with the timer cleared at the headers, a server that answers and
244
+ * then stalls mid-body left a request hanging with nothing to end it. On a
245
+ * money path that is a transfer whose outcome is never learned, and no
246
+ * retry decision either.
247
+ */
213
248
  async #send(
214
249
  method: string,
215
250
  path: string,
216
251
  options: RequestOptions,
217
- ): Promise<{ response: Response; endpoint: string }> {
252
+ readPayload: (response: Response, endpoint: string) => Promise<unknown>,
253
+ ): Promise<{ response: Response; endpoint: string; payload: unknown }> {
218
254
  const endpoint = `${method.toUpperCase()} ${path}`
219
255
  const url = `${this.baseUrl}${path}${serializeQuery(options.query)}`
220
256
  const timeoutMs = options.timeoutMs ?? this.#timeoutMs
@@ -227,7 +263,14 @@ export class HttpClient {
227
263
  ...options.headers,
228
264
  }
229
265
  if (options.body !== undefined) headers['content-type'] = 'application/json'
230
- if (options.idempotencyKey) headers['idempotency-key'] = options.idempotencyKey
266
+ // NOTHING ELSE IS ADDED BY DEFAULT, and that is a browser constraint
267
+ // rather than minimalism: the API's CORS preflight allows exactly
268
+ // `Content-Type` and `Authorization`, so any further header of ours makes
269
+ // the preflight fail and the request is never sent. These three are safe —
270
+ // `accept` is CORS-safelisted, and `user-agent` is a forbidden header name
271
+ // the browser drops before it can be preflighted (it still reaches the API
272
+ // from Node and React Native, which is where it is useful). A header
273
+ // passed in through `headers` is the caller's own risk.
231
274
 
232
275
  // A timer of our own on top of the caller's signal: `AbortSignal.timeout`
233
276
  // is missing in some React Native runtimes, and "the caller cancelled" has
@@ -242,47 +285,128 @@ export class HttpClient {
242
285
  const onExternalAbort = () => controller.abort()
243
286
  options.signal?.addEventListener('abort', onExternalAbort, { once: true })
244
287
 
245
- let response: Response
288
+ const classify = (cause: unknown): CoreError =>
289
+ transportError(cause, { timedOut, timeoutMs, endpoint, signal: options.signal })
290
+
291
+ // The cleanup is an OUTER finally, so it also runs when `onTokenRotated`
292
+ // throws — a caller's storage failure should not leave our timer armed,
293
+ // ready to abort an unrelated stream seconds later.
246
294
  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)
295
+ let response: Response
296
+ try {
297
+ response = await this.#fetch(url, {
298
+ method: method.toUpperCase(),
299
+ headers,
300
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
301
+ ...(this.#options.credentials === undefined
302
+ ? {}
303
+ : { credentials: this.#options.credentials }),
304
+ signal: controller.signal,
305
+ })
306
+ } catch (cause) {
307
+ throw classify(cause)
308
+ }
309
+
310
+ // Rotation arrives on successful responses too, and is taken BEFORE the
311
+ // body: the token is valid whatever the body turns out to be, and a
312
+ // rotation we drop signs the client out at the next one.
313
+ const rotated = response.headers.get(ROTATED_TOKEN_HEADER)
314
+ if (rotated && this.#options.onTokenRotated) await this.#options.onTokenRotated(rotated)
315
+
316
+ let payload: unknown
317
+ try {
318
+ payload = await readPayload(response, endpoint)
319
+ } catch (cause) {
320
+ throw classify(cause)
321
+ }
322
+
323
+ return { response, endpoint, payload }
260
324
  } finally {
261
325
  clearTimeout(timer)
262
326
  options.signal?.removeEventListener('abort', onExternalAbort)
263
327
  }
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
328
  }
271
329
  }
272
330
 
331
+ /**
332
+ * Names a transport failure, of the request or of the body behind it.
333
+ *
334
+ * The distinction is not cosmetic on a money path: a timeout may have been
335
+ * applied server-side (retry only with the same idempotency key), a cancel is
336
+ * the caller's own decision and must not be shown as a failure, and a network
337
+ * error never left the device. A `CoreError` arrives already named — the body
338
+ * reader raises one when a successful response is not the JSON the contract
339
+ * promises — and is passed through, so "the contract is wrong" is not reported
340
+ * as "the network is down".
341
+ *
342
+ * Our own timeout wins over the caller's signal, and both win over the cause:
343
+ * an abort mid-body surfaces as the abort that caused it, not as the opaque
344
+ * `AbortError` the stream rejects with.
345
+ */
346
+ function transportError(
347
+ cause: unknown,
348
+ ctx: {
349
+ timedOut: boolean
350
+ timeoutMs: number
351
+ endpoint: string
352
+ signal?: AbortSignal | undefined
353
+ },
354
+ ): CoreError {
355
+ if (ctx.timedOut) return timeoutError(ctx.timeoutMs, ctx.endpoint)
356
+ if (ctx.signal?.aborted) return abortedError(ctx.endpoint)
357
+ if (isCoreError(cause)) return cause
358
+ return networkError(cause, ctx.endpoint)
359
+ }
360
+
361
+ /** The longest fragment of a non-JSON error body worth carrying into a message. */
362
+ const MAX_PLAIN_ERROR_CHARS = 200
363
+
273
364
  /**
274
365
  * An empty body (204, DELETE) is `undefined`, not a parse failure. Invalid
275
366
  * 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.
367
+ * it is, and returning `undefined` quietly is worse than throwing.
368
+ *
369
+ * A read that fails halfway is no longer swallowed. It used to be caught into
370
+ * an empty string, which turned a truncated response — a dropped connection,
371
+ * our own timeout firing while the body streamed — into `data: undefined`
372
+ * typed as the shape the caller asked for: a failure wearing the face of a
373
+ * successful empty answer, and on a balance or a transfer the worst possible
374
+ * disguise. The rejection now travels out to `transportError`, which names it
375
+ * a timeout, a cancel or a network failure.
278
376
  */
279
- async function readBody(response: Response, endpoint: string): Promise<unknown> {
280
- const text = await response.text().catch(() => '')
377
+ async function readJsonBody(response: Response, endpoint: string): Promise<unknown> {
378
+ const text = await response.text()
281
379
  if (!text) return undefined
282
380
  try {
283
381
  return JSON.parse(text) as unknown
284
382
  } catch (cause) {
285
383
  if (response.ok) throw parseError(cause, endpoint)
286
- return { error: text }
384
+ return plainTextError(text)
385
+ }
386
+ }
387
+
388
+ /**
389
+ * A non-JSON error body reduced to something showable — or to nothing.
390
+ *
391
+ * Who sends these: everything in front of the API, never the API itself. A
392
+ * 502/504 while the worker is being replaced, a 403 from a WAF, a platform
393
+ * 429 — they answer with a whole HTML page (2318 bytes in one measured case).
394
+ * Passing that on as `{error: text}` put the page into `message`, which is the
395
+ * one field meant to be read by a person.
396
+ *
397
+ * Markup is therefore dropped and `parseErrorBody` falls back to its own
398
+ * wording for the status. A short line of plain text is kept instead of being
399
+ * dropped with it, because a proxy occasionally says something the status does
400
+ * not ("upstream connect error", "no healthy upstream").
401
+ */
402
+ function plainTextError(text: string): unknown {
403
+ // A leading `<` catches HTML and XML error documents alike; neither is ours.
404
+ const collapsed = text.trim().replace(/\s+/g, ' ')
405
+ if (!collapsed || collapsed.startsWith('<')) return undefined
406
+ return {
407
+ error:
408
+ collapsed.length > MAX_PLAIN_ERROR_CHARS
409
+ ? `${collapsed.slice(0, MAX_PLAIN_ERROR_CHARS)}…`
410
+ : collapsed,
287
411
  }
288
412
  }