@coderoftime/fints 0.1.0 → 0.3.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 (289) hide show
  1. package/CHANGELOG.md +519 -1
  2. package/README.md +138 -40
  3. package/dist/client.d.ts +49 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +242 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/core/account.d.ts +46 -7
  8. package/dist/core/account.d.ts.map +1 -1
  9. package/dist/core/account.js +143 -24
  10. package/dist/core/account.js.map +1 -1
  11. package/dist/core/amount.d.ts +15 -4
  12. package/dist/core/amount.d.ts.map +1 -1
  13. package/dist/core/amount.js +46 -9
  14. package/dist/core/amount.js.map +1 -1
  15. package/dist/core/balance.d.ts +4 -0
  16. package/dist/core/balance.d.ts.map +1 -1
  17. package/dist/core/balance.js +11 -5
  18. package/dist/core/balance.js.map +1 -1
  19. package/dist/core/bank-info.d.ts +21 -4
  20. package/dist/core/bank-info.d.ts.map +1 -1
  21. package/dist/core/bank-info.js +26 -5
  22. package/dist/core/bank-info.js.map +1 -1
  23. package/dist/core/client.d.ts +81 -19
  24. package/dist/core/client.d.ts.map +1 -1
  25. package/dist/core/config.d.ts +40 -20
  26. package/dist/core/config.d.ts.map +1 -1
  27. package/dist/core/config.js +85 -30
  28. package/dist/core/config.js.map +1 -1
  29. package/dist/core/errors.d.ts +100 -26
  30. package/dist/core/errors.d.ts.map +1 -1
  31. package/dist/core/errors.js +136 -53
  32. package/dist/core/errors.js.map +1 -1
  33. package/dist/core/feedback.d.ts +11 -1
  34. package/dist/core/feedback.d.ts.map +1 -1
  35. package/dist/core/feedback.js +14 -2
  36. package/dist/core/feedback.js.map +1 -1
  37. package/dist/core/transaction.d.ts +11 -1
  38. package/dist/core/transaction.d.ts.map +1 -1
  39. package/dist/core/transaction.js +21 -4
  40. package/dist/core/transaction.js.map +1 -1
  41. package/dist/core/transport.d.ts +48 -6
  42. package/dist/core/transport.d.ts.map +1 -1
  43. package/dist/core/transport.js +126 -29
  44. package/dist/core/transport.js.map +1 -1
  45. package/dist/formats/camt.d.ts +28 -5
  46. package/dist/formats/camt.d.ts.map +1 -1
  47. package/dist/formats/camt.js +132 -50
  48. package/dist/formats/camt.js.map +1 -1
  49. package/dist/formats/mt940.d.ts +29 -5
  50. package/dist/formats/mt940.d.ts.map +1 -1
  51. package/dist/formats/mt940.js +209 -113
  52. package/dist/formats/mt940.js.map +1 -1
  53. package/dist/formats/readers.d.ts +6 -2
  54. package/dist/formats/readers.d.ts.map +1 -1
  55. package/dist/formats/readers.js +28 -29
  56. package/dist/formats/readers.js.map +1 -1
  57. package/dist/formats/record-budget.d.ts +7 -0
  58. package/dist/formats/record-budget.d.ts.map +1 -0
  59. package/dist/formats/record-budget.js +17 -0
  60. package/dist/formats/record-budget.js.map +1 -0
  61. package/dist/formats/sepa-purpose.d.ts +9 -0
  62. package/dist/formats/sepa-purpose.d.ts.map +1 -1
  63. package/dist/formats/sepa-purpose.js +32 -2
  64. package/dist/formats/sepa-purpose.js.map +1 -1
  65. package/dist/index.d.ts +3 -3
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -2
  68. package/dist/index.js.map +1 -1
  69. package/dist/session/app-approval.d.ts +11 -5
  70. package/dist/session/app-approval.d.ts.map +1 -1
  71. package/dist/session/app-approval.js +23 -12
  72. package/dist/session/app-approval.js.map +1 -1
  73. package/dist/session/dialog-runner.d.ts +11 -11
  74. package/dist/session/dialog-runner.d.ts.map +1 -1
  75. package/dist/session/dialog-runner.js +34 -19
  76. package/dist/session/dialog-runner.js.map +1 -1
  77. package/dist/session/login-guard.d.ts +54 -49
  78. package/dist/session/login-guard.d.ts.map +1 -1
  79. package/dist/session/login-guard.js +170 -83
  80. package/dist/session/login-guard.js.map +1 -1
  81. package/dist/session/sepa-accounts.d.ts +20 -14
  82. package/dist/session/sepa-accounts.d.ts.map +1 -1
  83. package/dist/session/sepa-accounts.js +62 -46
  84. package/dist/session/sepa-accounts.js.map +1 -1
  85. package/dist/session/session-setup.d.ts +41 -24
  86. package/dist/session/session-setup.d.ts.map +1 -1
  87. package/dist/session/session-setup.js +106 -63
  88. package/dist/session/session-setup.js.map +1 -1
  89. package/dist/session/session-state.d.ts +10 -6
  90. package/dist/session/session-state.d.ts.map +1 -1
  91. package/dist/session/session-state.js +72 -18
  92. package/dist/session/session-state.js.map +1 -1
  93. package/dist/session/signed-dialog.d.ts +83 -0
  94. package/dist/session/signed-dialog.d.ts.map +1 -0
  95. package/dist/session/signed-dialog.js +185 -0
  96. package/dist/session/signed-dialog.js.map +1 -0
  97. package/dist/session/tan-selection.d.ts +26 -18
  98. package/dist/session/tan-selection.d.ts.map +1 -1
  99. package/dist/session/tan-selection.js +44 -33
  100. package/dist/session/tan-selection.js.map +1 -1
  101. package/dist/session/user-prompts.d.ts +14 -5
  102. package/dist/session/user-prompts.d.ts.map +1 -1
  103. package/dist/session/user-prompts.js +53 -18
  104. package/dist/session/user-prompts.js.map +1 -1
  105. package/dist/tan/challenge.d.ts +47 -24
  106. package/dist/tan/challenge.d.ts.map +1 -1
  107. package/dist/tan/challenge.js +56 -51
  108. package/dist/tan/challenge.js.map +1 -1
  109. package/dist/tan/flicker.d.ts +11 -1
  110. package/dist/tan/flicker.d.ts.map +1 -1
  111. package/dist/tan/flicker.js +70 -24
  112. package/dist/tan/flicker.js.map +1 -1
  113. package/dist/tan/tan-image.d.ts +6 -2
  114. package/dist/tan/tan-image.d.ts.map +1 -1
  115. package/dist/tan/tan-image.js +14 -6
  116. package/dist/tan/tan-image.js.map +1 -1
  117. package/dist/tan/tan-models.d.ts +14 -2
  118. package/dist/tan/tan-models.d.ts.map +1 -1
  119. package/dist/tan/tan-models.js +24 -7
  120. package/dist/tan/tan-models.js.map +1 -1
  121. package/dist/util/arguments.d.ts +13 -0
  122. package/dist/util/arguments.d.ts.map +1 -0
  123. package/dist/util/arguments.js +40 -0
  124. package/dist/util/arguments.js.map +1 -0
  125. package/dist/util/async.d.ts +4 -3
  126. package/dist/util/async.d.ts.map +1 -1
  127. package/dist/util/async.js +6 -7
  128. package/dist/util/async.js.map +1 -1
  129. package/dist/util/bytes.d.ts +3 -4
  130. package/dist/util/bytes.d.ts.map +1 -1
  131. package/dist/util/bytes.js +13 -21
  132. package/dist/util/bytes.js.map +1 -1
  133. package/dist/util/call-context.d.ts.map +1 -1
  134. package/dist/util/call-context.js +3 -2
  135. package/dist/util/call-context.js.map +1 -1
  136. package/dist/util/dates.d.ts +6 -20
  137. package/dist/util/dates.d.ts.map +1 -1
  138. package/dist/util/dates.js +58 -28
  139. package/dist/util/dates.js.map +1 -1
  140. package/dist/util/errors.d.ts +13 -3
  141. package/dist/util/errors.d.ts.map +1 -1
  142. package/dist/util/errors.js +19 -6
  143. package/dist/util/errors.js.map +1 -1
  144. package/dist/util/immutable-map.d.ts +5 -2
  145. package/dist/util/immutable-map.d.ts.map +1 -1
  146. package/dist/util/immutable-map.js +15 -12
  147. package/dist/util/immutable-map.js.map +1 -1
  148. package/dist/util/inspect.d.ts +11 -0
  149. package/dist/util/inspect.d.ts.map +1 -0
  150. package/dist/util/inspect.js +14 -0
  151. package/dist/util/inspect.js.map +1 -0
  152. package/dist/util/json.d.ts +4 -0
  153. package/dist/util/json.d.ts.map +1 -1
  154. package/dist/util/json.js +18 -0
  155. package/dist/util/json.js.map +1 -1
  156. package/dist/util/text.d.ts +38 -5
  157. package/dist/util/text.d.ts.map +1 -1
  158. package/dist/util/text.js +90 -16
  159. package/dist/util/text.js.map +1 -1
  160. package/dist/util/xml.d.ts +9 -6
  161. package/dist/util/xml.d.ts.map +1 -1
  162. package/dist/util/xml.js +129 -56
  163. package/dist/util/xml.js.map +1 -1
  164. package/dist/v3/account-extension.d.ts.map +1 -1
  165. package/dist/v3/account-extension.js +7 -10
  166. package/dist/v3/account-extension.js.map +1 -1
  167. package/dist/v3/bank-connection.d.ts +54 -0
  168. package/dist/v3/bank-connection.d.ts.map +1 -0
  169. package/dist/v3/bank-connection.js +118 -0
  170. package/dist/v3/bank-connection.js.map +1 -0
  171. package/dist/v3/dialog.d.ts +84 -46
  172. package/dist/v3/dialog.d.ts.map +1 -1
  173. package/dist/v3/dialog.js +174 -60
  174. package/dist/v3/dialog.js.map +1 -1
  175. package/dist/v3/message.d.ts +32 -11
  176. package/dist/v3/message.d.ts.map +1 -1
  177. package/dist/v3/message.js +144 -83
  178. package/dist/v3/message.js.map +1 -1
  179. package/dist/v3/parameter-store.d.ts +111 -0
  180. package/dist/v3/parameter-store.d.ts.map +1 -0
  181. package/dist/v3/parameter-store.js +197 -0
  182. package/dist/v3/parameter-store.js.map +1 -0
  183. package/dist/v3/parameters.d.ts +44 -17
  184. package/dist/v3/parameters.d.ts.map +1 -1
  185. package/dist/v3/parameters.js +136 -60
  186. package/dist/v3/parameters.js.map +1 -1
  187. package/dist/v3/protocol.d.ts +50 -50
  188. package/dist/v3/protocol.d.ts.map +1 -1
  189. package/dist/v3/protocol.js +91 -210
  190. package/dist/v3/protocol.js.map +1 -1
  191. package/dist/v3/queries.d.ts +51 -0
  192. package/dist/v3/queries.d.ts.map +1 -0
  193. package/dist/v3/queries.js +128 -0
  194. package/dist/v3/queries.js.map +1 -0
  195. package/dist/v3/segments.d.ts +43 -19
  196. package/dist/v3/segments.d.ts.map +1 -1
  197. package/dist/v3/segments.js +84 -33
  198. package/dist/v3/segments.js.map +1 -1
  199. package/dist/v3/syntax.d.ts +68 -13
  200. package/dist/v3/syntax.d.ts.map +1 -1
  201. package/dist/v3/syntax.js +183 -102
  202. package/dist/v3/syntax.js.map +1 -1
  203. package/package.json +10 -6
  204. package/src/client.ts +293 -0
  205. package/src/core/account.ts +153 -24
  206. package/src/core/amount.ts +45 -10
  207. package/src/core/balance.ts +12 -5
  208. package/src/core/bank-info.ts +29 -5
  209. package/src/core/client.ts +83 -19
  210. package/src/core/config.ts +95 -35
  211. package/src/core/errors.ts +148 -54
  212. package/src/core/feedback.ts +16 -2
  213. package/src/core/transaction.ts +22 -4
  214. package/src/core/transport.ts +153 -32
  215. package/src/formats/camt.ts +153 -45
  216. package/src/formats/mt940.ts +244 -122
  217. package/src/formats/readers.ts +28 -31
  218. package/src/formats/record-budget.ts +18 -0
  219. package/src/formats/sepa-purpose.ts +29 -2
  220. package/src/index.ts +4 -2
  221. package/src/session/app-approval.ts +51 -38
  222. package/src/session/dialog-runner.ts +61 -31
  223. package/src/session/login-guard.ts +189 -87
  224. package/src/session/sepa-accounts.ts +63 -45
  225. package/src/session/session-setup.ts +105 -68
  226. package/src/session/session-state.ts +74 -23
  227. package/src/session/signed-dialog.ts +222 -0
  228. package/src/session/tan-selection.ts +50 -39
  229. package/src/session/user-prompts.ts +62 -18
  230. package/src/tan/challenge.ts +87 -64
  231. package/src/tan/flicker.ts +72 -23
  232. package/src/tan/tan-image.ts +15 -6
  233. package/src/tan/tan-models.ts +26 -7
  234. package/src/util/arguments.ts +40 -0
  235. package/src/util/async.ts +10 -9
  236. package/src/util/bytes.ts +14 -20
  237. package/src/util/call-context.ts +3 -2
  238. package/src/util/dates.ts +64 -33
  239. package/src/util/errors.ts +22 -6
  240. package/src/util/immutable-map.ts +15 -12
  241. package/src/util/inspect.ts +14 -0
  242. package/src/util/json.ts +16 -0
  243. package/src/util/text.ts +87 -15
  244. package/src/util/xml.ts +130 -52
  245. package/src/v3/account-extension.ts +7 -12
  246. package/src/v3/bank-connection.ts +126 -0
  247. package/src/v3/dialog.ts +186 -92
  248. package/src/v3/message.ts +140 -79
  249. package/src/v3/parameter-store.ts +226 -0
  250. package/src/v3/parameters.ts +166 -66
  251. package/src/v3/protocol.ts +103 -253
  252. package/src/v3/queries.ts +198 -0
  253. package/src/v3/segments.ts +86 -31
  254. package/src/v3/syntax.ts +172 -93
  255. package/dist/session/bank-connection.d.ts +0 -28
  256. package/dist/session/bank-connection.d.ts.map +0 -1
  257. package/dist/session/bank-connection.js +0 -48
  258. package/dist/session/bank-connection.js.map +0 -1
  259. package/dist/session/parameter-data.d.ts +0 -83
  260. package/dist/session/parameter-data.d.ts.map +0 -1
  261. package/dist/session/parameter-data.js +0 -68
  262. package/dist/session/parameter-data.js.map +0 -1
  263. package/dist/session/pin-tan-client.d.ts +0 -54
  264. package/dist/session/pin-tan-client.d.ts.map +0 -1
  265. package/dist/session/pin-tan-client.js +0 -153
  266. package/dist/session/pin-tan-client.js.map +0 -1
  267. package/dist/session/pin-tan-dialog.d.ts +0 -71
  268. package/dist/session/pin-tan-dialog.d.ts.map +0 -1
  269. package/dist/session/pin-tan-dialog.js +0 -124
  270. package/dist/session/pin-tan-dialog.js.map +0 -1
  271. package/dist/session/protocol.d.ts +0 -209
  272. package/dist/session/protocol.d.ts.map +0 -1
  273. package/dist/session/protocol.js +0 -75
  274. package/dist/session/protocol.js.map +0 -1
  275. package/dist/session/response-handler.d.ts +0 -48
  276. package/dist/session/response-handler.d.ts.map +0 -1
  277. package/dist/session/response-handler.js +0 -121
  278. package/dist/session/response-handler.js.map +0 -1
  279. package/dist/v3/client.d.ts +0 -24
  280. package/dist/v3/client.d.ts.map +0 -1
  281. package/dist/v3/client.js +0 -16
  282. package/dist/v3/client.js.map +0 -1
  283. package/src/session/bank-connection.ts +0 -51
  284. package/src/session/parameter-data.ts +0 -137
  285. package/src/session/pin-tan-client.ts +0 -186
  286. package/src/session/pin-tan-dialog.ts +0 -160
  287. package/src/session/protocol.ts +0 -311
  288. package/src/session/response-handler.ts +0 -130
  289. package/src/v3/client.ts +0 -29
@@ -1,61 +1,89 @@
1
1
  import { FormatError } from '../util/errors.js';
2
- import { endsDialog, FeedbackCodes, type FinTsFeedback } from './feedback.js';
2
+ import { nonBlank } from '../util/text.js';
3
+ import { endsDialog, FeedbackCodes, hasCode, type FinTsFeedback } from './feedback.js';
3
4
 
4
- /** Base class of all errors the client raises about the bank and its data. */
5
+ /**
6
+ * Base class of all errors the client raises about the bank and its data.
7
+ *
8
+ * Each error class states its `name`: a minifier renames classes, and `constructor.name` would show a meaningless one.
9
+ */
5
10
  export abstract class FinTsError extends Error {
6
- protected constructor(name: string, message: string, cause?: unknown) {
7
- super(message, cause === undefined ? undefined : { cause });
8
- // Set explicitly: a minifier renames classes, and `constructor.name` would show a meaningless name.
9
- this.name = name;
10
- }
11
+ abstract override name: string;
11
12
  }
12
13
 
13
- /** The connection to the bank failed or returned an HTTP error. */
14
+ /**
15
+ * The connection to the bank failed or returned an HTTP error.
16
+ *
17
+ * It carries no `cause`: the transport's own error may hold the message it was sending, PIN and TAN included.
18
+ */
14
19
  export class FinTsTransportError extends FinTsError {
15
- /** The HTTP status code of the response, `undefined` if there was none, for example after a timeout. */
20
+ override name = 'FinTsTransportError';
21
+
22
+ /**
23
+ * The HTTP status code of the bank's answer, `undefined` where none came, for example after a timeout; the client
24
+ * acts on it (`FinTsTransport.post`).
25
+ */
16
26
  readonly statusCode: number | undefined;
17
27
 
18
- constructor(message: string, options?: { statusCode?: number; cause?: unknown }) {
19
- super('FinTsTransportError', message, options?.cause);
28
+ constructor(message: string, options?: { statusCode?: number }) {
29
+ super(message);
20
30
  this.statusCode = options?.statusCode;
21
31
  }
22
32
  }
23
33
 
24
34
  /** The bank's response could not be understood. */
25
35
  export class FinTsProtocolError extends FinTsError {
26
- constructor(message: string) {
27
- super('FinTsProtocolError', message);
28
- }
36
+ override name = 'FinTsProtocolError';
29
37
  }
30
38
 
31
39
  /** The bank does not offer the requested business transaction. */
32
40
  export class FinTsUnsupportedError extends FinTsError {
33
- constructor(message: string) {
34
- super('FinTsUnsupportedError', message);
35
- }
41
+ override name = 'FinTsUnsupportedError';
42
+ }
43
+
44
+ /**
45
+ * The client has no TAN method or medium to log in with: the method in use needs a TAN medium the client could not
46
+ * learn, or the bank offers the user only TAN methods the client cannot run. Pass a medium to
47
+ * `FinTsClient.selectTanMethod`, or choose another method.
48
+ *
49
+ * The client does not send a login the bank would refuse for it, but the steps before, such as the synchronization or
50
+ * the lookup of the TAN media, may have gone out.
51
+ */
52
+ export class FinTsTanSetupError extends FinTsError {
53
+ override name = 'FinTsTanSetupError';
36
54
  }
37
55
 
38
56
  /**
39
- * The bank rejected the request; `feedback` holds all return codes.
57
+ * The bank rejected the request; `feedback` holds all return codes. A refused login is a `FinTsLoginRefusedError`.
40
58
  *
41
- * After a refused login every later call that would send the PIN fails with the same error, without contacting the
42
- * bank; create a new client to try again. The README tells which errors refuse a login.
59
+ * The message is not safe to log: by default it holds the code, text and parameters of each error the bank names, such
60
+ * as an IBAN or a login name, and so does `stack`. Log `codes` instead. `feedback` is a getter, not a field, so that
61
+ * `JSON.stringify` and Node.js's report of an uncaught error, which show an error's fields as they are, leave out at
62
+ * least the warnings and successes, such as the new login data of return code 3072.
43
63
  */
44
64
  export class FinTsBankError extends FinTsError {
45
- /** All return codes of the response, warnings and successes included. */
46
- readonly feedback: readonly FinTsFeedback[];
65
+ override name = 'FinTsBankError';
47
66
 
48
- /** A rejection with the return codes in `feedback`; `message` defaults to the errors among them. */
49
- constructor(feedback: readonly FinTsFeedback[], message?: string, name = 'FinTsBankError') {
67
+ readonly #feedback: readonly FinTsFeedback[];
68
+
69
+ /**
70
+ * A rejection with the return codes in `feedback`; `message` defaults to the errors among them, with the bank's texts
71
+ * and parameters, such as `9010: Konto gesperrt [DE89370400440532013000]`, so it is not safe to log.
72
+ */
73
+ constructor(feedback: readonly FinTsFeedback[], message?: string) {
50
74
  super(
51
- name,
52
75
  message ??
53
76
  feedback
54
77
  .filter((f) => f.isError)
55
- .map((f) => f.toString())
78
+ .map(withText)
56
79
  .join('; '),
57
80
  );
58
- this.feedback = Object.freeze([...feedback]);
81
+ this.#feedback = Object.freeze([...feedback]);
82
+ }
83
+
84
+ /** All return codes of the response, warnings and successes included. */
85
+ get feedback(): readonly FinTsFeedback[] {
86
+ return this.#feedback;
59
87
  }
60
88
 
61
89
  /** The return codes of `feedback`. */
@@ -64,29 +92,45 @@ export class FinTsBankError extends FinTsError {
64
92
  }
65
93
  }
66
94
 
67
- /** Wrong or blocked PIN, a refused login as `FinTsBankError` describes it. */
68
- export class FinTsPinError extends FinTsBankError {
69
- constructor(feedback: readonly FinTsFeedback[]) {
70
- super(feedback, undefined, 'FinTsPinError');
71
- }
95
+ /**
96
+ * The bank refused the login, so the client sends the PIN no more: every later call that would send it fails with this
97
+ * error without contacting the bank, also in a client restored from the saved state, until
98
+ * `FinTsClient.clearLoginRefusal` takes a PIN the user has entered anew; for good where the bank has changed the login
99
+ * data (`FinTsLoginDataChangedError`).
100
+ *
101
+ * Banks lock the access after a few failed logins, and a return code does not always tell whether the PIN was the
102
+ * reason, so any error in the answer to a login refuses it, also one that ends the dialog, and so does a wrong PIN
103
+ * wherever the bank reports it. Only where the bank names a reason the client can take care of, another TAN method or a
104
+ * new customer system ID, does the same call log in once more, for each reason once. The client refuses the login
105
+ * itself where it could not read the answers to two logins in a row, which the bank may have counted as failed
106
+ * attempts; `feedback` is empty then.
107
+ */
108
+ export class FinTsLoginRefusedError extends FinTsBankError {
109
+ override name = 'FinTsLoginRefusedError';
110
+ }
111
+
112
+ /** Wrong or blocked PIN. */
113
+ export class FinTsPinError extends FinTsLoginRefusedError {
114
+ override name = 'FinTsPinError';
72
115
  }
73
116
 
74
117
  /** Invalid, expired or rejected TAN, as the bank reports it in answer to a TAN or to an approval status request. */
75
118
  export class FinTsTanError extends FinTsBankError {
76
- constructor(feedback: readonly FinTsFeedback[]) {
77
- super(feedback, undefined, 'FinTsTanError');
78
- }
119
+ override name = 'FinTsTanError';
79
120
  }
80
121
 
81
122
  /**
82
- * The bank has changed the user's login data and expects the new ones from now on (return code 3072).
123
+ * The bank has changed the user's login data and expects the new ones from now on (return code 3072), also where it
124
+ * names an error next to them, but for a rejected PIN, which is a `FinTsPinError`.
83
125
  *
84
- * The client has ended the dialog, and every later call fails with this error. Create a new client with the new data; a
85
- * `loginName` replaces user ID and customer ID: pass it as `userId`, without a customer ID.
126
+ * The client has ended the dialog, where the bank opened one, and logs in with the old data no more. Create a new
127
+ * client with the new data; a `loginName` replaces user ID and customer ID: pass it as `userId`, without a customer ID.
86
128
  */
87
- export class FinTsLoginDataChangedError extends FinTsBankError {
129
+ export class FinTsLoginDataChangedError extends FinTsLoginRefusedError {
130
+ override name = 'FinTsLoginDataChangedError';
131
+
88
132
  constructor(feedback: readonly FinTsFeedback[]) {
89
- super(feedback, 'The bank has changed the login data', 'FinTsLoginDataChangedError');
133
+ super(feedback, 'The bank has changed the login data');
90
134
  }
91
135
 
92
136
  /** The new user ID (Benutzerkennung); `undefined` where the bank names none. */
@@ -106,29 +150,36 @@ export class FinTsLoginDataChangedError extends FinTsBankError {
106
150
 
107
151
  private parameter(index: number): string | undefined {
108
152
  const change = this.feedback.find((f) => f.code === FeedbackCodes.newLoginData);
109
- const value = change?.parameters[index]?.trim();
110
- return value === undefined || value === '' ? undefined : value;
153
+ return nonBlank(change?.parameters[index]);
111
154
  }
112
155
  }
113
156
 
114
- /** The bank ended the dialog, for example after a timeout. */
157
+ /**
158
+ * The bank ended the dialog, for example after a timeout; the next call logs in anew. Where the bank ends the dialog
159
+ * in answer to a login, the call fails with `FinTsLoginRefusedError` instead.
160
+ */
115
161
  export class FinTsDialogAbortedError extends FinTsBankError {
116
- constructor(feedback: readonly FinTsFeedback[]) {
117
- super(feedback, undefined, 'FinTsDialogAbortedError');
118
- }
162
+ override name = 'FinTsDialogAbortedError';
119
163
  }
120
164
 
121
165
  /** The user cancelled the TAN entry or the approval in the banking app. */
122
166
  export class FinTsTanCancelledError extends FinTsError {
167
+ override name = 'FinTsTanCancelledError';
168
+
123
169
  constructor() {
124
- super('FinTsTanCancelledError', 'TAN entry cancelled');
170
+ super('TAN entry cancelled');
125
171
  }
126
172
  }
127
173
 
128
- /** The user did not approve in the banking app within the bank's polling limit. */
174
+ /**
175
+ * The user did not approve in the banking app in time: within the status requests the bank allows, 60 where it states
176
+ * none, and 10 minutes of polling at most.
177
+ */
129
178
  export class FinTsDecoupledTimeoutError extends FinTsError {
179
+ override name = 'FinTsDecoupledTimeoutError';
180
+
130
181
  constructor() {
131
- super('FinTsDecoupledTimeoutError', 'Approval on the separate device was not completed in time');
182
+ super('Approval on the separate device was not completed in time');
132
183
  }
133
184
  }
134
185
 
@@ -138,10 +189,12 @@ export class FinTsDecoupledTimeoutError extends FinTsError {
138
189
  * It does not extend `FinTsError`: it tells about the app's use of the client, not about the bank.
139
190
  */
140
191
  export class FinTsStateError extends Error {
141
- constructor(message: string) {
142
- super(message);
143
- this.name = 'FinTsStateError';
144
- }
192
+ override name = 'FinTsStateError';
193
+ }
194
+
195
+ /** The error of whatever a closed client is asked to do. */
196
+ export function clientClosedError(): FinTsStateError {
197
+ return new FinTsStateError('The FinTS client is closed');
145
198
  }
146
199
 
147
200
  /**
@@ -176,6 +229,11 @@ function isPinError(f: FinTsFeedback): boolean {
176
229
  }
177
230
  }
178
231
 
232
+ /** `f` with the bank's text and parameters, which `FinTsFeedback.toString` leaves out. */
233
+ function withText(f: FinTsFeedback): string {
234
+ return `${f.code}: ${f.text}${f.parameters.length === 0 ? '' : ` [${f.parameters.join(', ')}]`}`;
235
+ }
236
+
179
237
  /**
180
238
  * The most specific error for the errors in `feedback`, the answer to a message that `completesApproval` or not.
181
239
  *
@@ -192,7 +250,23 @@ export function bankErrorFor(feedback: readonly FinTsFeedback[], completesApprov
192
250
  return new FinTsBankError(feedback);
193
251
  }
194
252
 
195
- /** Runs `parse` on data from the bank and reports malformed data as `FinTsProtocolError`. */
253
+ /**
254
+ * The refusal of a login whose answer carried `feedback`: `FinTsPinError` for a rejected PIN, else
255
+ * `FinTsLoginDataChangedError` for new login data, also next to other errors, and `FinTsLoginRefusedError` for anything
256
+ * else. The same for a refusal restored from the saved state, which keeps only `feedback`.
257
+ *
258
+ * New login data end the old ones for good, so the session takes care of no other reason the answer names.
259
+ */
260
+ export function loginRefusalFor(feedback: readonly FinTsFeedback[]): FinTsLoginRefusedError {
261
+ if (feedback.some(isPinError)) return new FinTsPinError(feedback);
262
+ if (hasCode(feedback, FeedbackCodes.newLoginData)) return new FinTsLoginDataChangedError(feedback);
263
+ return new FinTsLoginRefusedError(feedback);
264
+ }
265
+
266
+ /**
267
+ * Runs `parse` on data from the bank and reports malformed data as `FinTsProtocolError`, whose message leaves the data
268
+ * out (`FormatError`).
269
+ */
196
270
  export function parseBankData<T>(parse: () => T): T {
197
271
  try {
198
272
  return parse();
@@ -201,3 +275,23 @@ export function parseBankData<T>(parse: () => T): T {
201
275
  throw error;
202
276
  }
203
277
  }
278
+
279
+ /**
280
+ * Runs `lookup`, which only completes data, such as the SEPA account list; `undefined` where the bank rejects it, so
281
+ * that the caller carries on without it. An aborted dialog counts as a rejection: banks end the dialog along with many.
282
+ * A refused login and a rejected TAN fail the call: the user must hear of them.
283
+ */
284
+ export async function tryLookUp<T>(lookup: () => Promise<T>): Promise<T | undefined> {
285
+ try {
286
+ return await lookup();
287
+ } catch (error) {
288
+ if (
289
+ !(error instanceof FinTsBankError) ||
290
+ error instanceof FinTsLoginRefusedError ||
291
+ error instanceof FinTsTanError
292
+ ) {
293
+ throw error;
294
+ }
295
+ return undefined;
296
+ }
297
+ }
@@ -1,3 +1,5 @@
1
+ import { inspectCustom } from '../util/inspect.js';
2
+
1
3
  /** A return code with text from the bank, for example `0020: Auftrag ausgeführt`. */
2
4
  export class FinTsFeedback {
3
5
  /** The four-digit return code; `0…` reports success, `3…` a warning and `9…` an error. */
@@ -9,7 +11,10 @@ export class FinTsFeedback {
9
11
  /** The bank's explanation of the code. */
10
12
  readonly text: string;
11
13
 
12
- /** Values the bank adds to the code, such as the position to continue at. */
14
+ /**
15
+ * Values the bank adds to the code, such as the position to continue at: one character per byte the bank sent (ISO
16
+ * 8859-1, the FinTS character set), since the client and the app send them back as they came.
17
+ */
13
18
  readonly parameters: readonly string[];
14
19
 
15
20
  constructor(init: { code: string; reference?: string | undefined; text: string; parameters?: readonly string[] }) {
@@ -34,8 +39,17 @@ export class FinTsFeedback {
34
39
  return this.code.startsWith('9');
35
40
  }
36
41
 
42
+ /**
43
+ * The code only, not the text and parameters, which may name the account or hold login data, such as those of return
44
+ * code 3072, so that logs and crash reports do not collect them; show `text` to the user.
45
+ */
37
46
  toString(): string {
38
- return `${this.code}: ${this.text}${this.parameters.length === 0 ? '' : ` [${this.parameters.join(', ')}]`}`;
47
+ return `FinTsFeedback(${this.code})`;
48
+ }
49
+
50
+ /** What Node.js's `util.inspect` and `console.log` show: `toString`, not the fields. */
51
+ [inspectCustom](): string {
52
+ return this.toString();
39
53
  }
40
54
  }
41
55
 
@@ -1,10 +1,18 @@
1
- import { formatIsoDate } from '../util/dates.js';
1
+ import { copyDate, formatIsoDate } from '../util/dates.js';
2
+ import { invalidArgument } from '../util/errors.js';
2
3
  import { ImmutableMap } from '../util/immutable-map.js';
4
+ import { inspectCustom } from '../util/inspect.js';
3
5
  import type { Amount } from './amount.js';
4
6
 
5
7
  /** Whether a transaction is booked or still pending. */
6
8
  export type TransactionStatus = 'booked' | 'pending';
7
9
 
10
+ /** `status`; throws `TypeError` unless it is a `TransactionStatus`. */
11
+ export function requireStatus(status: unknown): TransactionStatus {
12
+ if (status === 'booked' || status === 'pending') return status;
13
+ throw invalidArgument("Must be 'booked' or 'pending'", 'status');
14
+ }
15
+
8
16
  /** Transaction format to request; `auto` prefers camt.052 when the bank supports it. */
9
17
  export type TransactionFormat =
10
18
  /** camt.052 where the bank offers it, otherwise MT940. */
@@ -112,13 +120,18 @@ export class Transaction {
112
120
  /** The party the payer acts for, where that is not the payer itself. */
113
121
  readonly ultimateDebtor: string | undefined;
114
122
 
115
- /** All SEPA keywords found in the purpose, keyed by keyword (EREF, KREF, MREF, CRED, SVWZ, …). */
123
+ /**
124
+ * The SEPA keywords of the purpose, keyed by keyword (EREF, KREF, MREF, CRED, SVWZ, …): those `SepaPurpose` reads. In
125
+ * camt, EREF, MREF and CRED are those of `endToEndId`, `mandateId` and `creditorId`, which the bank states apart,
126
+ * never keywords of the remittance text the payer or the payee writes.
127
+ */
116
128
  readonly purposeCodes: ReadonlyMap<string, string>;
117
129
 
130
+ /** Takes copies of the dates and purpose codes of `init`: each transaction has its own, which no other one shares. */
118
131
  constructor(init: TransactionInit) {
119
132
  this.status = init.status ?? 'booked';
120
- this.bookingDate = init.bookingDate;
121
- this.valueDate = init.valueDate;
133
+ this.bookingDate = copyDate(init.bookingDate);
134
+ this.valueDate = copyDate(init.valueDate);
122
135
  this.amount = init.amount;
123
136
  this.isReversal = init.isReversal ?? false;
124
137
  this.bookingText = init.bookingText;
@@ -153,4 +166,9 @@ export class Transaction {
153
166
  toString(): string {
154
167
  return `Transaction(${formatIsoDate(this.bookingDate ?? this.valueDate)}, ${this.status}${this.isReversal ? ', reversal' : ''})`;
155
168
  }
169
+
170
+ /** What Node.js's `util.inspect` and `console.log` show: `toString`, not the fields. */
171
+ [inspectCustom](): string {
172
+ return this.toString();
173
+ }
156
174
  }
@@ -1,4 +1,5 @@
1
- import { concatBytes } from '../util/bytes.js';
1
+ import { invalidArgument } from '../util/errors.js';
2
+ import { trimBlanks } from '../util/text.js';
2
3
  import { FinTsTransportError } from './errors.js';
3
4
 
4
5
  /** Sends one FinTS message and returns the bank's reply; replace it for a relay, proxies, pinning or tests. */
@@ -6,9 +7,22 @@ export interface FinTsTransport {
6
7
  /**
7
8
  * Posts `body` to `url` and returns the response body.
8
9
  *
9
- * Implementations report failures as `FinTsTransportError`.
10
+ * Once the client is closed, it aborts `signal` five seconds later, and that of the dialog ends it then sends five
11
+ * seconds after the request on its way, if any, is over: the client no longer waits for the answer then, and the
12
+ * transport should stop the request, since the dialog end may go out while it still runs.
13
+ *
14
+ * The client bounds neither the time of the exchange nor the size of the answer: do so in the transport, as
15
+ * `HttpTransport` does, so that a server that stops answering cannot hold up a call and one that sends without end
16
+ * cannot exhaust the memory.
17
+ *
18
+ * Implementations report failures as `FinTsTransportError`, with the HTTP status as `statusCode` where the bank
19
+ * answered with an HTTP error: an anonymous request for the bank parameter data that fails so goes out once more,
20
+ * without the announcement of strong authentication, which some banks fail on. Of any other error, another
21
+ * `FinTsError` included, the client keeps only its message and the reason its cause gives (its message, or else the
22
+ * messages of its errors or its code), as a `FinTsTransportError` without `statusCode`. `body` holds PIN and TAN in
23
+ * plain text: keep it out of error messages and logs.
10
24
  */
11
- post(url: URL, body: Uint8Array, contentType: string): Promise<Uint8Array>;
25
+ post(url: URL, body: Uint8Array, contentType: string, signal: AbortSignal): Promise<Uint8Array>;
12
26
 
13
27
  /** Releases the connection; the client calls it when it is closed. */
14
28
  close(): void;
@@ -16,17 +30,38 @@ export interface FinTsTransport {
16
30
 
17
31
  /** What `HttpTransport` takes; all optional. */
18
32
  export interface HttpTransportOptions {
19
- /** The `fetch` to send with, for example one with a proxy agent; the global `fetch` by default. */
33
+ /**
34
+ * The `fetch` to send with, for example one with a proxy agent; the global `fetch` by default, as it is at each
35
+ * request.
36
+ *
37
+ * It must post to the URL it is given: an answer from another URL counts as a redirect, which fails the request. Of
38
+ * an answer whose `body` is no `ReadableStream`, as with node-fetch, the size can only be checked once it is read
39
+ * whole.
40
+ */
20
41
  fetch?: typeof fetch;
21
- /** Time for the whole exchange in milliseconds, from sending the request to the last byte of the response; 90 s. */
42
+ /**
43
+ * Time for the whole exchange in milliseconds, from sending the request to the last byte of the response; 90 s. At
44
+ * most 2147483647 (about 24 days), the longest `setTimeout` waits.
45
+ */
22
46
  timeoutMs?: number;
23
- /** Largest response accepted, so that a faulty server cannot exhaust the memory; 16 MiB. */
47
+ /** Largest response accepted in bytes, so that a faulty server cannot exhaust the memory; 16 MiB. */
24
48
  maxResponseBytes?: number;
25
49
  }
26
50
 
51
+ /** The longest delay `setTimeout` takes; it fires at once for a longer one, such as `Infinity`. */
52
+ const maxTimeoutMs = 2 ** 31 - 1;
53
+
27
54
  /**
28
55
  * Default `FinTsTransport`: HTTP POST with `fetch`.
29
56
  *
57
+ * Without the `fetch` option it sends with the global `fetch` as it is at each request, and cannot bypass what that
58
+ * does, for plain http to a local server (`FinTsConfig.allowInsecureUrl`) as for https: whatever replaces that `fetch`
59
+ * or handles its requests, such as a test's stub of `globalThis.fetch` or on Node.js a global dispatcher (undici's
60
+ * `setGlobalDispatcher`), gets every message, PIN and TAN included, and a proxy that the runtime takes from the
61
+ * environment, as Node.js does with `NODE_USE_ENV_PROXY=1`, and Deno and Bun do from `HTTP_PROXY`, sees the PIN of
62
+ * plain http unencrypted. Exempt the host with `NO_PROXY`, such as `localhost,127.0.0.1,::1`, or pass a `fetch` that
63
+ * connects directly.
64
+ *
30
65
  * In a browser it only reaches servers that allow the page's origin (CORS), which bank servers do not: a web app needs
31
66
  * a server of its own between browser and bank, reached with a `FinTsTransport` of its own.
32
67
  */
@@ -35,30 +70,47 @@ export class HttpTransport implements FinTsTransport {
35
70
  readonly maxResponseBytes: number;
36
71
  private readonly fetchFn: typeof fetch;
37
72
 
73
+ /**
74
+ * Throws `TypeError` if `timeoutMs` is not a positive number of at most 2147483647, or `maxResponseBytes` is not a
75
+ * positive number.
76
+ */
38
77
  constructor(options: HttpTransportOptions = {}) {
39
- this.fetchFn = options.fetch ?? ((input, init) => fetch(input, init));
78
+ const custom = options.fetch;
79
+ // Called as a plain function: a browser's native `fetch` fails as a method of another object.
80
+ this.fetchFn = custom === undefined ? (input, init) => fetch(input, init) : (input, init) => custom(input, init);
40
81
  this.timeoutMs = options.timeoutMs ?? 90_000;
41
82
  this.maxResponseBytes = options.maxResponseBytes ?? 16 << 20;
83
+ if (!isPositive(this.timeoutMs) || this.timeoutMs > maxTimeoutMs) {
84
+ throw invalidArgument(`Must be a positive number of at most ${maxTimeoutMs}`, 'timeoutMs');
85
+ }
86
+ if (!isPositive(this.maxResponseBytes)) throw invalidArgument('Must be a positive number', 'maxResponseBytes');
42
87
  }
43
88
 
44
- async post(url: URL, body: Uint8Array, contentType: string): Promise<Uint8Array> {
89
+ /** Fails with `FinTsTransportError` at the timeout or once `signal` is aborted; sends nothing if it is already. */
90
+ async post(url: URL, body: Uint8Array, contentType: string, signal?: AbortSignal): Promise<Uint8Array> {
91
+ if (signal?.aborted) throw new FinTsTransportError('Request aborted');
45
92
  const controller = new AbortController();
46
- let timer: ReturnType<typeof setTimeout> | undefined;
47
- // Waiting for the timeout as well covers a `fetch` that cannot abort a request.
48
- const timedOut = new Promise<never>((_, reject) => {
49
- timer = setTimeout(() => {
50
- controller.abort();
51
- reject(new FinTsTransportError('Request timed out'));
52
- }, this.timeoutMs);
93
+ // Raced with the exchange, for a `fetch` that cannot abort a request.
94
+ const stopped = new Promise<never>((_, reject) => {
95
+ controller.signal.addEventListener('abort', () => {
96
+ reject(controller.signal.reason as FinTsTransportError);
97
+ });
53
98
  });
99
+ const stop = (message: string) => (): void => {
100
+ controller.abort(new FinTsTransportError(message));
101
+ };
102
+ const timer = setTimeout(stop('Request timed out'), this.timeoutMs);
103
+ const abort = stop('Request aborted');
104
+ signal?.addEventListener('abort', abort);
54
105
  try {
55
- return await Promise.race([this.exchange(url, body, contentType, controller.signal), timedOut]);
106
+ return await Promise.race([this.exchange(url, body, contentType, controller.signal), stopped]);
56
107
  } catch (error) {
108
+ if (controller.signal.aborted) throw controller.signal.reason as FinTsTransportError;
57
109
  if (error instanceof FinTsTransportError) throw error;
58
- if (controller.signal.aborted) throw new FinTsTransportError('Request timed out');
59
- throw new FinTsTransportError(describe(error));
110
+ throw new FinTsTransportError(failureMessage(error));
60
111
  } finally {
61
112
  clearTimeout(timer);
113
+ signal?.removeEventListener('abort', abort);
62
114
  }
63
115
  }
64
116
 
@@ -69,36 +121,68 @@ export class HttpTransport implements FinTsTransport {
69
121
  headers: { 'Content-Type': contentType, 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
70
122
  // The PIN travels in the body: never let the request be repeated at another address.
71
123
  redirect: 'manual',
124
+ // In a browser: no cookies of the page and no referrer go with the PIN, and no cache keeps the exchange.
125
+ credentials: 'omit',
126
+ referrerPolicy: 'no-referrer',
127
+ cache: 'no-store',
72
128
  signal,
73
129
  });
74
130
  if (response.type === 'opaqueredirect') {
75
131
  throw new FinTsTransportError('The server redirects the request, which the client does not follow');
76
132
  }
133
+ // A `fetch` that ignores `redirect`, as one over XMLHttpRequest does, shows a redirect it followed only here.
134
+ if (response.redirected || (response.url && response.url !== withoutFragment(url))) {
135
+ await discard(response);
136
+ throw new FinTsTransportError('The fetch followed a redirect: the request may have gone to another address');
137
+ }
77
138
  if (response.status < 200 || response.status > 299) {
78
- await response.body?.cancel().catch(() => undefined);
79
- throw new FinTsTransportError(`HTTP ${response.status} ${response.statusText}`.trim(), {
139
+ await discard(response);
140
+ throw new FinTsTransportError(trimBlanks(`HTTP ${response.status} ${response.statusText}`), {
80
141
  statusCode: response.status,
81
142
  });
82
143
  }
144
+ const announced = response.headers.get('Content-Length');
145
+ if (announced !== null && /^\d+$/.test(announced) && Number(announced) > this.maxResponseBytes) {
146
+ await discard(response);
147
+ throw this.tooLarge();
148
+ }
83
149
  return this.readBody(response);
84
150
  }
85
151
 
86
152
  private async readBody(response: Response): Promise<Uint8Array> {
87
- const reader = response.body?.getReader();
88
- if (reader === undefined) return new Uint8Array(await response.arrayBuffer());
89
- const chunks: Uint8Array[] = [];
153
+ // A `fetch` that does not stream, as one over XMLHttpRequest or node-fetch, gives no body or one of another kind.
154
+ const body = response.body as ReadableStream<Uint8Array> | null | undefined;
155
+ if (typeof body?.getReader !== 'function') {
156
+ const bytes = new Uint8Array(await response.arrayBuffer());
157
+ if (bytes.length > this.maxResponseBytes) throw this.tooLarge();
158
+ return bytes;
159
+ }
160
+ const reader = body.getReader();
161
+ // Copied as they arrive: many tiny chunks kept as they are would take many times the bytes they hold, and a `fetch`
162
+ // may refill the chunk it handed out.
163
+ let bytes = new Uint8Array(0);
90
164
  let size = 0;
91
165
  for (;;) {
92
166
  const { done, value } = await reader.read();
93
167
  if (done) break;
94
- chunks.push(value);
95
- size += value.length;
96
- if (size > this.maxResponseBytes) {
168
+ const end = size + value.length;
169
+ if (end > this.maxResponseBytes) {
97
170
  await reader.cancel().catch(() => undefined);
98
- throw new FinTsTransportError(`Response larger than ${this.maxResponseBytes} bytes`);
171
+ throw this.tooLarge();
172
+ }
173
+ if (end > bytes.length) {
174
+ const grown = new Uint8Array(Math.min(Math.max(2 * bytes.length, end), this.maxResponseBytes));
175
+ grown.set(bytes.subarray(0, size));
176
+ bytes = grown;
99
177
  }
178
+ bytes.set(value, size);
179
+ size = end;
100
180
  }
101
- return concatBytes(chunks);
181
+ return bytes.slice(0, size);
182
+ }
183
+
184
+ private tooLarge(): FinTsTransportError {
185
+ return new FinTsTransportError(`Response larger than ${this.maxResponseBytes} bytes`);
102
186
  }
103
187
 
104
188
  close(): void {
@@ -106,9 +190,46 @@ export class HttpTransport implements FinTsTransport {
106
190
  }
107
191
  }
108
192
 
109
- /** The message of a `fetch` failure, with its cause: Node.js reports `fetch failed` and the reason in the cause. */
110
- function describe(error: unknown): string {
193
+ /** `url` as `Response.url` gives it: without fragment. */
194
+ function withoutFragment(url: URL): string {
195
+ const copy = new URL(url);
196
+ copy.hash = '';
197
+ return copy.href;
198
+ }
199
+
200
+ /** Cancels the body of a response the transport rejects, unless it cannot be cancelled, as a Node.js stream cannot. */
201
+ async function discard(response: Response): Promise<void> {
202
+ try {
203
+ await response.body?.cancel();
204
+ } catch {
205
+ // The response is rejected all the same.
206
+ }
207
+ }
208
+
209
+ function isPositive(value: number): boolean {
210
+ return Number.isFinite(value) && value > 0;
211
+ }
212
+
213
+ /**
214
+ * The message of a failed request, with the reason its cause gives: Node.js reports `fetch failed` and the reason in the
215
+ * cause. Only messages and codes, since the error may hold the request, PIN and TAN included.
216
+ */
217
+ export function failureMessage(error: unknown): string {
111
218
  if (!(error instanceof Error)) return String(error);
112
- const cause: unknown = error.cause;
113
- return cause instanceof Error ? `${error.message}: ${cause.message}` : error.message;
219
+ const reason = error.cause instanceof Error ? reasonOf(error.cause) : '';
220
+ return reason ? `${error.message}: ${reason}` : error.message;
221
+ }
222
+
223
+ /**
224
+ * The message of `cause`, or where it has none, the distinct messages of its `errors` or its `code`: Node.js reports a
225
+ * failed connection to a host with IPv6 and IPv4 addresses as an `AggregateError` without a message.
226
+ */
227
+ function reasonOf(cause: Error): string {
228
+ if (cause.message) return cause.message;
229
+ if (cause instanceof AggregateError) {
230
+ const messages = cause.errors.flatMap((error) => (error instanceof Error && error.message ? [error.message] : []));
231
+ if (messages.length > 0) return [...new Set(messages)].join('; ');
232
+ }
233
+ const code: unknown = (cause as { code?: unknown }).code;
234
+ return typeof code === 'string' ? code : '';
114
235
  }