@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.
- package/CHANGELOG.md +377 -0
- package/README.md +31 -23
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +52 -9
- package/dist/client.js.map +1 -1
- package/dist/core/account.d.ts +15 -4
- package/dist/core/account.d.ts.map +1 -1
- package/dist/core/account.js +84 -36
- package/dist/core/account.js.map +1 -1
- package/dist/core/amount.d.ts +13 -3
- package/dist/core/amount.d.ts.map +1 -1
- package/dist/core/amount.js +42 -8
- package/dist/core/amount.js.map +1 -1
- package/dist/core/balance.d.ts +3 -0
- package/dist/core/balance.d.ts.map +1 -1
- package/dist/core/balance.js +5 -0
- package/dist/core/balance.js.map +1 -1
- package/dist/core/bank-info.d.ts +20 -3
- package/dist/core/bank-info.d.ts.map +1 -1
- package/dist/core/bank-info.js +25 -4
- package/dist/core/bank-info.js.map +1 -1
- package/dist/core/client.d.ts +42 -15
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/config.d.ts +15 -7
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +29 -14
- package/dist/core/config.js.map +1 -1
- package/dist/core/errors.d.ts +17 -5
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +25 -7
- package/dist/core/errors.js.map +1 -1
- package/dist/core/feedback.d.ts +7 -0
- package/dist/core/feedback.d.ts.map +1 -1
- package/dist/core/feedback.js +10 -1
- package/dist/core/feedback.js.map +1 -1
- package/dist/core/transaction.d.ts +5 -0
- package/dist/core/transaction.d.ts.map +1 -1
- package/dist/core/transaction.js +12 -0
- package/dist/core/transaction.js.map +1 -1
- package/dist/core/transport.d.ts +22 -7
- package/dist/core/transport.d.ts.map +1 -1
- package/dist/core/transport.js +51 -11
- package/dist/core/transport.js.map +1 -1
- package/dist/formats/camt.d.ts +7 -4
- package/dist/formats/camt.d.ts.map +1 -1
- package/dist/formats/camt.js +53 -54
- package/dist/formats/camt.js.map +1 -1
- package/dist/formats/mt940.d.ts +18 -6
- package/dist/formats/mt940.d.ts.map +1 -1
- package/dist/formats/mt940.js +83 -66
- package/dist/formats/mt940.js.map +1 -1
- package/dist/formats/readers.d.ts.map +1 -1
- package/dist/formats/readers.js +6 -2
- package/dist/formats/readers.js.map +1 -1
- package/dist/formats/sepa-purpose.d.ts +7 -4
- package/dist/formats/sepa-purpose.d.ts.map +1 -1
- package/dist/formats/sepa-purpose.js +18 -9
- package/dist/formats/sepa-purpose.js.map +1 -1
- package/dist/session/dialog-runner.d.ts +5 -2
- package/dist/session/dialog-runner.d.ts.map +1 -1
- package/dist/session/dialog-runner.js +23 -7
- package/dist/session/dialog-runner.js.map +1 -1
- package/dist/session/login-guard.d.ts +4 -4
- package/dist/session/login-guard.d.ts.map +1 -1
- package/dist/session/login-guard.js +29 -14
- package/dist/session/login-guard.js.map +1 -1
- package/dist/session/session-setup.d.ts +14 -5
- package/dist/session/session-setup.d.ts.map +1 -1
- package/dist/session/session-setup.js +30 -9
- package/dist/session/session-setup.js.map +1 -1
- package/dist/session/session-state.d.ts +4 -3
- package/dist/session/session-state.d.ts.map +1 -1
- package/dist/session/session-state.js +28 -9
- package/dist/session/session-state.js.map +1 -1
- package/dist/session/signed-dialog.d.ts +19 -13
- package/dist/session/signed-dialog.d.ts.map +1 -1
- package/dist/session/signed-dialog.js +45 -30
- package/dist/session/signed-dialog.js.map +1 -1
- package/dist/session/user-prompts.d.ts +6 -1
- package/dist/session/user-prompts.d.ts.map +1 -1
- package/dist/session/user-prompts.js +19 -4
- package/dist/session/user-prompts.js.map +1 -1
- package/dist/tan/challenge.d.ts +15 -13
- package/dist/tan/challenge.d.ts.map +1 -1
- package/dist/tan/challenge.js +32 -26
- package/dist/tan/challenge.js.map +1 -1
- package/dist/tan/flicker.d.ts +5 -1
- package/dist/tan/flicker.d.ts.map +1 -1
- package/dist/tan/flicker.js +42 -30
- package/dist/tan/flicker.js.map +1 -1
- package/dist/tan/tan-image.d.ts +4 -1
- package/dist/tan/tan-image.d.ts.map +1 -1
- package/dist/tan/tan-image.js +8 -2
- package/dist/tan/tan-image.js.map +1 -1
- package/dist/tan/tan-models.d.ts +9 -1
- package/dist/tan/tan-models.d.ts.map +1 -1
- package/dist/tan/tan-models.js +14 -2
- package/dist/tan/tan-models.js.map +1 -1
- package/dist/util/arguments.d.ts +13 -0
- package/dist/util/arguments.d.ts.map +1 -0
- package/dist/util/arguments.js +40 -0
- package/dist/util/arguments.js.map +1 -0
- package/dist/util/async.d.ts +4 -1
- package/dist/util/async.d.ts.map +1 -1
- package/dist/util/async.js +6 -3
- package/dist/util/async.js.map +1 -1
- package/dist/util/bytes.d.ts +1 -1
- package/dist/util/bytes.d.ts.map +1 -1
- package/dist/util/bytes.js +3 -3
- package/dist/util/bytes.js.map +1 -1
- package/dist/util/dates.d.ts +3 -10
- package/dist/util/dates.d.ts.map +1 -1
- package/dist/util/dates.js +49 -39
- package/dist/util/dates.js.map +1 -1
- package/dist/util/errors.d.ts +5 -3
- package/dist/util/errors.d.ts.map +1 -1
- package/dist/util/errors.js +8 -4
- package/dist/util/errors.js.map +1 -1
- package/dist/util/immutable-map.d.ts +5 -2
- package/dist/util/immutable-map.d.ts.map +1 -1
- package/dist/util/immutable-map.js +15 -12
- package/dist/util/immutable-map.js.map +1 -1
- package/dist/util/inspect.d.ts +11 -0
- package/dist/util/inspect.d.ts.map +1 -0
- package/dist/util/inspect.js +14 -0
- package/dist/util/inspect.js.map +1 -0
- package/dist/util/text.d.ts +18 -2
- package/dist/util/text.d.ts.map +1 -1
- package/dist/util/text.js +50 -5
- package/dist/util/text.js.map +1 -1
- package/dist/util/xml.d.ts +6 -4
- package/dist/util/xml.d.ts.map +1 -1
- package/dist/util/xml.js +106 -44
- package/dist/util/xml.js.map +1 -1
- package/dist/v3/bank-connection.d.ts +23 -3
- package/dist/v3/bank-connection.d.ts.map +1 -1
- package/dist/v3/bank-connection.js +66 -6
- package/dist/v3/bank-connection.js.map +1 -1
- package/dist/v3/dialog.d.ts +6 -2
- package/dist/v3/dialog.d.ts.map +1 -1
- package/dist/v3/dialog.js +20 -9
- package/dist/v3/dialog.js.map +1 -1
- package/dist/v3/message.d.ts +18 -10
- package/dist/v3/message.d.ts.map +1 -1
- package/dist/v3/message.js +92 -75
- package/dist/v3/message.js.map +1 -1
- package/dist/v3/parameter-store.d.ts +43 -7
- package/dist/v3/parameter-store.d.ts.map +1 -1
- package/dist/v3/parameter-store.js +69 -15
- package/dist/v3/parameter-store.js.map +1 -1
- package/dist/v3/parameters.d.ts +12 -11
- package/dist/v3/parameters.d.ts.map +1 -1
- package/dist/v3/parameters.js +57 -35
- package/dist/v3/parameters.js.map +1 -1
- package/dist/v3/protocol.d.ts +16 -9
- package/dist/v3/protocol.d.ts.map +1 -1
- package/dist/v3/protocol.js +57 -26
- package/dist/v3/protocol.js.map +1 -1
- package/dist/v3/queries.d.ts +2 -2
- package/dist/v3/queries.d.ts.map +1 -1
- package/dist/v3/queries.js +24 -16
- package/dist/v3/queries.js.map +1 -1
- package/dist/v3/segments.d.ts +18 -11
- package/dist/v3/segments.d.ts.map +1 -1
- package/dist/v3/segments.js +30 -21
- package/dist/v3/segments.js.map +1 -1
- package/dist/v3/syntax.d.ts +15 -11
- package/dist/v3/syntax.d.ts.map +1 -1
- package/dist/v3/syntax.js +30 -15
- package/dist/v3/syntax.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +58 -17
- package/src/core/account.ts +91 -35
- package/src/core/amount.ts +42 -9
- package/src/core/balance.ts +6 -0
- package/src/core/bank-info.ts +28 -4
- package/src/core/client.ts +42 -15
- package/src/core/config.ts +37 -18
- package/src/core/errors.ts +27 -7
- package/src/core/feedback.ts +12 -1
- package/src/core/transaction.ts +13 -0
- package/src/core/transport.ts +62 -16
- package/src/formats/camt.ts +62 -51
- package/src/formats/mt940.ts +114 -78
- package/src/formats/readers.ts +6 -2
- package/src/formats/sepa-purpose.ts +17 -9
- package/src/session/dialog-runner.ts +34 -7
- package/src/session/login-guard.ts +29 -13
- package/src/session/session-setup.ts +27 -9
- package/src/session/session-state.ts +25 -8
- package/src/session/signed-dialog.ts +60 -30
- package/src/session/user-prompts.ts +28 -3
- package/src/tan/challenge.ts +50 -36
- package/src/tan/flicker.ts +40 -29
- package/src/tan/tan-image.ts +9 -2
- package/src/tan/tan-models.ts +16 -2
- package/src/util/arguments.ts +40 -0
- package/src/util/async.ts +10 -4
- package/src/util/bytes.ts +3 -3
- package/src/util/dates.ts +52 -38
- package/src/util/errors.ts +9 -4
- package/src/util/immutable-map.ts +15 -12
- package/src/util/inspect.ts +14 -0
- package/src/util/text.ts +47 -5
- package/src/util/xml.ts +105 -38
- package/src/v3/bank-connection.ts +65 -6
- package/src/v3/dialog.ts +18 -8
- package/src/v3/message.ts +81 -72
- package/src/v3/parameter-store.ts +82 -16
- package/src/v3/parameters.ts +68 -41
- package/src/v3/protocol.ts +61 -28
- package/src/v3/queries.ts +26 -18
- package/src/v3/segments.ts +32 -20
- package/src/v3/syntax.ts +31 -14
|
@@ -1,47 +1,50 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* A map that cannot be changed, also not at runtime: it has no `set`, `delete` or `clear`, and no other code reaches
|
|
3
|
+
* the map that holds its entries.
|
|
4
|
+
*/
|
|
2
5
|
export class ImmutableMap<K, V> implements ReadonlyMap<K, V> {
|
|
3
|
-
|
|
6
|
+
readonly #map: Map<K, V>;
|
|
4
7
|
|
|
5
8
|
constructor(entries?: Iterable<readonly [K, V]>) {
|
|
6
|
-
this
|
|
9
|
+
this.#map = new Map(entries);
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
get size(): number {
|
|
10
|
-
return this
|
|
13
|
+
return this.#map.size;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
get(key: K): V | undefined {
|
|
14
|
-
return this
|
|
17
|
+
return this.#map.get(key);
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
has(key: K): boolean {
|
|
18
|
-
return this
|
|
21
|
+
return this.#map.has(key);
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
forEach(callback: (value: V, key: K, map: ReadonlyMap<K, V>) => void): void {
|
|
22
|
-
this
|
|
25
|
+
this.#map.forEach((value, key) => {
|
|
23
26
|
callback(value, key, this);
|
|
24
27
|
});
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
entries(): MapIterator<[K, V]> {
|
|
28
|
-
return this
|
|
31
|
+
return this.#map.entries();
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
keys(): MapIterator<K> {
|
|
32
|
-
return this
|
|
35
|
+
return this.#map.keys();
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
values(): MapIterator<V> {
|
|
36
|
-
return this
|
|
39
|
+
return this.#map.values();
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
[Symbol.iterator](): MapIterator<[K, V]> {
|
|
40
|
-
return this
|
|
43
|
+
return this.#map.entries();
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/** A plain object of the entries, for JSON and for display. */
|
|
44
47
|
toJSON(): Record<string, V> {
|
|
45
|
-
return Object.fromEntries([...this
|
|
48
|
+
return Object.fromEntries([...this.#map].map(([key, value]) => [String(key), value]));
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key of the method by which Node.js's `util.inspect`, and so `console.log`, shows an object, as do Deno and Bun:
|
|
3
|
+
* its registered name, so that no Node.js import is needed.
|
|
4
|
+
*/
|
|
5
|
+
export const inspectCustom = Symbol.for('nodejs.util.inspect.custom');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `value` frozen, with `show` as what `util.inspect` shows of it instead of its fields. The method is no enumerable
|
|
9
|
+
* property, so that `JSON.stringify`, `structuredClone` and comparisons in tests see the same plain object as before.
|
|
10
|
+
*/
|
|
11
|
+
export function frozenWithInspect<T extends object>(value: T, show: (value: T) => string): Readonly<T> {
|
|
12
|
+
Object.defineProperty(value, inspectCustom, { value: () => show(value) });
|
|
13
|
+
return Object.freeze(value);
|
|
14
|
+
}
|
package/src/util/text.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { FormatError } from './errors.js';
|
|
|
3
3
|
|
|
4
4
|
const xmlEncodingDeclaration = /encoding\s*=\s*["']([A-Za-z0-9._-]+)["']/;
|
|
5
5
|
const whitespace = /\s/g;
|
|
6
|
+
const whiteSpaceCharacter = /\s/;
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Decodes text from the bank, which may be UTF-8 (with or without BOM) or ISO 8859-1; ASCII reads the same in both.
|
|
@@ -71,15 +72,39 @@ export function maskedId(id: string): string {
|
|
|
71
72
|
return id.length <= 4 ? '…' : `…${id.substring(id.length - 4)}`;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** `count` and `noun`, in the plural unless `count` is 1, such as `3 transactions`. */
|
|
76
|
+
export function counted(count: number, noun: string): string {
|
|
77
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
74
80
|
/** Whether `value` fits into ISO 8859-1, the FinTS 3.0 character set. */
|
|
75
81
|
export function isLatin1(value: string): boolean {
|
|
76
82
|
for (let i = 0; i < value.length; i++) if (value.charCodeAt(i) > 0xff) return false;
|
|
77
83
|
return true;
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Whether the UTF-16 code unit `code` is a blank: white space to `String.prototype.trim`, or U+0085 (NEL), which it
|
|
88
|
+
* keeps although Unicode counts it as white space. ISO 8859-1 bank data hold it as the byte 0x85.
|
|
89
|
+
*/
|
|
90
|
+
function isBlank(code: number): boolean {
|
|
91
|
+
// Below U+1680, the first white space beyond these, without a regular expression, which takes many times as long.
|
|
92
|
+
if (code < 0x1680) return code === 0x20 || (code >= 0x09 && code <= 0x0d) || code === 0x85 || code === 0xa0;
|
|
93
|
+
return whiteSpaceCharacter.test(String.fromCharCode(code));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `text` without blanks (`isBlank`) at its `start` and its `end`. */
|
|
97
|
+
export function trimBlanks(text: string, { start = true, end = true } = {}): string {
|
|
98
|
+
let first = 0;
|
|
99
|
+
let last = text.length;
|
|
100
|
+
if (start) while (first < last && isBlank(text.charCodeAt(first))) first++;
|
|
101
|
+
if (end) while (last > first && isBlank(text.charCodeAt(last - 1))) last--;
|
|
102
|
+
return text.substring(first, last);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `text` without blanks around it (`trimBlanks`); `undefined` if that leaves nothing. */
|
|
81
106
|
export function nonBlank(text: string | undefined): string | undefined {
|
|
82
|
-
const trimmed = text
|
|
107
|
+
const trimmed = text === undefined ? undefined : trimBlanks(text);
|
|
83
108
|
return trimmed === '' ? undefined : trimmed;
|
|
84
109
|
}
|
|
85
110
|
|
|
@@ -94,6 +119,16 @@ export function characterReference(name: string): string | undefined {
|
|
|
94
119
|
return code > 0 && (code < 0xd800 || code > 0xdfff) && code <= 0x10ffff ? String.fromCodePoint(code) : undefined;
|
|
95
120
|
}
|
|
96
121
|
|
|
122
|
+
/**
|
|
123
|
+
* `text` with its full-width characters, which East Asian input methods type, as their ASCII counterparts: U+FF01 to
|
|
124
|
+
* U+FF5E as U+0021 to U+007E, and the ideographic space U+3000 as a blank.
|
|
125
|
+
*/
|
|
126
|
+
export function withAsciiForms(text: string): string {
|
|
127
|
+
return text
|
|
128
|
+
.replace(/[\uff01-\uff5e]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0))
|
|
129
|
+
.replaceAll('\u3000', ' ');
|
|
130
|
+
}
|
|
131
|
+
|
|
97
132
|
/** `text` without spaces and line breaks. */
|
|
98
133
|
export function withoutWhitespace(text: string): string {
|
|
99
134
|
return text.replace(whitespace, '');
|
|
@@ -104,9 +139,16 @@ export function decodeBase64Lines(text: string): Uint8Array {
|
|
|
104
139
|
return base64Decode(withoutWhitespace(text));
|
|
105
140
|
}
|
|
106
141
|
|
|
107
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* The integer `text` states, decimal digits with an optional sign and blanks around, `-0` as 0; `undefined` for
|
|
144
|
+
* anything else, such as hex, and for a value beyond `Number.MAX_SAFE_INTEGER`, which a number holds only
|
|
145
|
+
* approximately.
|
|
146
|
+
*/
|
|
108
147
|
export function tryParseInt(text: string | undefined): number | undefined {
|
|
109
148
|
if (text === undefined) return undefined;
|
|
110
|
-
const
|
|
111
|
-
|
|
149
|
+
const integer = trimBlanks(text);
|
|
150
|
+
if (!/^[+-]?\d+$/.test(integer)) return undefined;
|
|
151
|
+
const value = Number(integer);
|
|
152
|
+
if (!Number.isSafeInteger(value)) return undefined;
|
|
153
|
+
return value === 0 ? 0 : value;
|
|
112
154
|
}
|
package/src/util/xml.ts
CHANGED
|
@@ -11,7 +11,7 @@ export class XmlElement {
|
|
|
11
11
|
constructor(
|
|
12
12
|
/** The name without namespace prefix. */
|
|
13
13
|
readonly localName: string,
|
|
14
|
-
/** The attributes by name
|
|
14
|
+
/** The attributes by name, namespace prefix and all. */
|
|
15
15
|
readonly attributes: ReadonlyMap<string, string>,
|
|
16
16
|
) {}
|
|
17
17
|
|
|
@@ -24,8 +24,9 @@ export class XmlElement {
|
|
|
24
24
|
return this.children.map((c) => (c instanceof XmlElement ? c.innerText : c)).join('');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** The attribute called `name`: an unprefixed name finds no prefixed attribute, which is of another namespace. */
|
|
28
|
+
getAttribute(name: string): string | undefined {
|
|
29
|
+
return this.attributes.get(name);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/** The first child element called `localName`. */
|
|
@@ -49,8 +50,29 @@ export class XmlElement {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
const
|
|
53
|
+
/** The characters an XML name starts with (XML 1.0, production 4). */
|
|
54
|
+
const nameStartCharacters = String.raw`:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}`;
|
|
55
|
+
/**
|
|
56
|
+
* An XML name (XML 1.0, production 5). The combining marks lead the class of the characters after the first: after
|
|
57
|
+
* another one, ESLint's `no-misleading-character-class` takes them for a combined character.
|
|
58
|
+
*/
|
|
59
|
+
const xmlName = String.raw`[${nameStartCharacters}][\u0300-\u036F${nameStartCharacters}\-.0-9\u00B7\u203F-\u2040]*`;
|
|
60
|
+
const namePattern = new RegExp(xmlName, 'uy');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* White space to Unicode, U+0085 (NEL) among them, which separates the parts of a tag, and `<?xml` of the rest of the
|
|
64
|
+
* XML declaration, as the Dart package has it.
|
|
65
|
+
*/
|
|
66
|
+
const blank = String.raw`[\s\u0085]`;
|
|
67
|
+
const blankPattern = new RegExp(blank);
|
|
68
|
+
const attributePattern = new RegExp(
|
|
69
|
+
String.raw`${blank}+(${xmlName})${blank}*=${blank}*(?:"([^"<]*)"|'([^'<]*)')`,
|
|
70
|
+
'uy',
|
|
71
|
+
);
|
|
72
|
+
/** The start of the XML declaration, whose content is not read. */
|
|
73
|
+
const declarationPattern = new RegExp(String.raw`<\?xml(?:${blank}|\?>)`, 'y');
|
|
74
|
+
/** The text of a reference after its `&`, up to the `;` that ends it. */
|
|
75
|
+
const referenceNamePattern = /[^;&\s]*/y;
|
|
54
76
|
|
|
55
77
|
/** The match of the sticky `pattern` at `position` in `text`. */
|
|
56
78
|
function matchAt(pattern: RegExp, text: string, position: number): RegExpExecArray | null {
|
|
@@ -66,51 +88,75 @@ const predefinedEntities = new Map([
|
|
|
66
88
|
]);
|
|
67
89
|
|
|
68
90
|
/** Deepest nesting of elements the parser takes: camt needs fewer than 20 levels, and `innerText` recurses. */
|
|
69
|
-
const maxDepth =
|
|
91
|
+
const maxDepth = 100;
|
|
70
92
|
|
|
71
93
|
/**
|
|
72
|
-
* Most
|
|
73
|
-
*
|
|
94
|
+
* Most nodes besides text a document may hold, unless it is long enough for one node per `charactersPerNode`
|
|
95
|
+
* characters: real camt spends 16 to 40 characters on each, while nodes as short as `<a/>` take a hundred times their
|
|
96
|
+
* length in memory.
|
|
74
97
|
*/
|
|
75
|
-
const maxNodes =
|
|
98
|
+
const maxNodes = 500_000;
|
|
99
|
+
const charactersPerNode = 16;
|
|
76
100
|
|
|
77
101
|
const noAttributes: ReadonlyMap<string, string> = new Map();
|
|
78
102
|
|
|
103
|
+
/** The name without namespace prefix; `:a` has none, but is no unprefixed `a` either. */
|
|
79
104
|
function localNameOf(qualified: string): string {
|
|
80
105
|
const colon = qualified.indexOf(':');
|
|
81
|
-
return colon
|
|
106
|
+
return colon > 0 ? qualified.substring(colon + 1) : qualified;
|
|
82
107
|
}
|
|
83
108
|
|
|
84
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Resolves the character and predefined entity references in `text`; throws `FormatError` for any other, at the first.
|
|
111
|
+
* A global `replace` would find all of them before it looked at the first: a gigabyte for megabytes of `&`.
|
|
112
|
+
*/
|
|
85
113
|
function decodeEntities(text: string): string {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
114
|
+
let decoded = '';
|
|
115
|
+
let position = 0;
|
|
116
|
+
for (let ampersand = text.indexOf('&'); ampersand >= 0; ampersand = text.indexOf('&', position)) {
|
|
117
|
+
const name = matchAt(referenceNamePattern, text, ampersand + 1)![0];
|
|
118
|
+
const end = ampersand + 1 + name.length;
|
|
119
|
+
if (text.charAt(end) !== ';') throw new FormatError('Invalid XML: unterminated entity reference');
|
|
120
|
+
decoded += text.substring(position, ampersand) + resolved(name, text.substring(ampersand, end + 1));
|
|
121
|
+
position = end + 1;
|
|
122
|
+
}
|
|
123
|
+
return position === 0 ? text : decoded + text.substring(position);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The text the `reference` called `name` stands for. */
|
|
127
|
+
function resolved(name: string, reference: string): string {
|
|
128
|
+
if (name.startsWith('#')) {
|
|
129
|
+
const character = characterReference(name);
|
|
130
|
+
if (character === undefined) throw new FormatError('Invalid XML character reference', reference);
|
|
131
|
+
return character;
|
|
132
|
+
}
|
|
133
|
+
const value = predefinedEntities.get(name);
|
|
134
|
+
if (value === undefined) throw new FormatError('Invalid XML: unknown entity', reference);
|
|
135
|
+
return value;
|
|
97
136
|
}
|
|
98
137
|
|
|
99
138
|
/**
|
|
100
139
|
* Parses an XML document and returns its root element; throws `FormatError` if it is not well-formed, nests elements
|
|
101
|
-
* deeper than
|
|
140
|
+
* deeper than 100 levels, or holds more than 500,000 nodes besides text (elements, attributes, comments, processing
|
|
141
|
+
* instructions, the XML declaration and CDATA sections) or one per 16 characters, whichever is more.
|
|
102
142
|
*
|
|
103
143
|
* A document type declaration is refused: bank data have none, and its entities could expand without limit.
|
|
104
144
|
*/
|
|
105
145
|
export function parseXml(xml: string): XmlElement {
|
|
106
146
|
let position = 0;
|
|
107
147
|
const stack: XmlElement[] = [];
|
|
148
|
+
const openNames: string[] = [];
|
|
108
149
|
let root: XmlElement | undefined;
|
|
150
|
+
let declared = false;
|
|
109
151
|
let nodes = 0;
|
|
152
|
+
const nodeLimit = Math.max(maxNodes, Math.floor(xml.length / charactersPerNode));
|
|
110
153
|
|
|
111
154
|
const fail = (reason: string): never => {
|
|
112
155
|
throw new FormatError(`Invalid XML: ${reason} at position ${position}`);
|
|
113
156
|
};
|
|
157
|
+
const countNode = (): void => {
|
|
158
|
+
if (++nodes > nodeLimit) fail(`more than ${nodeLimit} nodes`);
|
|
159
|
+
};
|
|
114
160
|
const skipPast = (terminator: string, what: string): void => {
|
|
115
161
|
const end = xml.indexOf(terminator, position);
|
|
116
162
|
if (end < 0) fail(`unterminated ${what}`);
|
|
@@ -122,18 +168,37 @@ export function parseXml(xml: string): XmlElement {
|
|
|
122
168
|
const textEnd = lt < 0 ? xml.length : lt;
|
|
123
169
|
if (textEnd > position) {
|
|
124
170
|
const text = xml.substring(position, textEnd);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
171
|
+
// Blanks are dropped only where they follow no text, as between elements: kept, they would double the nodes of a
|
|
172
|
+
// document with line breaks. Between two texts they are content, and a counted node separates the two.
|
|
173
|
+
if (nonBlank(text) !== undefined || typeof stack.at(-1)?.children.at(-1) === 'string') {
|
|
174
|
+
(stack.at(-1) ?? fail('text outside the root element')).children.push(decodeEntities(text));
|
|
175
|
+
}
|
|
128
176
|
position = textEnd;
|
|
129
177
|
continue;
|
|
130
178
|
}
|
|
131
179
|
if (xml.startsWith('<?', position)) {
|
|
180
|
+
countNode();
|
|
181
|
+
if (matchAt(declarationPattern, xml, position)) {
|
|
182
|
+
if (root) fail('XML declaration inside or after the root element');
|
|
183
|
+
if (declared) fail('a second XML declaration');
|
|
184
|
+
declared = true;
|
|
185
|
+
} else {
|
|
186
|
+
position += 2;
|
|
187
|
+
const target = matchAt(namePattern, xml, position)?.[0] ?? fail('missing processing instruction target');
|
|
188
|
+
position += target.length;
|
|
189
|
+
if (!xml.startsWith('?>', position) && !blankPattern.test(xml.charAt(position))) {
|
|
190
|
+
fail('malformed processing instruction');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
132
193
|
skipPast('?>', 'processing instruction');
|
|
133
194
|
} else if (xml.startsWith('<!--', position)) {
|
|
195
|
+
countNode();
|
|
196
|
+
// Past the `<!--`, whose dashes end no comment: `<!-->` starts one.
|
|
197
|
+
position += 4;
|
|
134
198
|
skipPast('-->', 'comment');
|
|
135
199
|
} else if (xml.startsWith('<![CDATA[', position)) {
|
|
136
200
|
const parent = stack.at(-1) ?? fail('CDATA outside the root element');
|
|
201
|
+
countNode();
|
|
137
202
|
const start = position + 9;
|
|
138
203
|
skipPast(']]>', 'CDATA section');
|
|
139
204
|
parent.children.push(xml.substring(start, position - 3));
|
|
@@ -143,14 +208,14 @@ export function parseXml(xml: string): XmlElement {
|
|
|
143
208
|
position += 2;
|
|
144
209
|
const name = matchAt(namePattern, xml, position)?.[0] ?? fail('missing end tag name');
|
|
145
210
|
position += name.length;
|
|
146
|
-
while (
|
|
211
|
+
while (blankPattern.test(xml.charAt(position))) position++;
|
|
147
212
|
if (xml.charAt(position) !== '>') fail('malformed end tag');
|
|
148
213
|
position++;
|
|
149
|
-
|
|
150
|
-
if (
|
|
214
|
+
stack.pop();
|
|
215
|
+
if (openNames.pop() !== name) fail(`unexpected end tag ${name}`);
|
|
151
216
|
} else {
|
|
152
217
|
if (stack.length >= maxDepth) fail(`elements nested deeper than ${maxDepth} levels`);
|
|
153
|
-
|
|
218
|
+
countNode();
|
|
154
219
|
position++;
|
|
155
220
|
const name = matchAt(namePattern, xml, position)?.[0] ?? fail('missing element name');
|
|
156
221
|
position += name.length;
|
|
@@ -159,14 +224,13 @@ export function parseXml(xml: string): XmlElement {
|
|
|
159
224
|
const attribute = matchAt(attributePattern, xml, position);
|
|
160
225
|
if (!attribute) break;
|
|
161
226
|
position += attribute[0].length;
|
|
162
|
-
|
|
227
|
+
countNode();
|
|
163
228
|
const attributeName = attribute[1]!;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
229
|
+
attributes ??= new Map();
|
|
230
|
+
if (attributes.has(attributeName)) fail(`duplicate attribute ${attributeName}`);
|
|
231
|
+
attributes.set(attributeName, decodeEntities(attribute[2] ?? attribute[3] ?? ''));
|
|
168
232
|
}
|
|
169
|
-
while (
|
|
233
|
+
while (blankPattern.test(xml.charAt(position))) position++;
|
|
170
234
|
const selfClosing = xml.startsWith('/>', position);
|
|
171
235
|
if (!selfClosing && xml.charAt(position) !== '>') fail(`malformed start tag ${name}`);
|
|
172
236
|
position += selfClosing ? 2 : 1;
|
|
@@ -175,9 +239,12 @@ export function parseXml(xml: string): XmlElement {
|
|
|
175
239
|
if (parent) parent.children.push(element);
|
|
176
240
|
else if (root) fail('a second root element');
|
|
177
241
|
else root = element;
|
|
178
|
-
if (!selfClosing)
|
|
242
|
+
if (!selfClosing) {
|
|
243
|
+
stack.push(element);
|
|
244
|
+
openNames.push(name);
|
|
245
|
+
}
|
|
179
246
|
}
|
|
180
247
|
}
|
|
181
|
-
if (stack.length > 0) fail(`unclosed element ${
|
|
248
|
+
if (stack.length > 0) fail(`unclosed element ${openNames.at(-1)!}`);
|
|
182
249
|
return root ?? fail('no root element');
|
|
183
250
|
}
|
|
@@ -19,9 +19,18 @@ export class BankConnection {
|
|
|
19
19
|
/** Where the messages go, as `FinTsConfig` checked it. */
|
|
20
20
|
private readonly href: string;
|
|
21
21
|
|
|
22
|
-
/** Aborts the requests
|
|
22
|
+
/** Aborts the requests posted since the last `abortRequestsAfter`. */
|
|
23
23
|
private requests = new AbortController();
|
|
24
24
|
|
|
25
|
+
/** How many requests are on their way. */
|
|
26
|
+
private onTheirWay = 0;
|
|
27
|
+
|
|
28
|
+
/** Starts the time of the requests posted after `abortRequestsAfter`, once none posted before is on its way. */
|
|
29
|
+
private startLaterRequests: (() => void) | undefined;
|
|
30
|
+
|
|
31
|
+
/** The timers of `abortRequestsAfter`, which `close` clears. */
|
|
32
|
+
private readonly deadlines: ReturnType<typeof setTimeout>[] = [];
|
|
33
|
+
|
|
25
34
|
/** A connection to `url` through `transport`, an `HttpTransport` by default. */
|
|
26
35
|
constructor(
|
|
27
36
|
url: URL,
|
|
@@ -40,28 +49,78 @@ export class BankConnection {
|
|
|
40
49
|
* `TypeError` where the network does, and one that throws another `FinTsError`, such as `FinTsPinError`, would pass
|
|
41
50
|
* for an answer of the bank. Of such an error only the message is kept (`failureMessage`), since it may hold `body`,
|
|
42
51
|
* as the errors of HTTP clients such as axios do.
|
|
52
|
+
*
|
|
53
|
+
* Once the time `abortRequestsAfter` gives is up, it fails at once, before anything reaches the transport
|
|
54
|
+
* (`checkSendable`), and a request on its way fails then without waiting for the transport, which may ignore the
|
|
55
|
+
* abort.
|
|
43
56
|
*/
|
|
44
57
|
async post(body: Uint8Array, contentType: string): Promise<Uint8Array> {
|
|
58
|
+
this.checkSendable();
|
|
59
|
+
const signal = this.requests.signal;
|
|
60
|
+
let stopWaiting!: () => void;
|
|
61
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
62
|
+
stopWaiting = () => {
|
|
63
|
+
reject(new FinTsTransportError('Request aborted'));
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
signal.addEventListener('abort', stopWaiting);
|
|
67
|
+
this.onTheirWay++;
|
|
45
68
|
try {
|
|
46
|
-
return await this.transport.post(new URL(this.href), body, contentType,
|
|
69
|
+
return await Promise.race([this.transport.post(new URL(this.href), body, contentType, signal), aborted]);
|
|
47
70
|
} catch (error) {
|
|
48
71
|
if (error instanceof FinTsTransportError) throw error;
|
|
49
72
|
throw new FinTsTransportError(failureMessage(error));
|
|
73
|
+
} finally {
|
|
74
|
+
signal.removeEventListener('abort', stopWaiting);
|
|
75
|
+
this.onTheirWay--;
|
|
76
|
+
if (this.onTheirWay === 0) this.startLaterRequests?.();
|
|
50
77
|
}
|
|
51
78
|
}
|
|
52
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Throws the `FinTsTransportError` that `post` fails with once the time `abortRequestsAfter` gives is up; a caller
|
|
82
|
+
* checks it before it reports a message as outgoing, since such a message never goes out.
|
|
83
|
+
*/
|
|
84
|
+
checkSendable(): void {
|
|
85
|
+
if (this.requests.signal.aborted) throw new FinTsTransportError('Request aborted');
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/** Reports a message to the logger; `message` is only built when a logger is set. */
|
|
54
89
|
log(direction: MessageDirection, message: () => string): void {
|
|
55
90
|
this.logger?.(direction, message());
|
|
56
91
|
}
|
|
57
92
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Gives the requests on their way `graceMs` to finish, and aborts them then. Those posted later, such as the dialog
|
|
95
|
+
* ends of a closing client, have as long again from the moment the earlier ones are over or aborted, and are aborted
|
|
96
|
+
* in turn once that time is up, as is any posted after it: a bank that has stopped answering would hold them up to
|
|
97
|
+
* the transport's timeout.
|
|
98
|
+
*/
|
|
99
|
+
abortRequestsAfter(graceMs: number): void {
|
|
100
|
+
const earlier = this.requests;
|
|
101
|
+
const later = new AbortController();
|
|
102
|
+
this.requests = later;
|
|
103
|
+
const afterGrace = (action: () => void): void => {
|
|
104
|
+
this.deadlines.push(setTimeout(action, graceMs));
|
|
105
|
+
};
|
|
106
|
+
this.startLaterRequests = () => {
|
|
107
|
+
this.startLaterRequests = undefined;
|
|
108
|
+
afterGrace(() => {
|
|
109
|
+
later.abort();
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
if (this.onTheirWay === 0) {
|
|
113
|
+
this.startLaterRequests();
|
|
114
|
+
} else {
|
|
115
|
+
afterGrace(() => {
|
|
116
|
+
earlier.abort();
|
|
117
|
+
this.startLaterRequests?.();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
62
120
|
}
|
|
63
121
|
|
|
64
122
|
close(): void {
|
|
123
|
+
for (const deadline of this.deadlines) clearTimeout(deadline);
|
|
65
124
|
this.transport.close();
|
|
66
125
|
}
|
|
67
126
|
}
|
package/src/v3/dialog.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { TanMethod } from '../tan/tan-models.js';
|
|
|
7
7
|
import { IncomingMessage3 } from './message.js';
|
|
8
8
|
import { requireBankParameters } from './parameters.js';
|
|
9
9
|
import type { FinTs3Protocol } from './protocol.js';
|
|
10
|
-
import { Segments, TanResponse } from './segments.js';
|
|
10
|
+
import { noReference, Segments, TanResponse } from './segments.js';
|
|
11
11
|
import type { SegmentSpec } from './syntax.js';
|
|
12
12
|
|
|
13
13
|
/** Dialog ID of a dialog the bank has not answered yet, and customer system ID before synchronization. */
|
|
@@ -202,6 +202,8 @@ export class FinTs3Dialog {
|
|
|
202
202
|
throw new FinTsProtocolError('The bank asks for an app approval, which needs HKTAN version 7');
|
|
203
203
|
}
|
|
204
204
|
const reference = tan.reference!;
|
|
205
|
+
// The placeholder the dialog that looks up the TAN media sends, and the bank may echo, is no medium.
|
|
206
|
+
const medium = tan.mediumName ?? this.tanMedium;
|
|
205
207
|
return {
|
|
206
208
|
challenge: TanChallenge.create({
|
|
207
209
|
method: this.method,
|
|
@@ -210,7 +212,7 @@ export class FinTs3Dialog {
|
|
|
210
212
|
hhdUc: tan.hhdUc,
|
|
211
213
|
isDecoupled: decoupled || this.method.decoupled,
|
|
212
214
|
validUntil: tan.validUntil,
|
|
213
|
-
tanMediumName:
|
|
215
|
+
tanMediumName: medium === noReference ? undefined : medium,
|
|
214
216
|
}),
|
|
215
217
|
submitTan: (value) => this.sendSegments([Segments.tanProcess2(this.tanVersion, reference)], value),
|
|
216
218
|
checkStatus: () => this.sendSegments([Segments.tanProcessStatus(this.tanVersion, reference)]),
|
|
@@ -225,7 +227,7 @@ export class FinTs3Dialog {
|
|
|
225
227
|
return Segments.tanProcess4(
|
|
226
228
|
this.tanVersion,
|
|
227
229
|
segmentType,
|
|
228
|
-
medium === undefined ? undefined : this.protocol.parameters.
|
|
230
|
+
medium === undefined ? undefined : this.protocol.parameters.tanMediumToSend(medium),
|
|
229
231
|
);
|
|
230
232
|
}
|
|
231
233
|
|
|
@@ -236,10 +238,14 @@ export class FinTs3Dialog {
|
|
|
236
238
|
* The bank assigns the dialog ID in its answer to the first message, so the answer to another user's first message
|
|
237
239
|
* passes for it (`ParameterStore.reportedUpdVersion`). Any other answer to another message fails with
|
|
238
240
|
* `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.
|
|
241
|
+
* another user's data, or a page of another query. Only the bank's dialog abort message may name the dialog or the
|
|
242
|
+
* message as unknown instead (`IncomingMessage3.aborts`), so that its text reaches the user.
|
|
240
243
|
*
|
|
241
244
|
* 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,
|
|
245
|
+
* ends it, such as 9800, a dialog abort message, a rejected opening message (Formals C.1.2), or a broken connection
|
|
246
|
+
* (C.6). A request that fails while the client is closing, as one it aborts (`FinTs3Protocol.beginClose`), leaves the
|
|
247
|
+
* dialog active instead, since the bank may have received it: its end follows under the next number. Not so where
|
|
248
|
+
* the request opened the dialog, whose ID only the answer tells.
|
|
243
249
|
*
|
|
244
250
|
* Only an answer that does not reject the whole message starts the idle time anew (`hasEnded`): a rejection may come
|
|
245
251
|
* from a bank that has ended the dialog without saying so.
|
|
@@ -258,18 +264,22 @@ export class FinTs3Dialog {
|
|
|
258
264
|
tan,
|
|
259
265
|
});
|
|
260
266
|
} catch (error) {
|
|
261
|
-
if (error instanceof FinTsTransportError)
|
|
267
|
+
if (error instanceof FinTsTransportError) {
|
|
268
|
+
if (this.protocol.isClosing && this.id !== unassigned) this.messageNumber++;
|
|
269
|
+
else this.discard();
|
|
270
|
+
}
|
|
262
271
|
throw error;
|
|
263
272
|
}
|
|
264
273
|
this.messageNumber++;
|
|
265
274
|
const response = parseBankData(() => IncomingMessage3.parse(answer));
|
|
266
275
|
const assigned = this.id === unassigned ? undefined : this.id;
|
|
267
|
-
|
|
276
|
+
const aborted = response.aborts(number, assigned);
|
|
277
|
+
if (!aborted && !response.answers(number, assigned)) {
|
|
268
278
|
throw new FinTsProtocolError('The answer belongs to another message than the one sent');
|
|
269
279
|
}
|
|
270
280
|
if (assigned === undefined) this.id = response.dialogId ?? unassigned;
|
|
271
281
|
if (!response.messageRejected) this.lastAnswer = Date.now();
|
|
272
|
-
if (endsDialog(response.feedback) || (number === 1 && response.messageRejected)) this.discard();
|
|
282
|
+
if (aborted || endsDialog(response.feedback) || (number === 1 && response.messageRejected)) this.discard();
|
|
273
283
|
return response;
|
|
274
284
|
}
|
|
275
285
|
}
|