@coderoftime/fints 0.2.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 (215) hide show
  1. package/CHANGELOG.md +377 -0
  2. package/README.md +31 -23
  3. package/dist/client.d.ts +3 -2
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +52 -9
  6. package/dist/client.js.map +1 -1
  7. package/dist/core/account.d.ts +15 -4
  8. package/dist/core/account.d.ts.map +1 -1
  9. package/dist/core/account.js +84 -36
  10. package/dist/core/account.js.map +1 -1
  11. package/dist/core/amount.d.ts +13 -3
  12. package/dist/core/amount.d.ts.map +1 -1
  13. package/dist/core/amount.js +42 -8
  14. package/dist/core/amount.js.map +1 -1
  15. package/dist/core/balance.d.ts +3 -0
  16. package/dist/core/balance.d.ts.map +1 -1
  17. package/dist/core/balance.js +5 -0
  18. package/dist/core/balance.js.map +1 -1
  19. package/dist/core/bank-info.d.ts +20 -3
  20. package/dist/core/bank-info.d.ts.map +1 -1
  21. package/dist/core/bank-info.js +25 -4
  22. package/dist/core/bank-info.js.map +1 -1
  23. package/dist/core/client.d.ts +42 -15
  24. package/dist/core/client.d.ts.map +1 -1
  25. package/dist/core/config.d.ts +15 -7
  26. package/dist/core/config.d.ts.map +1 -1
  27. package/dist/core/config.js +29 -14
  28. package/dist/core/config.js.map +1 -1
  29. package/dist/core/errors.d.ts +17 -5
  30. package/dist/core/errors.d.ts.map +1 -1
  31. package/dist/core/errors.js +25 -7
  32. package/dist/core/errors.js.map +1 -1
  33. package/dist/core/feedback.d.ts +7 -0
  34. package/dist/core/feedback.d.ts.map +1 -1
  35. package/dist/core/feedback.js +10 -1
  36. package/dist/core/feedback.js.map +1 -1
  37. package/dist/core/transaction.d.ts +5 -0
  38. package/dist/core/transaction.d.ts.map +1 -1
  39. package/dist/core/transaction.js +12 -0
  40. package/dist/core/transaction.js.map +1 -1
  41. package/dist/core/transport.d.ts +22 -7
  42. package/dist/core/transport.d.ts.map +1 -1
  43. package/dist/core/transport.js +51 -11
  44. package/dist/core/transport.js.map +1 -1
  45. package/dist/formats/camt.d.ts +7 -4
  46. package/dist/formats/camt.d.ts.map +1 -1
  47. package/dist/formats/camt.js +53 -54
  48. package/dist/formats/camt.js.map +1 -1
  49. package/dist/formats/mt940.d.ts +18 -6
  50. package/dist/formats/mt940.d.ts.map +1 -1
  51. package/dist/formats/mt940.js +83 -66
  52. package/dist/formats/mt940.js.map +1 -1
  53. package/dist/formats/readers.d.ts.map +1 -1
  54. package/dist/formats/readers.js +6 -2
  55. package/dist/formats/readers.js.map +1 -1
  56. package/dist/formats/sepa-purpose.d.ts +7 -4
  57. package/dist/formats/sepa-purpose.d.ts.map +1 -1
  58. package/dist/formats/sepa-purpose.js +18 -9
  59. package/dist/formats/sepa-purpose.js.map +1 -1
  60. package/dist/session/dialog-runner.d.ts +5 -2
  61. package/dist/session/dialog-runner.d.ts.map +1 -1
  62. package/dist/session/dialog-runner.js +23 -7
  63. package/dist/session/dialog-runner.js.map +1 -1
  64. package/dist/session/login-guard.d.ts +4 -4
  65. package/dist/session/login-guard.d.ts.map +1 -1
  66. package/dist/session/login-guard.js +29 -14
  67. package/dist/session/login-guard.js.map +1 -1
  68. package/dist/session/session-setup.d.ts +14 -5
  69. package/dist/session/session-setup.d.ts.map +1 -1
  70. package/dist/session/session-setup.js +30 -9
  71. package/dist/session/session-setup.js.map +1 -1
  72. package/dist/session/session-state.d.ts +4 -3
  73. package/dist/session/session-state.d.ts.map +1 -1
  74. package/dist/session/session-state.js +28 -9
  75. package/dist/session/session-state.js.map +1 -1
  76. package/dist/session/signed-dialog.d.ts +19 -13
  77. package/dist/session/signed-dialog.d.ts.map +1 -1
  78. package/dist/session/signed-dialog.js +45 -30
  79. package/dist/session/signed-dialog.js.map +1 -1
  80. package/dist/session/user-prompts.d.ts +6 -1
  81. package/dist/session/user-prompts.d.ts.map +1 -1
  82. package/dist/session/user-prompts.js +19 -4
  83. package/dist/session/user-prompts.js.map +1 -1
  84. package/dist/tan/challenge.d.ts +15 -13
  85. package/dist/tan/challenge.d.ts.map +1 -1
  86. package/dist/tan/challenge.js +32 -26
  87. package/dist/tan/challenge.js.map +1 -1
  88. package/dist/tan/flicker.d.ts +5 -1
  89. package/dist/tan/flicker.d.ts.map +1 -1
  90. package/dist/tan/flicker.js +42 -30
  91. package/dist/tan/flicker.js.map +1 -1
  92. package/dist/tan/tan-image.d.ts +4 -1
  93. package/dist/tan/tan-image.d.ts.map +1 -1
  94. package/dist/tan/tan-image.js +8 -2
  95. package/dist/tan/tan-image.js.map +1 -1
  96. package/dist/tan/tan-models.d.ts +9 -1
  97. package/dist/tan/tan-models.d.ts.map +1 -1
  98. package/dist/tan/tan-models.js +14 -2
  99. package/dist/tan/tan-models.js.map +1 -1
  100. package/dist/util/arguments.d.ts +13 -0
  101. package/dist/util/arguments.d.ts.map +1 -0
  102. package/dist/util/arguments.js +40 -0
  103. package/dist/util/arguments.js.map +1 -0
  104. package/dist/util/async.d.ts +4 -1
  105. package/dist/util/async.d.ts.map +1 -1
  106. package/dist/util/async.js +6 -3
  107. package/dist/util/async.js.map +1 -1
  108. package/dist/util/bytes.d.ts +1 -1
  109. package/dist/util/bytes.d.ts.map +1 -1
  110. package/dist/util/bytes.js +3 -3
  111. package/dist/util/bytes.js.map +1 -1
  112. package/dist/util/dates.d.ts +3 -10
  113. package/dist/util/dates.d.ts.map +1 -1
  114. package/dist/util/dates.js +49 -39
  115. package/dist/util/dates.js.map +1 -1
  116. package/dist/util/errors.d.ts +5 -3
  117. package/dist/util/errors.d.ts.map +1 -1
  118. package/dist/util/errors.js +8 -4
  119. package/dist/util/errors.js.map +1 -1
  120. package/dist/util/immutable-map.d.ts +5 -2
  121. package/dist/util/immutable-map.d.ts.map +1 -1
  122. package/dist/util/immutable-map.js +15 -12
  123. package/dist/util/immutable-map.js.map +1 -1
  124. package/dist/util/inspect.d.ts +11 -0
  125. package/dist/util/inspect.d.ts.map +1 -0
  126. package/dist/util/inspect.js +14 -0
  127. package/dist/util/inspect.js.map +1 -0
  128. package/dist/util/text.d.ts +18 -2
  129. package/dist/util/text.d.ts.map +1 -1
  130. package/dist/util/text.js +50 -5
  131. package/dist/util/text.js.map +1 -1
  132. package/dist/util/xml.d.ts +6 -4
  133. package/dist/util/xml.d.ts.map +1 -1
  134. package/dist/util/xml.js +106 -44
  135. package/dist/util/xml.js.map +1 -1
  136. package/dist/v3/bank-connection.d.ts +23 -3
  137. package/dist/v3/bank-connection.d.ts.map +1 -1
  138. package/dist/v3/bank-connection.js +66 -6
  139. package/dist/v3/bank-connection.js.map +1 -1
  140. package/dist/v3/dialog.d.ts +6 -2
  141. package/dist/v3/dialog.d.ts.map +1 -1
  142. package/dist/v3/dialog.js +20 -9
  143. package/dist/v3/dialog.js.map +1 -1
  144. package/dist/v3/message.d.ts +18 -10
  145. package/dist/v3/message.d.ts.map +1 -1
  146. package/dist/v3/message.js +92 -75
  147. package/dist/v3/message.js.map +1 -1
  148. package/dist/v3/parameter-store.d.ts +43 -7
  149. package/dist/v3/parameter-store.d.ts.map +1 -1
  150. package/dist/v3/parameter-store.js +69 -15
  151. package/dist/v3/parameter-store.js.map +1 -1
  152. package/dist/v3/parameters.d.ts +12 -11
  153. package/dist/v3/parameters.d.ts.map +1 -1
  154. package/dist/v3/parameters.js +57 -35
  155. package/dist/v3/parameters.js.map +1 -1
  156. package/dist/v3/protocol.d.ts +16 -9
  157. package/dist/v3/protocol.d.ts.map +1 -1
  158. package/dist/v3/protocol.js +57 -26
  159. package/dist/v3/protocol.js.map +1 -1
  160. package/dist/v3/queries.d.ts +2 -2
  161. package/dist/v3/queries.d.ts.map +1 -1
  162. package/dist/v3/queries.js +24 -16
  163. package/dist/v3/queries.js.map +1 -1
  164. package/dist/v3/segments.d.ts +18 -11
  165. package/dist/v3/segments.d.ts.map +1 -1
  166. package/dist/v3/segments.js +30 -21
  167. package/dist/v3/segments.js.map +1 -1
  168. package/dist/v3/syntax.d.ts +15 -11
  169. package/dist/v3/syntax.d.ts.map +1 -1
  170. package/dist/v3/syntax.js +30 -15
  171. package/dist/v3/syntax.js.map +1 -1
  172. package/package.json +1 -1
  173. package/src/client.ts +58 -17
  174. package/src/core/account.ts +91 -35
  175. package/src/core/amount.ts +42 -9
  176. package/src/core/balance.ts +6 -0
  177. package/src/core/bank-info.ts +28 -4
  178. package/src/core/client.ts +42 -15
  179. package/src/core/config.ts +37 -18
  180. package/src/core/errors.ts +27 -7
  181. package/src/core/feedback.ts +12 -1
  182. package/src/core/transaction.ts +13 -0
  183. package/src/core/transport.ts +62 -16
  184. package/src/formats/camt.ts +62 -51
  185. package/src/formats/mt940.ts +114 -78
  186. package/src/formats/readers.ts +6 -2
  187. package/src/formats/sepa-purpose.ts +17 -9
  188. package/src/session/dialog-runner.ts +34 -7
  189. package/src/session/login-guard.ts +29 -13
  190. package/src/session/session-setup.ts +27 -9
  191. package/src/session/session-state.ts +25 -8
  192. package/src/session/signed-dialog.ts +60 -30
  193. package/src/session/user-prompts.ts +28 -3
  194. package/src/tan/challenge.ts +50 -36
  195. package/src/tan/flicker.ts +40 -29
  196. package/src/tan/tan-image.ts +9 -2
  197. package/src/tan/tan-models.ts +16 -2
  198. package/src/util/arguments.ts +40 -0
  199. package/src/util/async.ts +10 -4
  200. package/src/util/bytes.ts +3 -3
  201. package/src/util/dates.ts +52 -38
  202. package/src/util/errors.ts +9 -4
  203. package/src/util/immutable-map.ts +15 -12
  204. package/src/util/inspect.ts +14 -0
  205. package/src/util/text.ts +47 -5
  206. package/src/util/xml.ts +105 -38
  207. package/src/v3/bank-connection.ts +65 -6
  208. package/src/v3/dialog.ts +18 -8
  209. package/src/v3/message.ts +81 -72
  210. package/src/v3/parameter-store.ts +82 -16
  211. package/src/v3/parameters.ts +68 -41
  212. package/src/v3/protocol.ts +61 -28
  213. package/src/v3/queries.ts +26 -18
  214. package/src/v3/segments.ts +32 -20
  215. package/src/v3/syntax.ts +31 -14
@@ -1,6 +1,7 @@
1
1
  import { latin1Decode } from '../util/bytes.js';
2
2
  import { copyDate } from '../util/dates.js';
3
- import { characterReference } from '../util/text.js';
3
+ import { frozenWithInspect, inspectCustom } from '../util/inspect.js';
4
+ import { characterReference, trimBlanks } from '../util/text.js';
4
5
  import { challengeInstruction, FlickerCode, hasEmbeddedFlickerCode } from './flicker.js';
5
6
  import { TanImage } from './tan-image.js';
6
7
  import type { TanMedium, TanMethod } from './tan-models.js';
@@ -25,6 +26,9 @@ const formattingTag = /<\/?(?:b|i|u|ul|ol)\b[^<>]*>/gi;
25
26
  const entityReference = /&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g;
26
27
  const blankLines = /\n{3,}/g;
27
28
 
29
+ /** Longest challenge `plainMessage` formats: far longer than the specification allows (an..2048). */
30
+ const maxFormattedLength = 65_536;
31
+
28
32
  const namedEntities: Record<string, string> = {
29
33
  amp: '&',
30
34
  lt: '<',
@@ -131,8 +135,9 @@ export class TanChallenge {
131
135
  if (data !== undefined) {
132
136
  image = TanImage.tryParse(data);
133
137
  if (image === undefined) flicker = FlickerCode.tryParse(latin1Decode(data));
134
- } else if (init.message !== undefined && hasEmbeddedFlickerCode(init.message)) {
135
- flicker = FlickerCode.tryParse(init.message);
138
+ }
139
+ if (image === undefined && init.message !== undefined && hasEmbeddedFlickerCode(init.message)) {
140
+ flicker ??= FlickerCode.tryParse(init.message);
136
141
  }
137
142
  return new TanChallenge({
138
143
  ...init,
@@ -157,7 +162,7 @@ export class TanChallenge {
157
162
  * A method with structured challenges (`TanMethod.structuredChallenge`) may use the formatting tags the specification
158
163
  * defines. Line breaks, paragraphs and list items become new lines. Bold, italics, underlining and list tags are
159
164
  * removed, and the entity references of such markup are resolved. Any other challenge stays as the bank sent it,
160
- * which is how the specification wants it shown.
165
+ * which is how the specification wants it shown. So does a structured challenge of more than 65,536 characters.
161
166
  *
162
167
  * Show it as text, such as with `textContent`, or `{{ }}` in Vue, and `white-space: pre-line` for its line breaks;
163
168
  * never as HTML (`innerHTML`, `v-html`). It keeps the tags the specification does not define, and `&lt;` becomes
@@ -167,19 +172,33 @@ export class TanChallenge {
167
172
  const text = this.message;
168
173
  if (text === undefined) return undefined;
169
174
  const instruction = challengeInstruction(text);
170
- if (!this.method.structuredChallenge) return instruction;
171
- return instruction
172
- .replace(lineBreak, '\n')
173
- .replace(blockTag, '\n')
174
- .replace(formattingTag, '')
175
- .replace(entityReference, (match, name: string) => decodeEntity(name) ?? match)
176
- .replace(blankLines, '\n\n')
177
- .trim();
175
+ if (!this.method.structuredChallenge || instruction.length > maxFormattedLength) return instruction;
176
+ return trimBlanks(
177
+ instruction
178
+ .replace(lineBreak, '\n')
179
+ .replace(blockTag, '\n')
180
+ .replace(formattingTag, '')
181
+ .replace(entityReference, (match, name: string) => decodeEntity(name) ?? match)
182
+ .replace(blankLines, '\n\n'),
183
+ );
184
+ }
185
+
186
+ /**
187
+ * What Node.js's `util.inspect` and `console.log` show: the method, the kind of challenge and the attempt; not the
188
+ * text, the reference, the challenge data, the image or the medium, which may name the user, the account or the order.
189
+ */
190
+ [inspectCustom](): string {
191
+ const kinds = [
192
+ ...(this.isDecoupled ? ['decoupled'] : []),
193
+ ...(this.flickerCode === undefined ? [] : ['flicker code']),
194
+ ...(this.image === undefined ? [] : ['image']),
195
+ ];
196
+ return `TanChallenge(${[this.method.toString(), ...kinds, `attempt ${this.attempt}`].join(', ')})`;
178
197
  }
179
198
  }
180
199
 
181
200
  // Copies of a challenge for the prompts of one approval. Not methods of `TanChallenge`, so that they are not part of its
182
- // API; they stand beside the class, so that a field added to it is added here as well.
201
+ // API. The own fields of a challenge are those of `TanChallengeInit`, so a copy spreads it.
183
202
 
184
203
  /** `challenge` again, counted as the next prompt for the same approval, with the bank's new instructions `message`. */
185
204
  export function nextAttempt(challenge: TanChallenge, message?: string): TanChallenge {
@@ -192,27 +211,15 @@ export function withResolved(challenge: TanChallenge, resolved: Promise<void>):
192
211
  }
193
212
 
194
213
  function copyChallenge(challenge: TanChallenge, changes: Partial<TanChallengeInit>): TanChallenge {
195
- return new TanChallenge({
196
- method: challenge.method,
197
- reference: challenge.reference,
198
- message: challenge.message,
199
- hhdUc: challenge.hhdUc,
200
- flickerCode: challenge.flickerCode,
201
- image: challenge.image,
202
- isDecoupled: challenge.isDecoupled,
203
- validUntil: challenge.validUntil,
204
- tanMediumName: challenge.tanMediumName,
205
- attempt: challenge.attempt,
206
- resolved: challenge.resolved,
207
- ...changes,
208
- });
214
+ const init: TanChallengeInit = challenge;
215
+ return new TanChallenge({ ...init, ...changes });
209
216
  }
210
217
 
211
218
  /** The user's answer to a `TanChallenge`: `TanAnswer.tan(…)`, `TanAnswer.approved` or `TanAnswer.cancelled`. */
212
219
  export type TanAnswer =
213
220
  /**
214
- * The TAN the user entered; an empty one cancels a TAN challenge. Blanks around it do not count, and digits of
215
- * another width, such as full-width ones, count as the digits they are (Unicode NFKC).
221
+ * The TAN the user entered; an empty one cancels a TAN challenge. Blanks around it do not count, and full-width
222
+ * characters, as East Asian input methods type them, count as their ASCII counterparts.
216
223
  */
217
224
  | { readonly kind: 'tan'; readonly tan: string }
218
225
  /** The user reports the approval in the banking app (`TanChallenge.isDecoupled`); the client then asks the bank. */
@@ -220,13 +227,18 @@ export type TanAnswer =
220
227
  /** The user cancels: the client ends the bank dialog, and the call fails with `FinTsTanCancelledError`. */
221
228
  | { readonly kind: 'cancelled' };
222
229
 
223
- const approvedAnswer: TanAnswer = Object.freeze({ kind: 'approved' });
224
- const cancelledAnswer: TanAnswer = Object.freeze({ kind: 'cancelled' });
230
+ /** `answer` frozen, which `util.inspect` shows by its kind, never with the TAN. */
231
+ function frozenAnswer(answer: TanAnswer): TanAnswer {
232
+ return frozenWithInspect(answer, (a) => `TanAnswer(${a.kind})`);
233
+ }
234
+
235
+ const approvedAnswer = frozenAnswer({ kind: 'approved' });
236
+ const cancelledAnswer = frozenAnswer({ kind: 'cancelled' });
225
237
 
226
238
  /** The answers a `TanCallback` returns. */
227
239
  export const TanAnswer = Object.freeze({
228
240
  /** The TAN the user entered; an empty one cancels a TAN challenge (`TanAnswer`). */
229
- tan: (tan: string): TanAnswer => Object.freeze({ kind: 'tan', tan }),
241
+ tan: (tan: string): TanAnswer => frozenAnswer({ kind: 'tan', tan }),
230
242
  /** The user reports the approval in the banking app (`TanChallenge.isDecoupled`); the client then asks the bank. */
231
243
  approved: approvedAnswer,
232
244
  /** The user cancels: the client ends the bank dialog, and the call fails with `FinTsTanCancelledError`. */
@@ -252,10 +264,12 @@ export const TanAnswer = Object.freeze({
252
264
  */
253
265
  export type TanCallback = (challenge: TanChallenge) => TanAnswer | Promise<TanAnswer>;
254
266
 
255
- /** Picks one of several allowed TAN methods; the first is used when this returns `undefined`. */
267
+ /** Picks one of several allowed TAN methods; the first is used when this returns `undefined` or `null`. */
256
268
  export type TanMethodSelector = (
257
269
  methods: readonly TanMethod[],
258
- ) => TanMethod | undefined | Promise<TanMethod | undefined>;
270
+ ) => TanMethod | null | undefined | Promise<TanMethod | null | undefined>;
259
271
 
260
- /** Picks one of several active TAN media; the first is used when this returns `undefined`. */
261
- export type TanMediumSelector = (media: readonly TanMedium[]) => TanMedium | undefined | Promise<TanMedium | undefined>;
272
+ /** Picks one of several active TAN media; the first is used when this returns `undefined` or `null`. */
273
+ export type TanMediumSelector = (
274
+ media: readonly TanMedium[],
275
+ ) => TanMedium | null | undefined | Promise<TanMedium | null | undefined>;
@@ -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,17 +11,29 @@ 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
  /**
@@ -60,15 +73,16 @@ export class FlickerCode {
60
73
  ) {}
61
74
 
62
75
  /**
63
- * Parses the challenge data sent by the bank; throws `FormatError` if it is not a flicker code a TAN generator can
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
64
78
  * take: one with characters after the length it states, an empty data element, data other than printable ASCII, or
65
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.
66
80
  */
67
81
  static parse(code: string): FlickerCode {
68
- const cleaned = extractCode(code);
82
+ const embedded = splitCombined(code)?.code ?? trimBlanks(code);
69
83
  for (const layout of layouts) {
70
84
  try {
71
- return FlickerCode.parseLayout(new Reader(cleaned), layout.lcDigits, layout.lengthDigits);
85
+ return FlickerCode.parseLayout(new Reader(embedded), layout.lcDigits, layout.lengthDigits);
72
86
  } catch (error) {
73
87
  // Not this layout; try the next one.
74
88
  if (!(error instanceof FormatError)) throw error;
@@ -96,14 +110,15 @@ export class FlickerCode {
96
110
  const controlBytes: number[] = [];
97
111
  if ((startLength & 0x80) !== 0) {
98
112
  do {
113
+ if (controlBytes.length === 10) throw new FormatError('Too many control bytes in flicker code');
99
114
  controlBytes.push(challenge.hex(2));
100
- } while ((controlBytes.at(-1)! & 0x80) !== 0 && controlBytes.length < 10);
115
+ } while ((controlBytes.at(-1)! & 0x80) !== 0);
101
116
  }
102
- const startCode = new StartCode(challenge.take(startLength & 0x3f), controlBytes);
117
+ const startCode = new StartCode(challenge.take(elementLength(startLength & 0x7f)), controlBytes);
103
118
  const dataElements: DataElement[] = [];
104
119
  for (let i = 0; i < 3; i++) {
105
120
  dataElements.push(
106
- new DataElement(challenge.isAtEnd ? undefined : challenge.take(challenge.dataLength(lengthDigits))),
121
+ new DataElement(challenge.isAtEnd ? undefined : challenge.take(elementLength(challenge.decimal(lengthDigits)))),
107
122
  );
108
123
  }
109
124
  // Only a layout whose fields fill exactly the LC characters, which end the code, can be the right one.
@@ -183,15 +198,21 @@ export class FlickerCode {
183
198
  }
184
199
  return result;
185
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
+ }
186
206
  }
187
207
 
188
- /** The code of a combined challenge, or `code` itself without whitespace. */
189
- function extractCode(code: string): string {
190
- const compact = withoutWhitespace(code);
191
- const start = compact.indexOf(codeMarker);
192
- const codeStart = start + codeMarker.length + markerLengthDigits;
193
- const end = compact.indexOf(textMarker);
194
- 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;
195
216
  }
196
217
 
197
218
  function xorChecksum(payload: string): string {
@@ -238,16 +259,6 @@ class Reader {
238
259
  return parseInt(Reader.match(/^[0-9A-Fa-f]+$/, this.take(digits)), 16);
239
260
  }
240
261
 
241
- /**
242
- * A data element length field (LDE) of `digits` decimal digits: 1 to 63 characters. The challenge leaves out an
243
- * element without data, and sets none of the flags in bits 6 and 7, which only the rendered code carries.
244
- */
245
- dataLength(digits: number): number {
246
- const length = this.decimal(digits);
247
- if (length === 0 || length > 0x3f) throw new FormatError('Invalid data element length');
248
- return length;
249
- }
250
-
251
262
  private static match(pattern: RegExp, field: string): string {
252
263
  if (!pattern.test(field)) throw new FormatError('Invalid length field', field);
253
264
  return field;
@@ -1,4 +1,6 @@
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
5
  /** The formats of photoTAN and QR-TAN images: raster images, which unlike SVG cannot carry scripts. */
4
6
  const imageMimeTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp']);
@@ -16,7 +18,7 @@ export class TanImage {
16
18
  * Reads the bank's image container: a two-byte length and the MIME type, then a two-byte length and the image.
17
19
  *
18
20
  * `undefined` unless the MIME type, in any case, is that of a PNG, JPEG, GIF, WebP or BMP image, and the container
19
- * holds the whole image.
21
+ * holds the whole image, of at least one byte.
20
22
  */
21
23
  static tryParse(data: Uint8Array): TanImage | undefined {
22
24
  if (data.length < 4) return undefined;
@@ -27,7 +29,7 @@ export class TanImage {
27
29
  const offset = 2 + mimeLength;
28
30
  const imageLength = (data[offset]! << 8) | data[offset + 1]!;
29
31
  const start = offset + 2;
30
- if (start + imageLength > data.length) return undefined;
32
+ if (imageLength === 0 || start + imageLength > data.length) return undefined;
31
33
  return new TanImage(mime, data.subarray(start, start + imageLength));
32
34
  }
33
35
 
@@ -35,4 +37,9 @@ export class TanImage {
35
37
  toDataUrl(): string {
36
38
  return `data:${this.mimeType};base64,${base64Encode(this.bytes)}`;
37
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
+ }
38
45
  }
@@ -1,4 +1,5 @@
1
1
  import { copyDate } from '../util/dates.js';
2
+ import { inspectCustom } from '../util/inspect.js';
2
3
  import { maskedId } from '../util/text.js';
3
4
 
4
5
  /** Whether a TAN method needs the name of a TAN medium. */
@@ -138,6 +139,11 @@ export class TanMethod {
138
139
  toString(): string {
139
140
  return `TanMethod(${this.securityFunction}, ${this.name})`;
140
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
+ }
141
147
  }
142
148
 
143
149
  /** State of a TAN medium as reported by the bank. */
@@ -244,9 +250,17 @@ export class TanMedium {
244
250
  return this.status === 'active' || this.status === 'activeFollowUpCard';
245
251
  }
246
252
 
247
- /** 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
+ */
248
257
  toString(): string {
249
- const label = this.name ?? (this.cardNumber === undefined ? this.mediumClass : maskedId(this.cardNumber));
258
+ const label = this.cardNumber === undefined ? this.mediumClass : maskedId(this.cardNumber);
250
259
  return `TanMedium(${label}, ${this.status})`;
251
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
+ }
252
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,12 +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
  }
package/src/util/bytes.ts CHANGED
@@ -56,14 +56,14 @@ export function fromCharCodes(codes: Uint8Array | Uint16Array): string {
56
56
  }
57
57
 
58
58
  const utf8Encoder = new TextEncoder();
59
- const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
60
- const lenientUtf8Decoder = new TextDecoder('utf-8', { ignoreBOM: true });
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
69
  return (allowMalformed ? lenientUtf8Decoder : utf8Decoder).decode(bytes);
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,8 +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
- const isoDateTime = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?(Z|[+-]\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];
35
39
 
36
40
  /** Parses a FinTS 3.0 date `YYYYMMDD` as a calendar date; `undefined` if it is missing or invalid. */
37
41
  export function parseFintsDate(date: string | undefined): Date | undefined {
@@ -43,52 +47,64 @@ export function parseFintsDate(date: string | undefined): Date | undefined {
43
47
  * invalid.
44
48
  */
45
49
  export function parseFintsDateTime(date: string | undefined, time: string | undefined): Date | undefined {
46
- return moment(parseDate(fintsDate, date), fintsTime, time);
50
+ const clock = clockOn(parseDate(fintsDate, date), match(fintsTime, time));
51
+ return clock && germanTime(...clock);
47
52
  }
48
53
 
49
54
  /**
50
- * Parses an ISO 8601 date `YYYY-MM-DD` as a calendar date, ignoring extra characters such as a time part; `undefined`
51
- * 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.
52
58
  */
53
59
  export function parseIsoDate(date: string | undefined): Date | undefined {
54
- return parseDate(isoDate, date);
60
+ if (date?.charAt(10) !== 'T') return parseDate(isoDate, date);
61
+ const moment = parseIsoMoment(date);
62
+ return moment && germanDay(moment);
55
63
  }
56
64
 
57
65
  /**
58
66
  * Parses an ISO 8601 date and time as XML Schema writes it, `YYYY-MM-DDThh:mm:ss` with optional fractional seconds and
59
- * UTC offset (`Z` or `±hh:mm`), as the moment it names, to the second: in German time where it states no offset.
60
- * `undefined` if it is missing or invalid.
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.
61
69
  */
62
- export function parseIsoMoment(dateTime: string | undefined): Date | undefined {
63
- const m = dateTime === undefined ? null : isoDateTime.exec(dateTime);
64
- if (!m) return undefined;
65
- const [, date, time, offset] = m;
66
- const german = moment(parseIsoDate(date), isoTime, time);
67
- if (german === undefined || offset === undefined) return german;
68
- // Every engine reads a valid date and time with an offset alike (ECMAScript Date Time String Format), but some roll
69
- // an invalid date, such as 30 February, over into the next month: `german` has checked them.
70
- const exact = new Date(`${date}T${time}${offset}`);
71
- return Number.isNaN(exact.getTime()) ? undefined : exact;
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;
83
+ }
84
+
85
+ function match(pattern: RegExp, text: string | undefined): RegExpExecArray | null {
86
+ return text === undefined ? null : pattern.exec(text);
72
87
  }
73
88
 
74
89
  function parseDate(pattern: RegExp, date: string | undefined): Date | undefined {
75
- const d = date === undefined ? null : pattern.exec(date);
90
+ const d = match(pattern, date);
76
91
  return d ? validDate(Number(d[1]), Number(d[2]), Number(d[3])) : undefined;
77
92
  }
78
93
 
79
- /** Hour, minute and second of the time matched by `pattern` in `time`; `undefined` if it is missing or invalid. */
80
- function parseTime(pattern: RegExp, time: string | undefined): [number, number, number] | undefined {
81
- const t = time === undefined ? null : pattern.exec(time);
82
- if (!t) return undefined;
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 {
99
+ if (day === undefined || !t) return undefined;
83
100
  const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3])];
84
- return hour > 23 || minute > 59 || second > 59 ? undefined : [hour, minute, second];
85
- }
86
-
87
- /** The moment of the German time matched by `timePattern` in `time` on `day`. */
88
- function moment(day: Date | undefined, timePattern: RegExp, time: string | undefined): Date | undefined {
89
- const t = parseTime(timePattern, time);
90
- if (day === undefined || t === undefined) return undefined;
91
- return germanTime(day.getFullYear(), day.getMonth() + 1, day.getDate(), ...t);
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
+ }
106
+ if (hour > 23 || minute > 59 || second > 59) return undefined;
107
+ return [day.getFullYear(), day.getMonth() + 1, day.getDate(), hour, minute, second];
92
108
  }
93
109
 
94
110
  /**
@@ -97,10 +113,8 @@ function moment(day: Date | undefined, timePattern: RegExp, time: string | undef
97
113
  * fewer than six digits lost its leading zeros as a JSON number.
98
114
  */
99
115
  export function parseUtcPlusOneDateTime(date: string | undefined, time: string | undefined): Date | undefined {
100
- const day = parseDate(fintsDate, date);
101
- const t = parseTime(fintsTime, time?.padStart(6, '0'));
102
- if (day === undefined || t === undefined) return undefined;
103
- return new Date(utc(day.getFullYear(), day.getMonth() + 1, day.getDate(), ...t) - hourMs);
116
+ const clock = clockOn(parseDate(fintsDate, date), match(fintsTime, time?.padStart(6, '0')));
117
+ return clock && new Date(utc(...clock) - hourMs);
104
118
  }
105
119
 
106
120
  /**
@@ -117,7 +131,7 @@ function germanTime(year: number, month: number, day: number, hour: number, minu
117
131
  }
118
132
 
119
133
  /** The calendar day a German clock shows at `moment`, as a local `Date` at midnight like every calendar date. */
120
- export function germanDay(moment: Date): Date {
134
+ function germanDay(moment: Date): Date {
121
135
  const utcMs = moment.getTime();
122
136
  const clock = new Date(utcMs + (isSummerTime(utcMs) ? 2 : 1) * hourMs);
123
137
  return localDate(clock.getUTCFullYear(), clock.getUTCMonth() + 1, clock.getUTCDate());
@@ -2,17 +2,22 @@
2
2
  * Data that do not have the expected format, such as a malformed MT940 line or amount.
3
3
  *
4
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.
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.
6
7
  */
7
8
  export class FormatError extends Error {
8
9
  override name = 'FormatError';
9
10
 
10
- /** The part of the data that is malformed, where the parser can tell it. */
11
- readonly source: string | undefined;
11
+ readonly #source: string | undefined;
12
12
 
13
13
  constructor(message: string, source?: string) {
14
14
  super(message);
15
- this.source = source;
15
+ this.#source = source;
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;
16
21
  }
17
22
  }
18
23