@coderoftime/fints 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/CHANGELOG.md +142 -1
  2. package/README.md +127 -37
  3. package/dist/client.d.ts +48 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +199 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/core/account.d.ts +35 -7
  8. package/dist/core/account.d.ts.map +1 -1
  9. package/dist/core/account.js +84 -13
  10. package/dist/core/account.js.map +1 -1
  11. package/dist/core/amount.d.ts +2 -1
  12. package/dist/core/amount.d.ts.map +1 -1
  13. package/dist/core/amount.js +4 -1
  14. package/dist/core/amount.js.map +1 -1
  15. package/dist/core/balance.d.ts +1 -0
  16. package/dist/core/balance.d.ts.map +1 -1
  17. package/dist/core/balance.js +6 -5
  18. package/dist/core/balance.js.map +1 -1
  19. package/dist/core/bank-info.d.ts +1 -1
  20. package/dist/core/bank-info.js +1 -1
  21. package/dist/core/client.d.ts +50 -15
  22. package/dist/core/client.d.ts.map +1 -1
  23. package/dist/core/config.d.ts +28 -16
  24. package/dist/core/config.d.ts.map +1 -1
  25. package/dist/core/config.js +66 -26
  26. package/dist/core/config.js.map +1 -1
  27. package/dist/core/errors.d.ts +85 -23
  28. package/dist/core/errors.d.ts.map +1 -1
  29. package/dist/core/errors.js +112 -47
  30. package/dist/core/errors.js.map +1 -1
  31. package/dist/core/feedback.d.ts +4 -1
  32. package/dist/core/feedback.d.ts.map +1 -1
  33. package/dist/core/feedback.js +4 -1
  34. package/dist/core/feedback.js.map +1 -1
  35. package/dist/core/transaction.d.ts +6 -1
  36. package/dist/core/transaction.d.ts.map +1 -1
  37. package/dist/core/transaction.js +9 -4
  38. package/dist/core/transaction.js.map +1 -1
  39. package/dist/core/transport.d.ts +33 -6
  40. package/dist/core/transport.d.ts.map +1 -1
  41. package/dist/core/transport.js +77 -20
  42. package/dist/core/transport.js.map +1 -1
  43. package/dist/formats/camt.d.ts +24 -4
  44. package/dist/formats/camt.d.ts.map +1 -1
  45. package/dist/formats/camt.js +124 -41
  46. package/dist/formats/camt.js.map +1 -1
  47. package/dist/formats/mt940.d.ts +15 -3
  48. package/dist/formats/mt940.d.ts.map +1 -1
  49. package/dist/formats/mt940.js +161 -82
  50. package/dist/formats/mt940.js.map +1 -1
  51. package/dist/formats/readers.d.ts +6 -2
  52. package/dist/formats/readers.d.ts.map +1 -1
  53. package/dist/formats/readers.js +24 -29
  54. package/dist/formats/readers.js.map +1 -1
  55. package/dist/formats/record-budget.d.ts +7 -0
  56. package/dist/formats/record-budget.d.ts.map +1 -0
  57. package/dist/formats/record-budget.js +17 -0
  58. package/dist/formats/record-budget.js.map +1 -0
  59. package/dist/formats/sepa-purpose.d.ts +6 -0
  60. package/dist/formats/sepa-purpose.d.ts.map +1 -1
  61. package/dist/formats/sepa-purpose.js +23 -2
  62. package/dist/formats/sepa-purpose.js.map +1 -1
  63. package/dist/index.d.ts +3 -3
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +2 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/session/app-approval.d.ts +11 -5
  68. package/dist/session/app-approval.d.ts.map +1 -1
  69. package/dist/session/app-approval.js +23 -12
  70. package/dist/session/app-approval.js.map +1 -1
  71. package/dist/session/dialog-runner.d.ts +7 -10
  72. package/dist/session/dialog-runner.d.ts.map +1 -1
  73. package/dist/session/dialog-runner.js +15 -16
  74. package/dist/session/dialog-runner.js.map +1 -1
  75. package/dist/session/login-guard.d.ts +54 -49
  76. package/dist/session/login-guard.d.ts.map +1 -1
  77. package/dist/session/login-guard.js +156 -84
  78. package/dist/session/login-guard.js.map +1 -1
  79. package/dist/session/sepa-accounts.d.ts +20 -14
  80. package/dist/session/sepa-accounts.d.ts.map +1 -1
  81. package/dist/session/sepa-accounts.js +62 -46
  82. package/dist/session/sepa-accounts.js.map +1 -1
  83. package/dist/session/session-setup.d.ts +33 -25
  84. package/dist/session/session-setup.d.ts.map +1 -1
  85. package/dist/session/session-setup.js +87 -65
  86. package/dist/session/session-setup.js.map +1 -1
  87. package/dist/session/session-state.d.ts +9 -6
  88. package/dist/session/session-state.d.ts.map +1 -1
  89. package/dist/session/session-state.js +52 -17
  90. package/dist/session/session-state.js.map +1 -1
  91. package/dist/session/signed-dialog.d.ts +77 -0
  92. package/dist/session/signed-dialog.d.ts.map +1 -0
  93. package/dist/session/signed-dialog.js +170 -0
  94. package/dist/session/signed-dialog.js.map +1 -0
  95. package/dist/session/tan-selection.d.ts +26 -18
  96. package/dist/session/tan-selection.d.ts.map +1 -1
  97. package/dist/session/tan-selection.js +44 -33
  98. package/dist/session/tan-selection.js.map +1 -1
  99. package/dist/session/user-prompts.d.ts +8 -4
  100. package/dist/session/user-prompts.d.ts.map +1 -1
  101. package/dist/session/user-prompts.js +34 -14
  102. package/dist/session/user-prompts.js.map +1 -1
  103. package/dist/tan/challenge.d.ts +34 -13
  104. package/dist/tan/challenge.d.ts.map +1 -1
  105. package/dist/tan/challenge.js +25 -26
  106. package/dist/tan/challenge.js.map +1 -1
  107. package/dist/tan/flicker.d.ts +7 -1
  108. package/dist/tan/flicker.d.ts.map +1 -1
  109. package/dist/tan/flicker.js +40 -6
  110. package/dist/tan/flicker.js.map +1 -1
  111. package/dist/tan/tan-image.d.ts +3 -2
  112. package/dist/tan/tan-image.d.ts.map +1 -1
  113. package/dist/tan/tan-image.js +7 -5
  114. package/dist/tan/tan-image.js.map +1 -1
  115. package/dist/tan/tan-models.d.ts +5 -1
  116. package/dist/tan/tan-models.d.ts.map +1 -1
  117. package/dist/tan/tan-models.js +10 -5
  118. package/dist/tan/tan-models.js.map +1 -1
  119. package/dist/util/async.d.ts +0 -2
  120. package/dist/util/async.d.ts.map +1 -1
  121. package/dist/util/async.js +0 -4
  122. package/dist/util/async.js.map +1 -1
  123. package/dist/util/bytes.d.ts +2 -3
  124. package/dist/util/bytes.d.ts.map +1 -1
  125. package/dist/util/bytes.js +12 -20
  126. package/dist/util/bytes.js.map +1 -1
  127. package/dist/util/call-context.d.ts.map +1 -1
  128. package/dist/util/call-context.js +3 -2
  129. package/dist/util/call-context.js.map +1 -1
  130. package/dist/util/dates.d.ts +9 -16
  131. package/dist/util/dates.d.ts.map +1 -1
  132. package/dist/util/dates.js +39 -19
  133. package/dist/util/dates.js.map +1 -1
  134. package/dist/util/errors.d.ts +9 -1
  135. package/dist/util/errors.d.ts.map +1 -1
  136. package/dist/util/errors.js +12 -3
  137. package/dist/util/errors.js.map +1 -1
  138. package/dist/util/json.d.ts +4 -0
  139. package/dist/util/json.d.ts.map +1 -1
  140. package/dist/util/json.js +18 -0
  141. package/dist/util/json.js.map +1 -1
  142. package/dist/util/text.d.ts +21 -4
  143. package/dist/util/text.d.ts.map +1 -1
  144. package/dist/util/text.js +42 -13
  145. package/dist/util/text.js.map +1 -1
  146. package/dist/util/xml.d.ts +4 -3
  147. package/dist/util/xml.d.ts.map +1 -1
  148. package/dist/util/xml.js +37 -26
  149. package/dist/util/xml.js.map +1 -1
  150. package/dist/v3/account-extension.d.ts.map +1 -1
  151. package/dist/v3/account-extension.js +7 -10
  152. package/dist/v3/account-extension.js.map +1 -1
  153. package/dist/v3/bank-connection.d.ts +34 -0
  154. package/dist/v3/bank-connection.d.ts.map +1 -0
  155. package/dist/v3/bank-connection.js +58 -0
  156. package/dist/v3/bank-connection.js.map +1 -0
  157. package/dist/v3/dialog.d.ts +80 -46
  158. package/dist/v3/dialog.d.ts.map +1 -1
  159. package/dist/v3/dialog.js +162 -59
  160. package/dist/v3/dialog.js.map +1 -1
  161. package/dist/v3/message.d.ts +17 -4
  162. package/dist/v3/message.d.ts.map +1 -1
  163. package/dist/v3/message.js +62 -18
  164. package/dist/v3/message.js.map +1 -1
  165. package/dist/v3/parameter-store.d.ts +75 -0
  166. package/dist/v3/parameter-store.d.ts.map +1 -0
  167. package/dist/v3/parameter-store.js +143 -0
  168. package/dist/v3/parameter-store.js.map +1 -0
  169. package/dist/v3/parameters.d.ts +38 -12
  170. package/dist/v3/parameters.d.ts.map +1 -1
  171. package/dist/v3/parameters.js +91 -37
  172. package/dist/v3/parameters.js.map +1 -1
  173. package/dist/v3/protocol.d.ts +43 -50
  174. package/dist/v3/protocol.d.ts.map +1 -1
  175. package/dist/v3/protocol.js +58 -208
  176. package/dist/v3/protocol.js.map +1 -1
  177. package/dist/v3/queries.d.ts +51 -0
  178. package/dist/v3/queries.d.ts.map +1 -0
  179. package/dist/v3/queries.js +120 -0
  180. package/dist/v3/queries.js.map +1 -0
  181. package/dist/v3/segments.d.ts +29 -12
  182. package/dist/v3/segments.d.ts.map +1 -1
  183. package/dist/v3/segments.js +65 -23
  184. package/dist/v3/segments.js.map +1 -1
  185. package/dist/v3/syntax.d.ts +65 -14
  186. package/dist/v3/syntax.d.ts.map +1 -1
  187. package/dist/v3/syntax.js +167 -101
  188. package/dist/v3/syntax.js.map +1 -1
  189. package/package.json +10 -6
  190. package/src/client.ts +252 -0
  191. package/src/core/account.ts +86 -13
  192. package/src/core/amount.ts +3 -1
  193. package/src/core/balance.ts +6 -5
  194. package/src/core/bank-info.ts +1 -1
  195. package/src/core/client.ts +52 -15
  196. package/src/core/config.ts +69 -28
  197. package/src/core/errors.ts +122 -48
  198. package/src/core/feedback.ts +4 -1
  199. package/src/core/transaction.ts +9 -4
  200. package/src/core/transport.ts +98 -23
  201. package/src/formats/camt.ts +136 -39
  202. package/src/formats/mt940.ts +167 -81
  203. package/src/formats/readers.ts +24 -31
  204. package/src/formats/record-budget.ts +18 -0
  205. package/src/formats/sepa-purpose.ts +21 -2
  206. package/src/index.ts +4 -2
  207. package/src/session/app-approval.ts +51 -38
  208. package/src/session/dialog-runner.ts +31 -28
  209. package/src/session/login-guard.ts +174 -88
  210. package/src/session/sepa-accounts.ts +63 -45
  211. package/src/session/session-setup.ts +90 -71
  212. package/src/session/session-state.ts +56 -22
  213. package/src/session/signed-dialog.ts +192 -0
  214. package/src/session/tan-selection.ts +50 -39
  215. package/src/session/user-prompts.ts +35 -16
  216. package/src/tan/challenge.ts +40 -31
  217. package/src/tan/flicker.ts +44 -6
  218. package/src/tan/tan-image.ts +7 -5
  219. package/src/tan/tan-models.ts +10 -5
  220. package/src/util/async.ts +0 -5
  221. package/src/util/bytes.ts +13 -19
  222. package/src/util/call-context.ts +3 -2
  223. package/src/util/dates.ts +41 -24
  224. package/src/util/errors.ts +14 -3
  225. package/src/util/json.ts +16 -0
  226. package/src/util/text.ts +42 -12
  227. package/src/util/xml.ts +37 -26
  228. package/src/v3/account-extension.ts +7 -12
  229. package/src/v3/bank-connection.ts +67 -0
  230. package/src/v3/dialog.ts +175 -91
  231. package/src/v3/message.ts +69 -17
  232. package/src/v3/parameter-store.ts +160 -0
  233. package/src/v3/parameters.ts +110 -37
  234. package/src/v3/protocol.ts +70 -253
  235. package/src/v3/queries.ts +190 -0
  236. package/src/v3/segments.ts +65 -22
  237. package/src/v3/syntax.ts +153 -91
  238. package/dist/session/bank-connection.d.ts +0 -28
  239. package/dist/session/bank-connection.d.ts.map +0 -1
  240. package/dist/session/bank-connection.js +0 -48
  241. package/dist/session/bank-connection.js.map +0 -1
  242. package/dist/session/parameter-data.d.ts +0 -83
  243. package/dist/session/parameter-data.d.ts.map +0 -1
  244. package/dist/session/parameter-data.js +0 -68
  245. package/dist/session/parameter-data.js.map +0 -1
  246. package/dist/session/pin-tan-client.d.ts +0 -54
  247. package/dist/session/pin-tan-client.d.ts.map +0 -1
  248. package/dist/session/pin-tan-client.js +0 -153
  249. package/dist/session/pin-tan-client.js.map +0 -1
  250. package/dist/session/pin-tan-dialog.d.ts +0 -71
  251. package/dist/session/pin-tan-dialog.d.ts.map +0 -1
  252. package/dist/session/pin-tan-dialog.js +0 -124
  253. package/dist/session/pin-tan-dialog.js.map +0 -1
  254. package/dist/session/protocol.d.ts +0 -209
  255. package/dist/session/protocol.d.ts.map +0 -1
  256. package/dist/session/protocol.js +0 -75
  257. package/dist/session/protocol.js.map +0 -1
  258. package/dist/session/response-handler.d.ts +0 -48
  259. package/dist/session/response-handler.d.ts.map +0 -1
  260. package/dist/session/response-handler.js +0 -121
  261. package/dist/session/response-handler.js.map +0 -1
  262. package/dist/v3/client.d.ts +0 -24
  263. package/dist/v3/client.d.ts.map +0 -1
  264. package/dist/v3/client.js +0 -16
  265. package/dist/v3/client.js.map +0 -1
  266. package/src/session/bank-connection.ts +0 -51
  267. package/src/session/parameter-data.ts +0 -137
  268. package/src/session/pin-tan-client.ts +0 -186
  269. package/src/session/pin-tan-dialog.ts +0 -160
  270. package/src/session/protocol.ts +0 -311
  271. package/src/session/response-handler.ts +0 -130
  272. package/src/v3/client.ts +0 -29
package/src/util/xml.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { FormatError } from './errors.js';
2
+ import { characterReference, nonBlank } from './text.js';
2
3
 
3
4
  /**
4
5
  * An element of a parsed XML document, looked up by local name: camt documents come with and without namespace
@@ -29,7 +30,7 @@ export class XmlElement {
29
30
 
30
31
  /** The first child element called `localName`. */
31
32
  child(localName: string): XmlElement | undefined {
32
- return this.childElements.find((c) => c.localName === localName);
33
+ return this.children.find((c): c is XmlElement => c instanceof XmlElement && c.localName === localName);
33
34
  }
34
35
 
35
36
  /** The element reached by following child names along `path`; this element for an empty path. */
@@ -39,21 +40,12 @@ export class XmlElement {
39
40
 
40
41
  /** The trimmed text of the element at `path`, or `undefined` if it is missing or empty. */
41
42
  textAt(path: readonly string[]): string | undefined {
42
- const text = this.at(path)?.innerText.trim();
43
- return text === undefined || text === '' ? undefined : text;
43
+ return nonBlank(this.at(path)?.innerText);
44
44
  }
45
45
 
46
- /** All elements below this one called `localName`, in document order. */
47
- descendantsNamed(localName: string): XmlElement[] {
48
- const found: XmlElement[] = [];
49
- const visit = (element: XmlElement): void => {
50
- for (const c of element.childElements) {
51
- if (c.localName === localName) found.push(c);
52
- visit(c);
53
- }
54
- };
55
- visit(this);
56
- return found;
46
+ /** The child elements called `localName`, in document order. */
47
+ childrenNamed(localName: string): XmlElement[] {
48
+ return this.children.filter((c): c is XmlElement => c instanceof XmlElement && c.localName === localName);
57
49
  }
58
50
  }
59
51
 
@@ -65,7 +57,24 @@ function matchAt(pattern: RegExp, text: string, position: number): RegExpExecArr
65
57
  pattern.lastIndex = position;
66
58
  return pattern.exec(text);
67
59
  }
68
- const predefinedEntities: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
60
+ const predefinedEntities = new Map([
61
+ ['amp', '&'],
62
+ ['lt', '<'],
63
+ ['gt', '>'],
64
+ ['quot', '"'],
65
+ ['apos', "'"],
66
+ ]);
67
+
68
+ /** Deepest nesting of elements the parser takes: camt needs fewer than 20 levels, and `innerText` recurses. */
69
+ const maxDepth = 256;
70
+
71
+ /**
72
+ * Most elements and attributes one document may hold. Real camt spends about 20 bytes on each, so the largest answer a
73
+ * bank can send holds fewer than 600,000, while a hostile document stays within about 150 MB of memory.
74
+ */
75
+ const maxNodes = 1_000_000;
76
+
77
+ const noAttributes: ReadonlyMap<string, string> = new Map();
69
78
 
70
79
  function localNameOf(qualified: string): string {
71
80
  const colon = qualified.indexOf(':');
@@ -77,22 +86,19 @@ function decodeEntities(text: string): string {
77
86
  return text.replace(/&([^;&\s]*);?/g, (match, name: string) => {
78
87
  if (!match.endsWith(';')) throw new FormatError('Invalid XML: unterminated entity reference');
79
88
  if (name.startsWith('#')) {
80
- const hex = name.startsWith('#x');
81
- const digits = name.substring(hex ? 2 : 1);
82
- const code = (hex ? /^[0-9a-fA-F]+$/ : /^\d+$/).test(digits) ? parseInt(digits, hex ? 16 : 10) : NaN;
83
- if (!(code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff))) {
84
- throw new FormatError('Invalid XML character reference', match);
85
- }
86
- return String.fromCodePoint(code);
89
+ const character = characterReference(name);
90
+ if (character === undefined) throw new FormatError('Invalid XML character reference', match);
91
+ return character;
87
92
  }
88
- const value = predefinedEntities[name];
93
+ const value = predefinedEntities.get(name);
89
94
  if (value === undefined) throw new FormatError('Invalid XML: unknown entity', match);
90
95
  return value;
91
96
  });
92
97
  }
93
98
 
94
99
  /**
95
- * Parses an XML document and returns its root element; throws `FormatError` if it is not well-formed.
100
+ * Parses an XML document and returns its root element; throws `FormatError` if it is not well-formed, nests elements
101
+ * deeper than 256 levels or holds more than a million elements and attributes.
96
102
  *
97
103
  * A document type declaration is refused: bank data have none, and its entities could expand without limit.
98
104
  */
@@ -100,6 +106,7 @@ export function parseXml(xml: string): XmlElement {
100
106
  let position = 0;
101
107
  const stack: XmlElement[] = [];
102
108
  let root: XmlElement | undefined;
109
+ let nodes = 0;
103
110
 
104
111
  const fail = (reason: string): never => {
105
112
  throw new FormatError(`Invalid XML: ${reason} at position ${position}`);
@@ -142,16 +149,20 @@ export function parseXml(xml: string): XmlElement {
142
149
  const open = stack.pop();
143
150
  if (open === undefined || open.localName !== localNameOf(name)) fail(`unexpected end tag ${name}`);
144
151
  } else {
152
+ if (stack.length >= maxDepth) fail(`elements nested deeper than ${maxDepth} levels`);
153
+ if (++nodes > maxNodes) fail(`more than ${maxNodes} elements and attributes`);
145
154
  position++;
146
155
  const name = matchAt(namePattern, xml, position)?.[0] ?? fail('missing element name');
147
156
  position += name.length;
148
- const attributes = new Map<string, string>();
157
+ let attributes: Map<string, string> | undefined;
149
158
  for (;;) {
150
159
  const attribute = matchAt(attributePattern, xml, position);
151
160
  if (!attribute) break;
152
161
  position += attribute[0].length;
162
+ if (++nodes > maxNodes) fail(`more than ${maxNodes} elements and attributes`);
153
163
  const attributeName = attribute[1]!;
154
164
  if (attributeName !== 'xmlns' && !attributeName.startsWith('xmlns:')) {
165
+ attributes ??= new Map();
155
166
  attributes.set(localNameOf(attributeName), decodeEntities(attribute[2] ?? attribute[3] ?? ''));
156
167
  }
157
168
  }
@@ -159,7 +170,7 @@ export function parseXml(xml: string): XmlElement {
159
170
  const selfClosing = xml.startsWith('/>', position);
160
171
  if (!selfClosing && xml.charAt(position) !== '>') fail(`malformed start tag ${name}`);
161
172
  position += selfClosing ? 2 : 1;
162
- const element = new XmlElement(localNameOf(name), attributes);
173
+ const element = new XmlElement(localNameOf(name), attributes ?? noAttributes);
163
174
  const parent = stack.at(-1);
164
175
  if (parent) parent.children.push(element);
165
176
  else if (root) fail('a second root element');
@@ -4,7 +4,9 @@ import { AccountDataStatus, type Account } from '../core/account.js';
4
4
  import { Amount, withMark } from '../core/amount.js';
5
5
  import { Balance } from '../core/balance.js';
6
6
  import { parseFintsDate, parseUtcPlusOneDateTime } from '../util/dates.js';
7
- import { FormatError } from '../util/errors.js';
7
+ import { fail, FormatError } from '../util/errors.js';
8
+ import { isJsonObject } from '../util/json.js';
9
+ import { nonBlank } from '../util/text.js';
8
10
 
9
11
  /** A booked or pending balance of the extension: credit/debit mark, value, date and time. */
10
12
  interface Saldo {
@@ -47,7 +49,7 @@ export class AccountExtension {
47
49
  static tryParse(json: string): AccountExtension | undefined {
48
50
  try {
49
51
  const decoded: unknown = JSON.parse(json);
50
- return isObject(decoded) ? AccountExtension.read(new Json(decoded)) : undefined;
52
+ return isJsonObject(decoded) ? AccountExtension.read(new Json(decoded)) : undefined;
51
53
  } catch (error) {
52
54
  if (error instanceof FormatError || error instanceof SyntaxError) return undefined;
53
55
  throw error;
@@ -76,6 +78,7 @@ export class AccountExtension {
76
78
  if (b === undefined || !this.balanceComplete) return undefined;
77
79
  return new Balance({
78
80
  account,
81
+ productName: account.productName,
79
82
  booked: b.booked.amount,
80
83
  bookedAsOf: b.booked.date,
81
84
  bookedAsOfTime: b.booked.time,
@@ -129,14 +132,6 @@ function moment(json: Json, defaultTime?: string): Date | undefined {
129
132
  return parseUtcPlusOneDateTime(json.string('Date'), time) ?? fail('Extension date or time is malformed');
130
133
  }
131
134
 
132
- function fail(message: string): never {
133
- throw new FormatError(message);
134
- }
135
-
136
- function isObject(value: unknown): value is Record<string, unknown> {
137
- return typeof value === 'object' && value !== null && !Array.isArray(value);
138
- }
139
-
140
135
  /**
141
136
  * A JSON object of the extension, read by key without regard to case: the specification itself spells some keys
142
137
  * differently (`OverDraftLim` and `OverdraftLim`). Values of the wrong type are a `FormatError`.
@@ -156,7 +151,7 @@ class Json {
156
151
  string(key: string): string | undefined {
157
152
  const value = this.value(key);
158
153
  if (value === undefined || value === null) return undefined;
159
- if (typeof value === 'string') return value.trim() === '' ? undefined : value.trim();
154
+ if (typeof value === 'string') return nonBlank(value);
160
155
  if (typeof value === 'number') return String(value);
161
156
  throw new FormatError(`Extension element "${key}" is not text`);
162
157
  }
@@ -172,7 +167,7 @@ class Json {
172
167
  object(key: string): Json | undefined {
173
168
  const value = this.value(key);
174
169
  if (value === undefined || value === null) return undefined;
175
- if (isObject(value)) return new Json(value);
170
+ if (isJsonObject(value)) return new Json(value);
176
171
  throw new FormatError(`Extension element "${key}" is not an object`);
177
172
  }
178
173
 
@@ -0,0 +1,67 @@
1
+ import type { MessageDirection, MessageLogger } from '../core/client.js';
2
+ import { FinTsProtocolError, FinTsTransportError } from '../core/errors.js';
3
+ import { failureMessage, HttpTransport, type FinTsTransport } from '../core/transport.js';
4
+
5
+ /**
6
+ * Throws `FinTsProtocolError` if a message of `size` bytes exceeds the bank's `limit` in kilobytes (1024 bytes): the
7
+ * specification does not allow to send such a message.
8
+ */
9
+ export function checkMessageSize(size: number, limit: number | undefined): void {
10
+ if (limit !== undefined && limit > 0 && size > limit * 1024) {
11
+ throw new FinTsProtocolError(`A message of ${size} bytes exceeds the bank's maximum message size of ${limit} KB`);
12
+ }
13
+ }
14
+
15
+ /** The bank's FinTS endpoint: posts messages there and reports them to the logger. */
16
+ export class BankConnection {
17
+ private readonly transport: FinTsTransport;
18
+
19
+ /** Where the messages go, as `FinTsConfig` checked it. */
20
+ private readonly href: string;
21
+
22
+ /** Aborts the requests on their way (`abortRequests`). */
23
+ private requests = new AbortController();
24
+
25
+ /** A connection to `url` through `transport`, an `HttpTransport` by default. */
26
+ constructor(
27
+ url: URL,
28
+ transport: FinTsTransport | undefined,
29
+ private readonly logger: MessageLogger | undefined,
30
+ ) {
31
+ this.href = url.href;
32
+ this.transport = transport ?? new HttpTransport();
33
+ }
34
+
35
+ /**
36
+ * Posts `body` to a new copy of the URL, so that a transport that changes the one it is given changes no later
37
+ * request.
38
+ *
39
+ * Reports any failure of the transport as `FinTsTransportError`: a custom transport that calls `fetch` fails with a
40
+ * `TypeError` where the network does, and one that throws another `FinTsError`, such as `FinTsPinError`, would pass
41
+ * for an answer of the bank. Of such an error only the message is kept (`failureMessage`), since it may hold `body`,
42
+ * as the errors of HTTP clients such as axios do.
43
+ */
44
+ async post(body: Uint8Array, contentType: string): Promise<Uint8Array> {
45
+ try {
46
+ return await this.transport.post(new URL(this.href), body, contentType, this.requests.signal);
47
+ } catch (error) {
48
+ if (error instanceof FinTsTransportError) throw error;
49
+ throw new FinTsTransportError(failureMessage(error));
50
+ }
51
+ }
52
+
53
+ /** Reports a message to the logger; `message` is only built when a logger is set. */
54
+ log(direction: MessageDirection, message: () => string): void {
55
+ this.logger?.(direction, message());
56
+ }
57
+
58
+ /** Aborts the requests on their way; those posted afterwards, such as the dialog ends of a closing client, go out. */
59
+ abortRequests(): void {
60
+ this.requests.abort();
61
+ this.requests = new AbortController();
62
+ }
63
+
64
+ close(): void {
65
+ this.transport.close();
66
+ }
67
+ }
package/src/v3/dialog.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  // Internal to the package, and named after the FinTS specification. Documentation is where the meaning is not evident.
2
2
 
3
- import { FinTsProtocolError } from '../core/errors.js';
4
- import { FeedbackCodes, hasCode } from '../core/feedback.js';
5
- import { requireBankParameters } from '../session/parameter-data.js';
6
- import { PinTanDialog } from '../session/pin-tan-dialog.js';
7
- import type { Approval, StartWithOrder } from '../session/protocol.js';
3
+ import { FinTsError, FinTsProtocolError, FinTsTransportError, parseBankData } from '../core/errors.js';
4
+ import { endsDialog, FeedbackCodes, hasCode, hasErrors } from '../core/feedback.js';
5
+ import { challengeText, TanChallenge } from '../tan/challenge.js';
8
6
  import { TanMethod } from '../tan/tan-models.js';
9
7
  import { IncomingMessage3 } from './message.js';
10
- import type { Bpd3 } from './parameters.js';
8
+ import { requireBankParameters } from './parameters.js';
9
+ import type { FinTs3Protocol } from './protocol.js';
11
10
  import { Segments, TanResponse } from './segments.js';
12
11
  import type { SegmentSpec } from './syntax.js';
13
12
 
@@ -20,96 +19,121 @@ const firstScaVersion = 6;
20
19
  /** The first version of HKTAN that supports app approvals, with the status request (process S). */
21
20
  const firstDecoupledVersion = 7;
22
21
 
23
- /** How a message is signed: the security function of the TAN method and the customer system ID. */
24
- export interface Signing3 {
25
- readonly securityFunction: string;
26
- readonly systemId: string;
27
- }
28
-
29
22
  /**
30
- * What a dialog needs from the session: the parameter data received so far, the segments that open a dialog, and the
31
- * way to the bank. The `FinTs3Protocol` provides it, and keeps the login data and the PIN to itself.
23
+ * How long a dialog may stay idle where the bank states no timeout: on the safe side, since a dialog given up too early
24
+ * only costs a new login, while one kept too long fails the next call.
32
25
  */
33
- export interface FinTs3DialogHost {
34
- /** The bank parameter data received so far, if any. */
35
- readonly bankParameters: Bpd3 | undefined;
26
+ const defaultIdleLimitMs = 5 * 60_000;
27
+
28
+ /** The bank's request to approve a message with a TAN or in a banking app; `R` is the bank's answer. */
29
+ export interface Approval<R> {
30
+ /** What the user is asked. */
31
+ readonly challenge: TanChallenge;
36
32
 
37
- /** The customer system ID the bank assigned; `undefined` before the synchronization. */
38
- readonly systemId: string | undefined;
33
+ /** Sends the TAN the user entered. */
34
+ readonly submitTan: (tan: string) => Promise<R>;
39
35
 
40
- /** Identification (HKIDN) and processing preparation (HKVVB), which open every dialog. */
41
- initSegments(systemId: string, anonymous?: boolean): SegmentSpec[];
36
+ /** Asks the bank whether the user has approved in the banking app. */
37
+ readonly checkStatus: () => Promise<R>;
42
38
 
43
39
  /**
44
- * Sends `segments` as message `number` of the dialog `dialogId` and returns the bank's answer, not yet parsed.
45
- *
46
- * With `signing` the message is signed with the PIN and the `tan`, after `LoginGuard.check`; without, it goes out
47
- * unsigned, as in an anonymous dialog.
40
+ * Whether the bank announces a push message for once the user has approved (return code 3957), before which the
41
+ * status must not be asked. The client has no push channel, so the user reports the approval instead.
48
42
  */
49
- post(message: {
50
- dialogId: string;
51
- number: number;
52
- segments: readonly SegmentSpec[];
53
- signing: Signing3 | undefined;
54
- tan: string | undefined;
55
- }): Promise<Uint8Array>;
43
+ readonly waitForPush: boolean;
44
+
45
+ /** The new instructions a status answer gives the user (return code 3956); `undefined` where it gives none. */
46
+ readonly instructionsIn: (response: R) => string | undefined;
56
47
  }
57
48
 
58
- /** One FinTS 3.0 dialog: numbered messages under the dialog ID the bank assigns in its first answer. */
59
- export class FinTs3Dialog extends PinTanDialog<IncomingMessage3, SegmentSpec> {
49
+ /**
50
+ * One FinTS 3.0 dialog: numbered messages under the dialog ID the bank assigns in its first answer, signed with a TAN
51
+ * method and medium, and the approvals the bank asks for. The `FinTs3Protocol` builds the messages and signs them.
52
+ */
53
+ export class FinTs3Dialog {
60
54
  private systemId: string;
61
55
  private id = unassigned;
56
+ private messageNumber = 1;
57
+ private ended = false;
58
+
59
+ /** When the bank last answered without rejecting the whole message, which shows it still knows the dialog. */
60
+ private lastAnswer: number | undefined;
62
61
 
63
62
  private constructor(
64
- private readonly host: FinTs3DialogHost,
65
- method: TanMethod,
66
- tanMedium: string | undefined,
63
+ private readonly protocol: FinTs3Protocol,
64
+ readonly method: TanMethod,
65
+ /** The TAN medium the messages name, where the method takes one. */
66
+ readonly tanMedium: string | undefined,
67
67
  private readonly anonymous: boolean,
68
68
  ) {
69
- super(method, tanMedium);
70
- this.systemId = anonymous ? unassigned : (host.systemId ?? unassigned);
69
+ this.systemId = anonymous ? unassigned : (protocol.parameters.systemId ?? unassigned);
71
70
  }
72
71
 
73
- static signed(host: FinTs3DialogHost, method: TanMethod, tanMedium?: string): FinTs3Dialog {
74
- return new FinTs3Dialog(host, method, tanMedium, false);
72
+ static signed(protocol: FinTs3Protocol, method: TanMethod, tanMedium?: string): FinTs3Dialog {
73
+ return new FinTs3Dialog(protocol, method, tanMedium, false);
75
74
  }
76
75
 
77
76
  /** A dialog without login, which only delivers the bank parameter data. */
78
- static anonymous(host: FinTs3DialogHost): FinTs3Dialog {
79
- return new FinTs3Dialog(host, TanMethod.oneStep, undefined, true);
80
- }
81
-
82
- protected get bankParameters(): Bpd3 | undefined {
83
- return this.host.bankParameters;
77
+ static anonymous(protocol: FinTs3Protocol): FinTs3Dialog {
78
+ return new FinTs3Dialog(protocol, TanMethod.oneStep, undefined, true);
84
79
  }
85
80
 
81
+ /** Whether the bank knows this dialog and it has not ended. */
86
82
  get isActive(): boolean {
87
83
  return this.id !== unassigned && !this.hasEnded;
88
84
  }
89
85
 
86
+ /**
87
+ * Whether the dialog has ended, on either side.
88
+ *
89
+ * The bank ends a dialog idle for longer than its maximum timeout (HIBPA, Formals C.9), or five minutes where it
90
+ * states none. Such a dialog is neither used nor ended again: the bank would refuse any message in it.
91
+ */
92
+ private get hasEnded(): boolean {
93
+ return this.ended || this.idleTooLong;
94
+ }
95
+
96
+ private get idleTooLong(): boolean {
97
+ const last = this.lastAnswer;
98
+ const timeout = this.protocol.parameters.bankParameters?.bankInfo.maxTimeout;
99
+ // A timeout of 0 or below: the bank keeps idle dialogs without limit.
100
+ const limit = timeout === undefined ? defaultIdleLimitMs : timeout <= 0 ? undefined : timeout * 1000;
101
+ return last !== undefined && limit !== undefined && Date.now() - last > limit;
102
+ }
103
+
104
+ /** Whether the messages are signed with a two-step TAN method, so that the bank may ask for approvals. */
105
+ private get isTwoStep(): boolean {
106
+ return !this.method.isOneStep;
107
+ }
108
+
90
109
  /** The version of the HKTAN segments: that of the HITANS segment the bank describes the method in. */
91
110
  private get tanVersion(): number {
92
- return this.bankParameters?.tanVersion(this.method) ?? firstScaVersion;
111
+ return this.protocol.parameters.bankParameters?.tanVersion(this.method) ?? firstScaVersion;
93
112
  }
94
113
 
114
+ /** Opens the dialog; with `synchronize` the bank also assigns a customer system ID. */
95
115
  start(synchronize = false): Promise<IncomingMessage3> {
96
116
  if (synchronize) this.systemId = unassigned;
97
117
  return this.sendSegments([
98
- ...this.host.initSegments(this.systemId),
118
+ ...this.protocol.initSegments(this.systemId),
99
119
  ...(this.isTwoStep ? [this.tanRequest('HKIDN')] : []),
100
120
  ...(synchronize ? [Segments.synchronization()] : []),
101
121
  ]);
102
122
  }
103
123
 
104
124
  /**
105
- * Opens the dialog, asking strong authentication for `order` with a two-step method, then sends `order` in the next
106
- * message.
125
+ * The messages that open the dialog and send `order` while no TAN medium is chosen yet, as for listing the media:
126
+ * `login` opens the dialog, asking strong authentication for `order` with a two-step method, and `order` sends the
127
+ * order in a message of its own.
107
128
  */
108
- startWith(order: SegmentSpec): StartWithOrder<IncomingMessage3> {
129
+ startWith(order: SegmentSpec): {
130
+ readonly login: () => Promise<IncomingMessage3>;
131
+ readonly order: () => Promise<IncomingMessage3>;
132
+ } {
109
133
  return {
110
134
  login: () =>
111
135
  this.sendSegments([
112
- ...this.host.initSegments(this.systemId),
136
+ ...this.protocol.initSegments(this.systemId),
113
137
  ...(this.isTwoStep ? [this.tanRequest(order.type)] : []),
114
138
  ]),
115
139
  order: () => this.sendSegments([order]),
@@ -124,69 +148,129 @@ export class FinTs3Dialog extends PinTanDialog<IncomingMessage3, SegmentSpec> {
124
148
  */
125
149
  requestBankParameters(announceSca = false): Promise<IncomingMessage3> {
126
150
  return this.sendSegments([
127
- ...this.host.initSegments(unassigned, true),
151
+ ...this.protocol.initSegments(unassigned, true),
128
152
  ...(announceSca ? [Segments.tanProcess4(firstScaVersion, 'HKIDN')] : []),
129
153
  ]);
130
154
  }
131
155
 
156
+ /** Sends `order`, asking for strong customer authentication where the bank requires it. */
132
157
  send(order: SegmentSpec): Promise<IncomingMessage3> {
133
- const needsTan = this.isTwoStep && requireBankParameters(this.bankParameters).pinTan.requiresTan(order.type);
158
+ const needsTan =
159
+ this.isTwoStep && requireBankParameters(this.protocol.parameters.bankParameters).pinTan.requiresTan(order.type);
134
160
  return this.sendSegments([order, ...(needsTan ? [this.tanRequest(order.type)] : [])]);
135
161
  }
136
162
 
137
- protected async sendEnd(): Promise<void> {
138
- await this.sendSegments([Segments.dialogEnd(this.id)]);
163
+ /**
164
+ * Ends the dialog at the bank and returns the answer; `undefined` if the dialog is not active or the end failed.
165
+ *
166
+ * Bank and connection errors are ignored, since the bank may have ended the dialog on its side already.
167
+ */
168
+ async end(): Promise<IncomingMessage3 | undefined> {
169
+ if (!this.isActive) return undefined;
170
+ this.ended = true;
171
+ try {
172
+ return await this.sendSegments([Segments.dialogEnd(this.id)]);
173
+ } catch (error) {
174
+ if (!(error instanceof FinTsError)) throw error;
175
+ return undefined;
176
+ }
139
177
  }
140
178
 
141
- private tanRequest(segmentType: string): SegmentSpec {
142
- return Segments.tanProcess4(this.tanVersion, segmentType, this.mediumName);
179
+ /** Forgets the dialog without contacting the bank, for example after the bank rejected the PIN. */
180
+ discard(): void {
181
+ this.ended = true;
143
182
  }
144
183
 
145
184
  /**
146
- * The approval a HITAN segment of process 4 asks for, answered with HKTAN process 2 (TAN) or S (status).
185
+ * The approval `response`, the dialog's latest answer, asks for with return code 0030 (TAN) or 3955 (banking app):
186
+ * HITAN of process 4, answered with HKTAN process 2 (TAN) or S (status); `undefined` if it asks for none, or carries
187
+ * only the dummy challenge of a message the bank exempts from strong authentication.
147
188
  *
148
- * Throws `FinTsProtocolError` for an app approval with a method of HITANS #6: the status request (process S) came with
149
- * version 7, along with app approvals (PIN/TAN B.5.2).
189
+ * The bank has not executed the message then, so a request without a challenge is a `FinTsProtocolError`: read as
190
+ * done, it would report an empty result. So is an app approval with a method of HITANS #6: the status request
191
+ * (process S) came with version 7, along with app approvals (PIN/TAN B.5.2).
150
192
  */
151
- protected approvalIn(response: IncomingMessage3): Approval<IncomingMessage3> | undefined {
193
+ approvalAskedIn(response: IncomingMessage3): Approval<IncomingMessage3> | undefined {
194
+ const feedback = response.feedback;
195
+ const decoupled = hasCode(feedback, FeedbackCodes.decoupledPending);
196
+ if (hasErrors(feedback) || !(hasCode(feedback, FeedbackCodes.tanRequired) || decoupled)) return undefined;
152
197
  const tan = tanResponseOf(response, '4');
153
- if (tan === undefined || tan.isDummy) return undefined;
154
- if (hasCode(response.feedback, FeedbackCodes.decoupledPending) && this.tanVersion < firstDecoupledVersion) {
198
+ if (tan === undefined || tan.isDummy) {
199
+ throw new FinTsProtocolError('The bank asks for an approval, but sends no challenge');
200
+ }
201
+ if (decoupled && this.tanVersion < firstDecoupledVersion) {
155
202
  throw new FinTsProtocolError('The bank asks for an app approval, which needs HKTAN version 7');
156
203
  }
157
204
  const reference = tan.reference!;
158
- return this.approvalOf(response, {
159
- reference,
160
- message: tan.challenge,
161
- hhdUc: tan.hhdUc,
162
- validUntil: tan.validUntil,
163
- tanMediumName: tan.mediumName,
205
+ return {
206
+ challenge: TanChallenge.create({
207
+ method: this.method,
208
+ reference,
209
+ message: tan.challenge,
210
+ hhdUc: tan.hhdUc,
211
+ isDecoupled: decoupled || this.method.decoupled,
212
+ validUntil: tan.validUntil,
213
+ tanMediumName: tan.mediumName ?? this.tanMedium,
214
+ }),
164
215
  submitTan: (value) => this.sendSegments([Segments.tanProcess2(this.tanVersion, reference)], value),
165
216
  checkStatus: () => this.sendSegments([Segments.tanProcessStatus(this.tanVersion, reference)]),
217
+ waitForPush: hasCode(feedback, FeedbackCodes.decoupledWaitForPush),
166
218
  // A status answer that the approval is still pending may carry new instructions (PIN/TAN B.4.2.2.1, step 2b).
167
- instructionsIn: (status) => tanResponseOf(status, 'S')?.challenge,
168
- });
169
- }
170
-
171
- private sendSegments(segments: readonly SegmentSpec[], tan?: string): Promise<IncomingMessage3> {
172
- return this.transmit(
173
- (number) =>
174
- this.host.post({
175
- dialogId: this.id,
176
- number,
177
- segments,
178
- signing: this.anonymous
179
- ? undefined
180
- : { securityFunction: this.method.securityFunction, systemId: this.systemId },
181
- tan,
182
- }),
183
- (answer) => IncomingMessage3.parse(answer),
219
+ instructionsIn: (status) => challengeText(tanResponseOf(status, 'S')?.challenge),
220
+ };
221
+ }
222
+
223
+ private tanRequest(segmentType: string): SegmentSpec {
224
+ const medium = this.method.mediumRequirement === 'notAllowed' ? undefined : this.tanMedium;
225
+ return Segments.tanProcess4(
226
+ this.tanVersion,
227
+ segmentType,
228
+ medium === undefined ? undefined : this.protocol.parameters.tanMediumAsSent(medium),
184
229
  );
185
230
  }
186
231
 
187
- /** The bank assigns the dialog ID in its answer to the first message. */
188
- protected readDialogData(response: IncomingMessage3): void {
189
- if (this.id === unassigned) this.id = response.dialogId ?? unassigned;
232
+ /**
233
+ * Sends `segments`, with `tan` where they complete an approval, as the next message, and reads the bank's answer. The
234
+ * message counts once the bank has answered, whatever it answered.
235
+ *
236
+ * The bank assigns the dialog ID in its answer to the first message, so the answer to another user's first message
237
+ * passes for it (`ParameterStore.reportedUpdVersion`). Any other answer to another message fails with
238
+ * `FinTsProtocolError` before anything of it is kept: a relay or proxy that mixes up answers would hand the client
239
+ * another user's data, or a page of another query.
240
+ *
241
+ * The dialog ends on the client's side, without a dialog end, where it is over for the bank: after a return code that
242
+ * ends it, such as 9800, and after a rejected opening message (Formals C.1.2), or a broken connection (C.6).
243
+ *
244
+ * Only an answer that does not reject the whole message starts the idle time anew (`hasEnded`): a rejection may come
245
+ * from a bank that has ended the dialog without saying so.
246
+ */
247
+ private async sendSegments(segments: readonly SegmentSpec[], tan?: string): Promise<IncomingMessage3> {
248
+ const number = this.messageNumber;
249
+ let answer: Uint8Array;
250
+ try {
251
+ answer = await this.protocol.post({
252
+ dialogId: this.id,
253
+ number,
254
+ segments,
255
+ signing: this.anonymous
256
+ ? undefined
257
+ : { securityFunction: this.method.securityFunction, systemId: this.systemId },
258
+ tan,
259
+ });
260
+ } catch (error) {
261
+ if (error instanceof FinTsTransportError) this.discard();
262
+ throw error;
263
+ }
264
+ this.messageNumber++;
265
+ const response = parseBankData(() => IncomingMessage3.parse(answer));
266
+ const assigned = this.id === unassigned ? undefined : this.id;
267
+ if (!response.answers(number, assigned)) {
268
+ throw new FinTsProtocolError('The answer belongs to another message than the one sent');
269
+ }
270
+ if (assigned === undefined) this.id = response.dialogId ?? unassigned;
271
+ if (!response.messageRejected) this.lastAnswer = Date.now();
272
+ if (endsDialog(response.feedback) || (number === 1 && response.messageRejected)) this.discard();
273
+ return response;
190
274
  }
191
275
  }
192
276