@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,5 +1,6 @@
1
1
  import { FormatError } from '../util/errors.js';
2
- import { withoutWhitespace } from '../util/text.js';
2
+ import { inspectCustom } from '../util/inspect.js';
3
+ import { trimBlanks, withoutWhitespace } from '../util/text.js';
3
4
 
4
5
  /** Version of the chipTAN HHD specification a flicker code follows: 1.3, or 1.4, which adds control bytes. */
5
6
  export type HhdVersion = 'hhd13' | 'hhd14';
@@ -10,23 +11,36 @@ const codeMarker = 'CHLGUC';
10
11
  const textMarker = 'CHLGTEXT';
11
12
  const markerLengthDigits = 4;
12
13
 
14
+ /**
15
+ * The flicker code and the instruction text of a combined challenge; `undefined` unless `message` starts with
16
+ * `CHLGUC`, after blanks, and states the length of the code. White space in the code wraps lines and is dropped.
17
+ */
18
+ function splitCombined(message: string): { code: string; instruction: string } | undefined {
19
+ const text = trimBlanks(message, { end: false });
20
+ const end = text.indexOf(textMarker);
21
+ if (!text.startsWith(codeMarker) || end < 0) return undefined;
22
+ const lengthAndCode = withoutWhitespace(text.substring(codeMarker.length, end));
23
+ const code = lengthAndCode.substring(markerLengthDigits);
24
+ const length = lengthAndCode.substring(0, markerLengthDigits);
25
+ if (!/^[0-9]{4}$/.test(length) || parseInt(length, 10) !== code.length) return undefined;
26
+ return { code, instruction: text.substring(end + textMarker.length + markerLengthDigits) };
27
+ }
28
+
13
29
  /** Whether `message` is a combined challenge with an embedded flicker code. */
14
30
  export function hasEmbeddedFlickerCode(message: string): boolean {
15
- return message.includes(codeMarker);
31
+ return splitCombined(message) !== undefined;
16
32
  }
17
33
 
18
34
  /** The instruction text of a combined challenge; `message` itself if it is none. */
19
35
  export function challengeInstruction(message: string): string {
20
- const start = message.indexOf(textMarker);
21
- if (start < 0) return message;
22
- const textStart = start + textMarker.length + markerLengthDigits;
23
- return textStart < message.length ? message.substring(textStart) : '';
36
+ return splitCombined(message)?.instruction ?? message;
24
37
  }
25
38
 
26
39
  /**
27
40
  * Digit counts of the challenge length (LC) and of the data element lengths (LDE) to try when parsing.
28
41
  *
29
- * HHD 1.4 states the LC with three digits, HHD 1.3 with two.
42
+ * HHD 1.4 states the LC with three digits, HHD 1.3 with two. Some banks state the LDE of HHD 1.4 with three digits;
43
+ * a code that both LDE widths read is taken with two, as the specification has it.
30
44
  */
31
45
  const layouts = [
32
46
  { lcDigits: 3, lengthDigits: 2 },
@@ -34,6 +48,15 @@ const layouts = [
34
48
  { lcDigits: 2, lengthDigits: 2 },
35
49
  ];
36
50
 
51
+ /** The most bytes a length field states: HHD 1.3 flags ASCII data in bit 4 of it, HHD 1.4 in bit 6. */
52
+ const maxElementLength: Readonly<Record<HhdVersion, number>> = { hhd13: 0x0f, hhd14: 0x3f };
53
+
54
+ /** The most bytes of a rendered code, which its one-byte length (LC) states. */
55
+ const maxCodeLength = 0xff;
56
+
57
+ /** The characters a TAN generator shows; one byte each. */
58
+ const printableAscii = /^[\x20-\x7E]*$/;
59
+
37
60
  function hex(value: number, width: number): string {
38
61
  return value.toString(16).toUpperCase().padStart(width, '0');
39
62
  }
@@ -49,12 +72,17 @@ export class FlickerCode {
49
72
  private readonly dataElements: readonly DataElement[],
50
73
  ) {}
51
74
 
52
- /** Parses the challenge data sent by the bank; throws `FormatError` if it is not a flicker code. */
75
+ /**
76
+ * Parses the challenge data sent by the bank, the code itself or a combined challenge, which embeds it in the
77
+ * challenge text (`CHLGUC`), wrapped into lines; throws `FormatError` if it is not a flicker code a TAN generator can
78
+ * take: one with characters after the length it states, an empty data element, data other than printable ASCII, or
79
+ * more data than its length fields can state, such as more than 15 bytes in an element in HHD 1.3 or 255 bytes in all.
80
+ */
53
81
  static parse(code: string): FlickerCode {
54
- const cleaned = extractCode(code);
82
+ const embedded = splitCombined(code)?.code ?? trimBlanks(code);
55
83
  for (const layout of layouts) {
56
84
  try {
57
- return FlickerCode.parseLayout(new Reader(cleaned), layout.lcDigits, layout.lengthDigits);
85
+ return FlickerCode.parseLayout(new Reader(embedded), layout.lcDigits, layout.lengthDigits);
58
86
  } catch (error) {
59
87
  // Not this layout; try the next one.
60
88
  if (!(error instanceof FormatError)) throw error;
@@ -82,19 +110,28 @@ export class FlickerCode {
82
110
  const controlBytes: number[] = [];
83
111
  if ((startLength & 0x80) !== 0) {
84
112
  do {
113
+ if (controlBytes.length === 10) throw new FormatError('Too many control bytes in flicker code');
85
114
  controlBytes.push(challenge.hex(2));
86
- } while ((controlBytes.at(-1)! & 0x80) !== 0 && controlBytes.length < 10);
115
+ } while ((controlBytes.at(-1)! & 0x80) !== 0);
87
116
  }
88
- const startCode = new StartCode(challenge.take(startLength & 0x3f), controlBytes);
117
+ const startCode = new StartCode(challenge.take(elementLength(startLength & 0x7f)), controlBytes);
89
118
  const dataElements: DataElement[] = [];
90
119
  for (let i = 0; i < 3; i++) {
91
120
  dataElements.push(
92
- new DataElement(challenge.isAtEnd ? undefined : challenge.take(challenge.decimal(lengthDigits) & 0x3f)),
121
+ new DataElement(challenge.isAtEnd ? undefined : challenge.take(elementLength(challenge.decimal(lengthDigits)))),
93
122
  );
94
123
  }
95
- // The fields fill exactly the LC characters; a layout that reads them otherwise is the wrong one.
96
- if (!challenge.isAtEnd) throw new FormatError('Flicker code fields do not match its length');
97
- return new FlickerCode(controlBytes.length === 0 ? 'hhd13' : 'hhd14', lc, startCode, dataElements);
124
+ // Only a layout whose fields fill exactly the LC characters, which end the code, can be the right one.
125
+ if (!challenge.isAtEnd || !code.isAtEnd) throw new FormatError('Flicker code fields do not match its length');
126
+ const flicker = new FlickerCode(controlBytes.length === 0 ? 'hhd13' : 'hhd14', lc, startCode, dataElements);
127
+ if (!flicker.fitsTanGenerator) throw new FormatError('Flicker code data the TAN generator cannot take');
128
+ return flicker;
129
+ }
130
+
131
+ /** Whether the code renders as the bank sent it: its data printable ASCII, every length within its field. */
132
+ private get fitsTanGenerator(): boolean {
133
+ const elements = [this.startCode, ...this.dataElements];
134
+ return elements.every((element) => element.fits(this.version)) && this.payload().length <= 2 * maxCodeLength;
98
135
  }
99
136
 
100
137
  /** The data of the start code, which opens the challenge. */
@@ -161,15 +198,21 @@ export class FlickerCode {
161
198
  }
162
199
  return result;
163
200
  }
201
+
202
+ /** What Node.js's `util.inspect` and `console.log` show: the version, not the data, which may hold IBAN and amount. */
203
+ [inspectCustom](): string {
204
+ return `FlickerCode(${this.version})`;
205
+ }
164
206
  }
165
207
 
166
- /** The code of a combined challenge, or `code` itself without whitespace. */
167
- function extractCode(code: string): string {
168
- const compact = withoutWhitespace(code);
169
- const start = compact.indexOf(codeMarker);
170
- const codeStart = start + codeMarker.length + markerLengthDigits;
171
- const end = compact.indexOf(textMarker);
172
- return start >= 0 && end >= codeStart ? compact.substring(codeStart, end) : compact;
208
+ /**
209
+ * The length a field of the challenge states for the start code or a data element: 1 to 63 characters. The challenge
210
+ * leaves out an element without data and sets no flag in bit 6, which only the rendered code carries; bit 7 of the start
211
+ * code, which announces control bytes, is masked off before.
212
+ */
213
+ function elementLength(length: number): number {
214
+ if (length === 0 || length > 0x3f) throw new FormatError('Invalid data element length');
215
+ return length;
173
216
  }
174
217
 
175
218
  function xorChecksum(payload: string): string {
@@ -233,6 +276,12 @@ class DataElement {
233
276
  return this.data === undefined || /^[0-9]+$/.test(this.data);
234
277
  }
235
278
 
279
+ /** Whether the data are printable ASCII, and no more bytes than the length field of `version` can state. */
280
+ fits(version: HhdVersion): boolean {
281
+ const d = this.data;
282
+ return d === undefined || (printableAscii.test(d) && this.renderData().length / 2 <= maxElementLength[version]);
283
+ }
284
+
236
285
  renderData(): string {
237
286
  const d = this.data;
238
287
  if (d === undefined) return '';
@@ -1,6 +1,9 @@
1
1
  import { base64Encode, latin1Decode } from '../util/bytes.js';
2
+ import { inspectCustom } from '../util/inspect.js';
3
+ import { counted } from '../util/text.js';
2
4
 
3
- const imageMimeType = /^image\/[a-zA-Z0-9.+-]+$/;
5
+ /** The formats of photoTAN and QR-TAN images: raster images, which unlike SVG cannot carry scripts. */
6
+ const imageMimeTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp']);
4
7
 
5
8
  /** An image challenge (photoTAN, QR-TAN) with its MIME type. */
6
9
  export class TanImage {
@@ -14,23 +17,29 @@ export class TanImage {
14
17
  /**
15
18
  * Reads the bank's image container: a two-byte length and the MIME type, then a two-byte length and the image.
16
19
  *
17
- * `undefined` unless the MIME type is an image type and the container holds the whole image.
20
+ * `undefined` unless the MIME type, in any case, is that of a PNG, JPEG, GIF, WebP or BMP image, and the container
21
+ * holds the whole image, of at least one byte.
18
22
  */
19
23
  static tryParse(data: Uint8Array): TanImage | undefined {
20
24
  if (data.length < 4) return undefined;
21
25
  const mimeLength = (data[0]! << 8) | data[1]!;
22
26
  if (mimeLength === 0 || data.length < 2 + mimeLength + 2) return undefined;
23
- const mime = latin1Decode(data.subarray(2, 2 + mimeLength));
24
- if (!imageMimeType.test(mime)) return undefined;
27
+ const mime = latin1Decode(data.subarray(2, 2 + mimeLength)).toLowerCase();
28
+ if (!imageMimeTypes.has(mime)) return undefined;
25
29
  const offset = 2 + mimeLength;
26
30
  const imageLength = (data[offset]! << 8) | data[offset + 1]!;
27
31
  const start = offset + 2;
28
- if (start + imageLength > data.length) return undefined;
32
+ if (imageLength === 0 || start + imageLength > data.length) return undefined;
29
33
  return new TanImage(mime, data.subarray(start, start + imageLength));
30
34
  }
31
35
 
32
- /** The image as a `data:` URL, ready for the `src` of an `<img>`. */
36
+ /** The image as a `data:` URL, for the `src` of an `<img>` only: the bytes are the bank's, whatever their type says. */
33
37
  toDataUrl(): string {
34
38
  return `data:${this.mimeType};base64,${base64Encode(this.bytes)}`;
35
39
  }
40
+
41
+ /** What Node.js's `util.inspect` and `console.log` show: type and size, not the image, which shows the order. */
42
+ [inspectCustom](): string {
43
+ return `TanImage(${this.mimeType}, ${counted(this.bytes.length, 'byte')})`;
44
+ }
36
45
  }
@@ -1,3 +1,5 @@
1
+ import { copyDate } from '../util/dates.js';
2
+ import { inspectCustom } from '../util/inspect.js';
1
3
  import { maskedId } from '../util/text.js';
2
4
 
3
5
  /** Whether a TAN method needs the name of a TAN medium. */
@@ -82,7 +84,10 @@ export class TanMethod {
82
84
  /** Whether approval happens in a separate app instead of entering a TAN. */
83
85
  readonly decoupled: boolean;
84
86
 
85
- /** Most status requests the bank allows for a decoupled approval; 0 allows any number. */
87
+ /**
88
+ * Most status requests the bank allows for a decoupled approval, 0 for any number. The client sends 60 where the bank
89
+ * states none, and polls for 10 minutes at most.
90
+ */
86
91
  readonly decoupledMaxPolls: number | undefined;
87
92
 
88
93
  /** How long to wait before the first status request of a decoupled approval, in seconds. */
@@ -134,6 +139,11 @@ export class TanMethod {
134
139
  toString(): string {
135
140
  return `TanMethod(${this.securityFunction}, ${this.name})`;
136
141
  }
142
+
143
+ /** What Node.js's `util.inspect` and `console.log` show: `toString`, not the fields. */
144
+ [inspectCustom](): string {
145
+ return this.toString();
146
+ }
137
147
  }
138
148
 
139
149
  /** State of a TAN medium as reported by the bank. */
@@ -219,6 +229,7 @@ export class TanMedium {
219
229
  /** When the medium was activated. */
220
230
  readonly activatedOn: Date | undefined;
221
231
 
232
+ /** Takes copies of the dates of `init`: each medium has its own, which no other one shares. */
222
233
  constructor(init: TanMediumInit) {
223
234
  this.mediumClass = init.mediumClass;
224
235
  this.status = init.status;
@@ -228,10 +239,10 @@ export class TanMedium {
228
239
  this.cardSequenceNumber = init.cardSequenceNumber;
229
240
  this.mobileNumber = init.mobileNumber;
230
241
  this.mobileNumberMasked = init.mobileNumberMasked;
231
- this.validFrom = init.validFrom;
232
- this.validUntil = init.validUntil;
233
- this.lastUsed = init.lastUsed;
234
- this.activatedOn = init.activatedOn;
242
+ this.validFrom = copyDate(init.validFrom);
243
+ this.validUntil = copyDate(init.validUntil);
244
+ this.lastUsed = copyDate(init.lastUsed);
245
+ this.activatedOn = copyDate(init.activatedOn);
235
246
  }
236
247
 
237
248
  /** Whether the medium is the one in use. */
@@ -239,9 +250,17 @@ export class TanMedium {
239
250
  return this.status === 'active' || this.status === 'activeFollowUpCard';
240
251
  }
241
252
 
242
- /** The name, or the last digits of the card number, so that logs and crash reports do not collect the number. */
253
+ /**
254
+ * The medium class, or the last digits of the card number, and the status: no name, which the user may have given the
255
+ * medium after themselves, such as `Max’s iPhone`, so that logs and crash reports collect neither.
256
+ */
243
257
  toString(): string {
244
- const label = this.name ?? (this.cardNumber === undefined ? this.mediumClass : maskedId(this.cardNumber));
258
+ const label = this.cardNumber === undefined ? this.mediumClass : maskedId(this.cardNumber);
245
259
  return `TanMedium(${label}, ${this.status})`;
246
260
  }
261
+
262
+ /** What Node.js's `util.inspect` and `console.log` show: `toString`, not the fields. */
263
+ [inspectCustom](): string {
264
+ return this.toString();
265
+ }
247
266
  }
@@ -0,0 +1,40 @@
1
+ import { invalidArgument } from './errors.js';
2
+
3
+ // Plain JavaScript passes arguments of types the declarations rule out, which the Dart package's types refuse. These
4
+ // checks refuse them as well, with messages that leave out the value.
5
+
6
+ /** `value`; throws `TypeError` unless it is a string. */
7
+ export function requireString(value: unknown, name: string): string {
8
+ if (typeof value !== 'string') throw invalidArgument('Must be a string', name);
9
+ return value;
10
+ }
11
+
12
+ /** `value`, `undefined` for `null`; throws `TypeError` unless it is a string or neither. */
13
+ export function optionalString(value: unknown, name: string): string | undefined {
14
+ return value === undefined || value === null ? undefined : requireString(value, name);
15
+ }
16
+
17
+ /** `value`, `undefined` for `null`; throws `TypeError` unless it is an integer or neither. */
18
+ export function optionalInteger(value: unknown, name: string): number | undefined {
19
+ if (value === undefined || value === null) return undefined;
20
+ if (!Number.isInteger(value)) throw invalidArgument('Must be an integer', name);
21
+ return value as number;
22
+ }
23
+
24
+ /** `value`, `undefined` for `null`; throws `TypeError` unless it is a boolean or neither. */
25
+ export function optionalBoolean(value: unknown, name: string): boolean | undefined {
26
+ if (value === undefined || value === null) return undefined;
27
+ if (typeof value !== 'boolean') throw invalidArgument('Must be a boolean', name);
28
+ return value;
29
+ }
30
+
31
+ /** `value`; throws `TypeError` unless it is a function. */
32
+ export function requireFunction<F>(value: F, name: string): F {
33
+ if (typeof value !== 'function') throw invalidArgument('Must be a function', name);
34
+ return value;
35
+ }
36
+
37
+ /** `value`, `undefined` for `null`; throws `TypeError` unless it is a function or neither. */
38
+ export function optionalFunction<F>(value: F | null | undefined, name: string): F | undefined {
39
+ return value === undefined || value === null ? undefined : requireFunction(value, name);
40
+ }
package/src/util/async.ts CHANGED
@@ -37,17 +37,18 @@ export class Deferred<T> {
37
37
  * A failing action does not stop the ones queued after it.
38
38
  */
39
39
  export class SerialExecutor {
40
- private last: Promise<unknown> = Promise.resolve();
40
+ private last: Promise<void> = Promise.resolve();
41
41
 
42
- /** Runs `action` once the actions submitted before have finished; the result is that of `action`. */
42
+ /**
43
+ * Runs `action` once the actions submitted before have finished; the result is that of `action`, which the executor
44
+ * does not keep.
45
+ */
43
46
  run<T>(action: () => Promise<T>): Promise<T> {
44
- const result = this.last.then(action);
45
- this.last = result.catch(() => undefined);
47
+ const result = this.last.then(() => action());
48
+ this.last = result.then(
49
+ () => undefined,
50
+ () => undefined,
51
+ );
46
52
  return result;
47
53
  }
48
54
  }
49
-
50
- /** Waits for `promise` and ignores its result, so that neither a value nor an error goes unhandled. */
51
- export function ignoreOutcome(promise: Promise<unknown>): void {
52
- promise.catch(() => undefined);
53
- }
package/src/util/bytes.ts CHANGED
@@ -16,16 +16,9 @@ export function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
16
16
  /** Collects bytes, like a growing buffer. */
17
17
  export class BytesBuilder {
18
18
  private readonly parts: Uint8Array[] = [];
19
- private size = 0;
20
-
21
- get length(): number {
22
- return this.size;
23
- }
24
19
 
25
20
  add(bytes: Uint8Array | readonly number[]): void {
26
- const part = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
27
- this.parts.push(part);
28
- this.size += part.length;
21
+ this.parts.push(bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes));
29
22
  }
30
23
 
31
24
  addByte(byte: number): void {
@@ -35,7 +28,6 @@ export class BytesBuilder {
35
28
  takeBytes(): Uint8Array {
36
29
  const result = concatBytes(this.parts);
37
30
  this.parts.length = 0;
38
- this.size = 0;
39
31
  return result;
40
32
  }
41
33
  }
@@ -49,24 +41,32 @@ export function latin1Encode(text: string): Uint8Array {
49
41
 
50
42
  /** The text of ISO 8859-1 `bytes`. */
51
43
  export function latin1Decode(bytes: Uint8Array): string {
44
+ return fromCharCodes(bytes);
45
+ }
46
+
47
+ /** The text of the UTF-16 code units `codes`. */
48
+ export function fromCharCodes(codes: Uint8Array | Uint16Array): string {
52
49
  let text = '';
53
- // In chunks, since a spread of a large array exceeds the argument limit of some engines.
54
- for (let i = 0; i < bytes.length; i += 0x8000) {
55
- text += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
50
+ // In chunks, since a large array exceeds the argument limit of some engines. `apply` takes the array as it is, where a
51
+ // spread would iterate over it.
52
+ for (let i = 0; i < codes.length; i += 0x8000) {
53
+ text += Reflect.apply(String.fromCharCode, undefined, codes.subarray(i, i + 0x8000)) as string;
56
54
  }
57
55
  return text;
58
56
  }
59
57
 
60
58
  const utf8Encoder = new TextEncoder();
59
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
60
+ const lenientUtf8Decoder = new TextDecoder('utf-8');
61
61
 
62
62
  export function utf8Encode(text: string): Uint8Array {
63
63
  return utf8Encoder.encode(text);
64
64
  }
65
65
 
66
- /** Decodes UTF-8; throws `FormatError` for malformed data unless `allowMalformed`. */
66
+ /** Decodes UTF-8, without a leading BOM; throws `FormatError` for malformed data unless `allowMalformed`. */
67
67
  export function utf8Decode(bytes: Uint8Array, allowMalformed = false): string {
68
68
  try {
69
- return new TextDecoder('utf-8', { fatal: !allowMalformed, ignoreBOM: true }).decode(bytes);
69
+ return (allowMalformed ? lenientUtf8Decoder : utf8Decoder).decode(bytes);
70
70
  } catch {
71
71
  throw new FormatError('Invalid UTF-8');
72
72
  }
@@ -127,9 +127,3 @@ export function base64Decode(text: string): Uint8Array {
127
127
  }
128
128
  return out;
129
129
  }
130
-
131
- export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
132
- if (a.length !== b.length) return false;
133
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
134
- return true;
135
- }
@@ -7,8 +7,9 @@ interface AsyncStorage<T> {
7
7
  type AsyncStorageConstructor = new <T>() => AsyncStorage<T>;
8
8
 
9
9
  /**
10
- * `AsyncLocalStorage` where the runtime has it (Node.js, Deno, Bun), found without importing `node:async_hooks`, so that
11
- * bundlers for the browser have nothing to resolve.
10
+ * `AsyncLocalStorage` where the runtime has it, found through `process.getBuiltinModule` (Node.js 22.3, Deno 2.1 and
11
+ * Bun 1.2.6 or later) rather than by importing `node:async_hooks`, so that bundlers for the browser have nothing to
12
+ * resolve.
12
13
  */
13
14
  const AsyncStorageClass: AsyncStorageConstructor | undefined = (() => {
14
15
  const runtime = globalThis as {
package/src/util/dates.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // A value is either a calendar date, a local `Date` at midnight of which only the day counts, or the exact moment of a
2
- // date with a time of day, which the bank states in German time (see `germanTime`). The parsers keep the two apart: a
3
- // date without a valid time of day is not a moment.
2
+ // date with a time of day, which the bank states in German time (see `germanTime`) unless it gives the UTC offset. The
3
+ // parsers keep the two apart: a date without a valid time of day is not a moment. A time of day runs from 00:00:00 to
4
+ // 23:59:59, and 24:00:00, which ISO 8601 and XML Schema allow for the end of a day, is midnight of the next.
4
5
 
5
6
  const hourMs = 3_600_000;
6
7
 
@@ -30,7 +31,11 @@ export function formatIsoDate(d: Date): string {
30
31
  const fintsDate = /^(\d{4})(\d{2})(\d{2})$/;
31
32
  const fintsTime = /^(\d{2})(\d{2})(\d{2})/;
32
33
  const isoDate = /^(\d{4})-(\d{2})-(\d{2})/;
33
- const isoTime = /^(\d{2}):(\d{2}):(\d{2})/;
34
+ // The time of an XML Schema `dateTime`, whose UTC offset lies between -14:00 and +14:00.
35
+ const isoTime = /^(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))?$/;
36
+
37
+ /** A date and time of day as a clock shows them, the month counted from 1. */
38
+ type Clock = [year: number, month: number, day: number, hour: number, minute: number, second: number];
34
39
 
35
40
  /** Parses a FinTS 3.0 date `YYYYMMDD` as a calendar date; `undefined` if it is missing or invalid. */
36
41
  export function parseFintsDate(date: string | undefined): Date | undefined {
@@ -42,34 +47,64 @@ export function parseFintsDate(date: string | undefined): Date | undefined {
42
47
  * invalid.
43
48
  */
44
49
  export function parseFintsDateTime(date: string | undefined, time: string | undefined): Date | undefined {
45
- return moment(parseDate(fintsDate, date), fintsTime, time);
50
+ const clock = clockOn(parseDate(fintsDate, date), match(fintsTime, time));
51
+ return clock && germanTime(...clock);
46
52
  }
47
53
 
48
54
  /**
49
- * Parses an ISO 8601 date `YYYY-MM-DD` as a calendar date, ignoring extra characters such as a time part; `undefined`
50
- * if it is missing or invalid.
55
+ * Parses an ISO 8601 date `YYYY-MM-DD` as a calendar date, ignoring what follows it, such as a time zone; `undefined`
56
+ * if it is missing or invalid. A `T` right after the date makes it a date and time, which must be one `parseIsoMoment`
57
+ * reads, and the date the day in Germany at that moment: for a time in UTC late in the evening, the next day.
51
58
  */
52
59
  export function parseIsoDate(date: string | undefined): Date | undefined {
53
- return parseDate(isoDate, date);
60
+ if (date?.charAt(10) !== 'T') return parseDate(isoDate, date);
61
+ const moment = parseIsoMoment(date);
62
+ return moment && germanDay(moment);
63
+ }
64
+
65
+ /**
66
+ * Parses an ISO 8601 date and time as XML Schema writes it, `YYYY-MM-DDThh:mm:ss` with optional fractional seconds and
67
+ * UTC offset (`Z` or `±hh:mm` up to ±14:00), as the moment it names, to the second: in German time where it states no
68
+ * offset. `undefined` if it is invalid.
69
+ */
70
+ function parseIsoMoment(dateTime: string): Date | undefined {
71
+ const t = isoTime.exec(dateTime.slice(11));
72
+ const clock = clockOn(parseDate(isoDate, dateTime), t);
73
+ if (clock === undefined) return undefined;
74
+ const zone = t?.[5];
75
+ return zone === undefined ? germanTime(...clock) : new Date(utc(...clock) - offsetMs(zone));
76
+ }
77
+
78
+ /** The UTC offset `Z` or `±hh:mm` in milliseconds. */
79
+ function offsetMs(zone: string): number {
80
+ if (zone === 'Z') return 0;
81
+ const ms = (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4))) * 60_000;
82
+ return zone.startsWith('-') ? -ms : ms;
54
83
  }
55
84
 
56
- /** Parses an ISO 8601 date `YYYY-MM-DD` and time `HH:MM:SS` as the moment they name. */
57
- export function parseIsoDateTime(date: string | undefined, time: string | undefined): Date | undefined {
58
- return moment(parseDate(isoDate, date), isoTime, time);
85
+ function match(pattern: RegExp, text: string | undefined): RegExpExecArray | null {
86
+ return text === undefined ? null : pattern.exec(text);
59
87
  }
60
88
 
61
89
  function parseDate(pattern: RegExp, date: string | undefined): Date | undefined {
62
- const d = date === undefined ? null : pattern.exec(date);
90
+ const d = match(pattern, date);
63
91
  return d ? validDate(Number(d[1]), Number(d[2]), Number(d[3])) : undefined;
64
92
  }
65
93
 
66
- /** The moment of the German time matched by `timePattern` in `time` on `day`. */
67
- function moment(day: Date | undefined, timePattern: RegExp, time: string | undefined): Date | undefined {
68
- const t = time === undefined ? null : timePattern.exec(time);
94
+ /**
95
+ * The time of day `t` on `day`, where `t` matched hour, minute and second, and maybe a fraction of a second after them;
96
+ * `undefined` if either is missing or the time is invalid.
97
+ */
98
+ function clockOn(day: Date | undefined, t: RegExpExecArray | null): Clock | undefined {
69
99
  if (day === undefined || !t) return undefined;
70
100
  const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3])];
101
+ // The fraction counts only here: 24:00:00.5 lies past the end of the day.
102
+ if (hour === 24 && minute === 0 && second === 0 && !/[1-9]/.test(t[4] ?? '')) {
103
+ const next = localDate(day.getFullYear(), day.getMonth() + 1, day.getDate() + 1);
104
+ return [next.getFullYear(), next.getMonth() + 1, next.getDate(), 0, 0, 0];
105
+ }
71
106
  if (hour > 23 || minute > 59 || second > 59) return undefined;
72
- return germanTime(day.getFullYear(), day.getMonth() + 1, day.getDate(), hour, minute, second);
107
+ return [day.getFullYear(), day.getMonth() + 1, day.getDate(), hour, minute, second];
73
108
  }
74
109
 
75
110
  /**
@@ -78,12 +113,8 @@ function moment(day: Date | undefined, timePattern: RegExp, time: string | undef
78
113
  * fewer than six digits lost its leading zeros as a JSON number.
79
114
  */
80
115
  export function parseUtcPlusOneDateTime(date: string | undefined, time: string | undefined): Date | undefined {
81
- const day = parseDate(fintsDate, date);
82
- const t = time === undefined ? null : fintsTime.exec(time.padStart(6, '0'));
83
- if (day === undefined || !t) return undefined;
84
- const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3])];
85
- if (hour > 23 || minute > 59 || second > 59) return undefined;
86
- return new Date(utc(day.getFullYear(), day.getMonth() + 1, day.getDate(), hour, minute, second) - hourMs);
116
+ const clock = clockOn(parseDate(fintsDate, date), match(fintsTime, time?.padStart(6, '0')));
117
+ return clock && new Date(utc(...clock) - hourMs);
87
118
  }
88
119
 
89
120
  /**
@@ -93,21 +124,14 @@ export function parseUtcPlusOneDateTime(date: string | undefined, time: string |
93
124
  * Sunday of October (the EU rule). The hour the clocks show twice in October counts as CEST; a time in the hour that is
94
125
  * skipped in March counts as CET.
95
126
  */
96
- export function germanTime(
97
- year: number,
98
- month: number,
99
- day: number,
100
- hour: number,
101
- minute: number,
102
- second: number,
103
- ): Date {
127
+ function germanTime(year: number, month: number, day: number, hour: number, minute: number, second: number): Date {
104
128
  const clock = utc(year, month, day, hour, minute, second);
105
129
  const summer = clock - 2 * hourMs;
106
130
  return new Date(isSummerTime(summer) ? summer : clock - hourMs);
107
131
  }
108
132
 
109
133
  /** The calendar day a German clock shows at `moment`, as a local `Date` at midnight like every calendar date. */
110
- export function germanDay(moment: Date): Date {
134
+ function germanDay(moment: Date): Date {
111
135
  const utcMs = moment.getTime();
112
136
  const clock = new Date(utcMs + (isSummerTime(utcMs) ? 2 : 1) * hourMs);
113
137
  return localDate(clock.getUTCFullYear(), clock.getUTCMonth() + 1, clock.getUTCDate());
@@ -134,7 +158,7 @@ function utc(year: number, month: number, day: number, hour: number, minute: num
134
158
  }
135
159
 
136
160
  /** Local midnight of the day; `month` counts from 1, and years below 100 are not moved to 19xx. */
137
- export function localDate(year: number, month: number, day: number): Date {
161
+ function localDate(year: number, month: number, day: number): Date {
138
162
  const d = new Date(0);
139
163
  d.setFullYear(year, month - 1, day);
140
164
  d.setHours(0, 0, 0, 0);
@@ -142,7 +166,7 @@ export function localDate(year: number, month: number, day: number): Date {
142
166
  }
143
167
 
144
168
  /** The date, or `undefined` if `month` or `day` is out of range; `Date` would roll it over into another date. */
145
- export function validDate(year: number, month: number, day: number): Date | undefined {
169
+ function validDate(year: number, month: number, day: number): Date | undefined {
146
170
  if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return undefined;
147
171
  return localDate(year, month, day);
148
172
  }
@@ -170,11 +194,18 @@ export function parseSwiftDate(yymmdd: string): Date | undefined {
170
194
  }
171
195
 
172
196
  /** Number of days the `month` (1 to 12) of `year` has. */
173
- export function daysInMonth(year: number, month: number): number {
197
+ function daysInMonth(year: number, month: number): number {
174
198
  if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28;
175
199
  return [4, 6, 9, 11].includes(month) ? 30 : 31;
176
200
  }
177
201
 
202
+ /** A copy of `d`, so that a model keeps dates of its own: a `Date` can be changed in place. */
203
+ export function copyDate(d: Date): Date;
204
+ export function copyDate(d: Date | undefined): Date | undefined;
205
+ export function copyDate(d: Date | undefined): Date | undefined {
206
+ return d && new Date(d.getTime());
207
+ }
208
+
178
209
  /** The calendar day `moment` falls on in local time, as a local `Date` at midnight. */
179
210
  export function calendarDay(moment: Date): Date {
180
211
  return localDate(moment.getFullYear(), moment.getMonth() + 1, moment.getDate());
@@ -1,13 +1,29 @@
1
- /** Data that do not have the expected format, such as a malformed MT940 line or amount. */
1
+ /**
2
+ * Data that do not have the expected format, such as a malformed MT940 line or amount.
3
+ *
4
+ * The message leaves out `source`, which may hold account data, such as amounts, names and IBANs, so that logs and
5
+ * crash reports do not collect them. For the same reason `source` is a getter, not a field: `JSON.stringify` and
6
+ * Node.js's report of an uncaught error show an error's fields as they are.
7
+ */
2
8
  export class FormatError extends Error {
3
- /** The part of the data that is malformed, where the parser can tell it. */
4
- readonly source: string | undefined;
9
+ override name = 'FormatError';
10
+
11
+ readonly #source: string | undefined;
5
12
 
6
13
  constructor(message: string, source?: string) {
7
- super(source === undefined ? message : `${message}: ${source}`);
8
- this.name = 'FormatError';
9
- this.source = source;
14
+ super(message);
15
+ this.#source = source;
10
16
  }
17
+
18
+ /** The part of the data that is malformed, where the parser can tell it. */
19
+ get source(): string | undefined {
20
+ return this.#source;
21
+ }
22
+ }
23
+
24
+ /** Throws a `FormatError`, where an expression is expected, such as `value ?? fail('…')`. */
25
+ export function fail(message: string, source?: string): never {
26
+ throw new FormatError(message, source);
11
27
  }
12
28
 
13
29
  /** An argument the function cannot work with, such as an empty PIN. The message never contains a PIN. */