@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
@@ -0,0 +1,192 @@
1
+ import { requireLatin1 } from '../core/config.js';
2
+ import {
3
+ bankErrorFor,
4
+ FinTsLoginDataChangedError,
5
+ FinTsProtocolError,
6
+ FinTsTanCancelledError,
7
+ } from '../core/errors.js';
8
+ import { allowedTanMethodsIn, FeedbackCodes, hasCode, hasErrors, userWarnings } from '../core/feedback.js';
9
+ import type { TanMethod } from '../tan/tan-models.js';
10
+ import { invalidArgument } from '../util/errors.js';
11
+ import type { Approval, FinTs3Dialog } from '../v3/dialog.js';
12
+ import { IncomingMessage3 } from '../v3/message.js';
13
+ import type { ParameterStore } from '../v3/parameter-store.js';
14
+ import type { SegmentSpec } from '../v3/syntax.js';
15
+ import { appApprovalWait } from './app-approval.js';
16
+ import type { LoginGuard, RequestKind } from './login-guard.js';
17
+ import type { SessionState } from './session-state.js';
18
+ import type { UserPrompts } from './user-prompts.js';
19
+
20
+ /** The parts of the session that every `SignedDialog` reports to. */
21
+ export interface SessionParts {
22
+ readonly parameters: ParameterStore;
23
+ readonly state: SessionState;
24
+ readonly prompts: UserPrompts;
25
+ readonly guard: LoginGuard;
26
+ }
27
+
28
+ /**
29
+ * A dialog signed with the PIN, which settles each answer before it hands it out: it keeps the parameter data of the
30
+ * dialog's opening, passes the notices on, has the `LoginGuard` judge the answer and throws its errors, and completes
31
+ * the approval the bank asks for. The session sends every message signed with the PIN through it, so that the guard
32
+ * judges every answer, that to the dialog's end included.
33
+ */
34
+ export class SignedDialog {
35
+ constructor(
36
+ private readonly dialog: FinTs3Dialog,
37
+ private readonly session: SessionParts,
38
+ ) {}
39
+
40
+ /** Whether the bank knows this dialog and it has not ended. */
41
+ get isActive(): boolean {
42
+ return this.dialog.isActive;
43
+ }
44
+
45
+ /** Logs in, which opens the dialog; with `synchronize` the bank also assigns a customer system ID. */
46
+ async login(synchronize = false): Promise<IncomingMessage3> {
47
+ return this.settle(await this.dialog.start(synchronize), 'login');
48
+ }
49
+
50
+ /**
51
+ * Logs in, asking strong authentication for `order`, then sends `order` in a message of its own, as for listing the
52
+ * TAN media while no medium is chosen yet (`FinTs3Dialog.startWith`); returns both answers.
53
+ */
54
+ async loginWith(order: SegmentSpec): Promise<IncomingMessage3[]> {
55
+ const start = this.dialog.startWith(order);
56
+ const login = await this.settle(await start.login(), 'login');
57
+ return [login, await this.settle(await start.order(), 'order')];
58
+ }
59
+
60
+ /** Sends `order`, the first page of a query, and completes the approval the bank asks for. */
61
+ async send(order: SegmentSpec): Promise<IncomingMessage3> {
62
+ return this.settle(await this.dialog.send(order), 'order');
63
+ }
64
+
65
+ /**
66
+ * Sends `order` for a further page of a query (Formals B.6.3), whose answer must not ask for an approval: the client
67
+ * completes that of the first page only, so that a bank that asks anew for every page cannot prompt the user once per
68
+ * page. Throws `FinTsProtocolError` if it asks for one.
69
+ */
70
+ async sendFurtherPage(order: SegmentSpec): Promise<IncomingMessage3> {
71
+ const response = await this.dialog.send(order);
72
+ this.receive(response, 'order', false);
73
+ if (this.dialog.approvalAskedIn(response) !== undefined) {
74
+ throw new FinTsProtocolError('The bank asks for an approval of a further page of the same query');
75
+ }
76
+ return response;
77
+ }
78
+
79
+ /**
80
+ * Ends the dialog at the bank; does nothing if it is not active. Of the answer only a rejected PIN counts, which the
81
+ * guard judges: the dialog is over either way.
82
+ */
83
+ async end(): Promise<void> {
84
+ const response = await this.dialog.end();
85
+ if (response !== undefined && hasErrors(response.feedback)) {
86
+ this.session.guard.judge(bankErrorFor(response.feedback), 'order', this.dialog.method);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Receives `response`, the answer to a request of `kind`: keeps its parameter data where it answers the `opening` of
92
+ * the dialog, the login or its approval, even alongside errors, since the remedy for a refused login may need them;
93
+ * passes its notices on; and has the guard judge it, then throws its errors, as the refusal of the login where they
94
+ * amount to one.
95
+ */
96
+ private receive(response: IncomingMessage3, kind: RequestKind, opening: boolean): void {
97
+ if (opening) this.session.parameters.keep(response);
98
+ this.readNotices(response);
99
+ const error = hasErrors(response.feedback) ? bankErrorFor(response.feedback, kind === 'approval') : undefined;
100
+ const refusal = this.session.guard.judge(error, kind, this.dialog.method);
101
+ // Every further message of a refused login's dialog would carry the refused PIN.
102
+ if (refusal !== undefined) this.dialog.discard();
103
+ if (error !== undefined) throw refusal ?? error;
104
+ }
105
+
106
+ /**
107
+ * Where `response` answers a login, a request of `kind` other than `order`, or its approval with new login data: ends
108
+ * the dialog, stops the client from logging in with the old ones, and fails the call with
109
+ * `FinTsLoginDataChangedError` (Rückmeldungscodes C.6). It runs before any approval, which would cost the user a TAN
110
+ * for a session that ends anyway. New login data next to an error are the login's refusal already
111
+ * (`loginRefusalFor`).
112
+ */
113
+ private async retireOldLoginData(response: IncomingMessage3, kind: RequestKind): Promise<void> {
114
+ if (kind === 'order' || !hasCode(response.feedback, FeedbackCodes.newLoginData)) return;
115
+ await this.end();
116
+ const error = new FinTsLoginDataChangedError(response.feedback);
117
+ throw this.session.guard.judge(error, kind, this.dialog.method) ?? error;
118
+ }
119
+
120
+ /**
121
+ * Receives `response`, the answer to a request of `kind`, then completes the approval it asks for; returns it joined
122
+ * with the response that completes the approval.
123
+ *
124
+ * Throws `FinTsProtocolError` if that response asks for an approval once more: the bank has not executed the message
125
+ * then either, and read as done it would report an empty result.
126
+ */
127
+ private async settle(response: IncomingMessage3, kind: RequestKind): Promise<IncomingMessage3> {
128
+ const opening = kind !== 'order';
129
+ this.receive(response, kind, opening);
130
+ await this.retireOldLoginData(response, kind);
131
+ const approval = this.dialog.approvalAskedIn(response);
132
+ if (approval === undefined) return response;
133
+ const approved = approval.challenge.isDecoupled
134
+ ? await this.awaitAppApproval(approval, opening)
135
+ : await this.enterTan(approval, opening);
136
+ await this.retireOldLoginData(approved, kind);
137
+ if (this.dialog.approvalAskedIn(approved) !== undefined) {
138
+ throw new FinTsProtocolError('The bank asks for another approval in answer to the approval');
139
+ }
140
+ return IncomingMessage3.combine(response, approved);
141
+ }
142
+
143
+ /** Passes on the notices in `response`: the TAN methods the user may use (3920), the bank's messages and warnings. */
144
+ private readNotices(response: IncomingMessage3): void {
145
+ const allowed = allowedTanMethodsIn(response.feedback);
146
+ if (allowed.length > 0) this.session.state.rememberAllowedMethods(allowed);
147
+ for (const message of response.bankMessages) this.session.prompts.showBankMessage(message);
148
+ for (const warning of userWarnings(response.feedback)) this.session.prompts.showWarning(warning);
149
+ }
150
+
151
+ /** Asks the user for the TAN `approval` takes and sends it; `opening` tells whether it approves the login. */
152
+ private async enterTan(approval: Approval<IncomingMessage3>, opening: boolean): Promise<IncomingMessage3> {
153
+ const answer = await this.session.prompts.askTan(approval.challenge);
154
+ if (answer.kind === 'approved') {
155
+ throw invalidArgument('Answered a TAN challenge with an approval in the banking app', 'tanCallback');
156
+ }
157
+ // NFKC, so that digits of another width, such as those of a Japanese input method, go out as the digits they are.
158
+ const tan = answer.kind === 'tan' ? answer.tan.normalize('NFKC').trim() : '';
159
+ if (tan === '') throw new FinTsTanCancelledError();
160
+ const response = await approval.submitTan(requireTanOf(approval.challenge.method, tan));
161
+ this.receive(response, 'approval', opening);
162
+ return response;
163
+ }
164
+
165
+ /** Waits for the user to approve in the banking app; `opening` tells whether the approval is that of the login. */
166
+ private awaitAppApproval(approval: Approval<IncomingMessage3>, opening: boolean): Promise<IncomingMessage3> {
167
+ const prompts = this.session.prompts;
168
+ return appApprovalWait(approval, {
169
+ ask: (challenge) => prompts.askTan(challenge),
170
+ pause: (delayMs, interrupt) => prompts.pause(delayMs, interrupt),
171
+ receive: (response) => {
172
+ this.receive(response, 'approval', opening);
173
+ },
174
+ }).run();
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Throws `TypeError` for a TAN that `method` rules out, which the bank would count as a failed attempt: one longer than
180
+ * `TanMethod.maxTanLength`, or with other characters than digits for a `TanMethod.numericTan` method; and for one that
181
+ * FinTS 3.0 cannot send. The message leaves out the TAN.
182
+ */
183
+ function requireTanOf(method: TanMethod, tan: string): string {
184
+ const max = method.maxTanLength;
185
+ if (max !== undefined && max > 0 && tan.length > max) {
186
+ throw invalidArgument(`The TAN method ${method.name} takes TANs of at most ${max} characters`, 'tan');
187
+ }
188
+ if (method.numericTan && !/^[0-9]+$/.test(tan)) {
189
+ throw invalidArgument(`The TAN method ${method.name} takes digits only`, 'tan');
190
+ }
191
+ return requireLatin1(tan, 'tan');
192
+ }
@@ -1,6 +1,8 @@
1
- import { FinTsUnsupportedError } from '../core/errors.js';
1
+ import { FinTsTanSetupError } from '../core/errors.js';
2
2
  import { TanMethod, type TanMedium } from '../tan/tan-models.js';
3
- import { noReference, type BankResponse, type PinTanProtocol, type ProtocolDialog } from './protocol.js';
3
+ import { FinTs3Dialog } from '../v3/dialog.js';
4
+ import type { FinTs3Protocol } from '../v3/protocol.js';
5
+ import { noReference } from '../v3/segments.js';
4
6
  import type { SessionState } from './session-state.js';
5
7
  import type { UserPrompts } from './user-prompts.js';
6
8
 
@@ -8,18 +10,18 @@ import type { UserPrompts } from './user-prompts.js';
8
10
  * The TAN method and medium the session's messages are signed with, chosen by the user among what the bank offers and
9
11
  * the user may use, and kept in the session state.
10
12
  */
11
- export class TanSelection<R extends BankResponse, O> {
12
- /** TAN methods whose media were looked up in this session. A lookup is not repeated, even where it gave no medium. */
13
- private readonly mediaLookedUp = new Set<string>();
14
-
15
- /** The list the last lookup gave, until `takeLookedUpMedia` hands it out. */
16
- private lookedUpMedia: TanMedium[] | undefined;
13
+ export class TanSelection {
14
+ /**
15
+ * The lookups of this session that found no medium to choose, by TAN method, with the list each gave; `undefined`
16
+ * where the bank gave none. Such a lookup is not repeated for its method until `select` or `forgetMedium` drops it.
17
+ */
18
+ private readonly lookupsWithoutMedium = new Map<string, TanMedium[] | undefined>();
17
19
 
18
20
  /** Whether the bank refused a login signed with the one-step method in this session. */
19
21
  private oneStepRefused = false;
20
22
 
21
23
  constructor(
22
- private readonly protocol: PinTanProtocol<R, O>,
24
+ private readonly protocol: FinTs3Protocol,
23
25
  private readonly state: SessionState,
24
26
  private readonly prompts: UserPrompts,
25
27
  ) {}
@@ -28,31 +30,41 @@ export class TanSelection<R extends BankResponse, O> {
28
30
  get method(): TanMethod {
29
31
  const code = this.state.tanMethod;
30
32
  if (code === undefined || code === TanMethod.oneStep.securityFunction) return TanMethod.oneStep;
31
- return this.protocol.bankParameters?.tanMethods.find((m) => m.securityFunction === code) ?? TanMethod.oneStep;
33
+ const methods = this.protocol.parameters.bankParameters?.tanMethods ?? [];
34
+ return methods.find((m) => m.securityFunction === code) ?? TanMethod.oneStep;
32
35
  }
33
36
 
34
37
  /** Switches to `securityFunction` and `tanMedium` for the dialogs to come. */
35
38
  select(securityFunction: string, tanMedium: string | undefined): void {
36
39
  this.state.useTanMethod(securityFunction, tanMedium);
37
- this.mediaLookedUp.delete(securityFunction);
40
+ this.lookupsWithoutMedium.delete(securityFunction);
41
+ }
42
+
43
+ /**
44
+ * Forgets the medium in use and the lookups of this session, so that the next login looks the media up anew where the
45
+ * method needs one: the bank may have refused the medium, such as that of a phone the user has replaced.
46
+ */
47
+ forgetMedium(): void {
48
+ this.state.useTanMedium(undefined);
49
+ this.lookupsWithoutMedium.clear();
38
50
  }
39
51
 
40
52
  /**
41
53
  * A dialog signed with the TAN method in use and its medium.
42
54
  *
43
- * Throws `FinTsUnsupportedError` if the method needs a medium the user has not chosen: the bank would refuse the
55
+ * Throws `FinTsTanSetupError` if the method needs a medium the user has not chosen: the bank would refuse the
44
56
  * login, and the client would not send the PIN again.
45
57
  */
46
- newDialog(): ProtocolDialog<R, O> {
58
+ newDialog(): FinTs3Dialog {
47
59
  const method = this.method;
48
60
  if (this.lacksMedium(method)) {
49
- throw new FinTsUnsupportedError(
61
+ throw new FinTsTanSetupError(
50
62
  `The TAN method ${method.name} needs a TAN medium, and the client could not learn one: the bank lists no ` +
51
63
  'active medium for it, or does not offer or permit the list of TAN media, or rejected it. Pass a medium to ' +
52
64
  'selectTanMethod, or choose another method',
53
65
  );
54
66
  }
55
- return this.protocol.newDialog(method, this.state.tanMedium);
67
+ return FinTs3Dialog.signed(this.protocol, method, this.state.tanMedium);
56
68
  }
57
69
 
58
70
  /**
@@ -61,9 +73,9 @@ export class TanSelection<R extends BankResponse, O> {
61
73
  * It runs before the medium is known, so it names a placeholder where the method needs one; the bank ignores the
62
74
  * medium in this dialog (PIN/TAN B.4.3.1.3).
63
75
  */
64
- lookupDialog(): ProtocolDialog<R, O> {
76
+ lookupDialog(): FinTs3Dialog {
65
77
  const method = this.method;
66
- return this.protocol.newDialog(method, this.lacksMedium(method) ? noReference : this.state.tanMedium);
78
+ return FinTs3Dialog.signed(this.protocol, method, this.lacksMedium(method) ? noReference : this.state.tanMedium);
67
79
  }
68
80
 
69
81
  /**
@@ -75,15 +87,15 @@ export class TanSelection<R extends BankResponse, O> {
75
87
  * a one-step login;
76
88
  * 3. another two-step method the user may use.
77
89
  *
78
- * Throws `FinTsUnsupportedError` if none does, before the bank could refuse the login.
90
+ * Throws `FinTsTanSetupError` if none does, before the bank could refuse the login.
79
91
  */
80
- synchronizationDialog(): ProtocolDialog<R, O> {
92
+ synchronizationDialog(): FinTs3Dialog {
81
93
  const method = this.method;
82
- if (!this.lacksMedium(method)) return this.protocol.newDialog(method, this.state.tanMedium);
83
- if (!this.oneStepRefused) return this.protocol.newDialog(TanMethod.oneStep);
94
+ if (!this.lacksMedium(method)) return FinTs3Dialog.signed(this.protocol, method, this.state.tanMedium);
95
+ if (!this.oneStepRefused) return FinTs3Dialog.signed(this.protocol, TanMethod.oneStep);
84
96
  const other = this.available().find((m) => !m.isOneStep && !this.lacksMedium(m));
85
- if (other !== undefined) return this.protocol.newDialog(other);
86
- throw new FinTsUnsupportedError(
97
+ if (other !== undefined) return FinTs3Dialog.signed(this.protocol, other);
98
+ throw new FinTsTanSetupError(
87
99
  `The bank refuses the one-step method, and the TAN method ${method.name} needs a TAN medium, which the client ` +
88
100
  'can look up only after the synchronization. Pass the medium to selectTanMethod, or choose another method',
89
101
  );
@@ -98,17 +110,17 @@ export class TanSelection<R extends BankResponse, O> {
98
110
  * The two-step TAN methods the bank offers, the user may use and this client can run; the one-step method if there
99
111
  * are none.
100
112
  *
101
- * Throws `FinTsUnsupportedError` if the bank offers the user nothing but methods the client cannot run.
113
+ * Throws `FinTsTanSetupError` if the bank offers the user nothing but methods the client cannot run.
102
114
  */
103
115
  available(): TanMethod[] {
104
- const bpd = this.protocol.bankParameters;
116
+ const bpd = this.protocol.parameters.bankParameters;
105
117
  if (bpd === undefined) return [TanMethod.oneStep];
106
- const allowed = this.state.allowedMethods ?? this.protocol.userParameters?.allowedTanMethods;
118
+ const allowed = this.state.allowedMethods;
107
119
  const isAllowed = (securityFunction: string): boolean =>
108
120
  allowed === undefined || allowed.length === 0 || allowed.includes(securityFunction);
109
121
  const runnable = bpd.tanMethods.filter((m) => isAllowed(m.securityFunction));
110
122
  if (runnable.length === 0 && bpd.unsupportedTanMethods.some(isAllowed)) {
111
- throw new FinTsUnsupportedError(
123
+ throw new FinTsTanSetupError(
112
124
  'The bank offers only TAN methods this client cannot run: process variant 1, or methods that need an SMS ' +
113
125
  'charge account or an HHD_UC answer',
114
126
  );
@@ -142,11 +154,11 @@ export class TanSelection<R extends BankResponse, O> {
142
154
 
143
155
  /**
144
156
  * Whether the media of the method in use have to be looked up: it needs a medium the user has not chosen, and they
145
- * were not looked up for it in this session.
157
+ * were not looked up in vain for it in this session.
146
158
  */
147
159
  get mediumToLookUp(): boolean {
148
160
  const method = this.method;
149
- return this.lacksMedium(method) && !this.mediaLookedUp.has(method.securityFunction);
161
+ return this.lacksMedium(method) && !this.lookupsWithoutMedium.has(method.securityFunction);
150
162
  }
151
163
 
152
164
  /**
@@ -155,21 +167,20 @@ export class TanSelection<R extends BankResponse, O> {
155
167
  */
156
168
  async chooseMedium(media: TanMedium[] | undefined): Promise<void> {
157
169
  const method = this.method;
158
- this.lookedUpMedia = media;
159
- this.state.useTanMedium(media === undefined ? undefined : await this.chooseMediumOf(method, media));
160
- this.mediaLookedUp.add(method.securityFunction);
170
+ const medium = media === undefined ? undefined : await this.chooseMediumOf(method, media);
171
+ this.state.useTanMedium(medium);
172
+ if (medium === undefined) this.lookupsWithoutMedium.set(method.securityFunction, media);
161
173
  }
162
174
 
163
175
  /**
164
- * The list of the last lookup, once; `undefined` if there was none since the last time.
176
+ * The list of the lookup that found no medium for the method in use; `undefined` where there was none, or the bank
177
+ * gave no list.
165
178
  *
166
- * `getTanMedia` takes it, so that the list is not asked for twice in a row. It also outlasts a call that failed for
167
- * want of an active medium, so that the app can show the user the list that tells why; later calls ask anew.
179
+ * It outlasts the call that failed for want of the medium, so that `getTanMedia` can show the user the list that tells
180
+ * why: the client neither looks the media up again for the method nor can list them in a dialog without a medium.
168
181
  */
169
- takeLookedUpMedia(): TanMedium[] | undefined {
170
- const media = this.lookedUpMedia;
171
- this.lookedUpMedia = undefined;
172
- return media;
182
+ get lookedUpMedia(): TanMedium[] | undefined {
183
+ return this.lookupsWithoutMedium.get(this.method.securityFunction);
173
184
  }
174
185
 
175
186
  /** The name of the medium the user chooses among the active ones in `media`; `undefined` if none is active. */
@@ -1,16 +1,12 @@
1
1
  import type { BankMessage } from '../core/bank-info.js';
2
2
  import type { FinTsClientOptions, MessageLogger } from '../core/client.js';
3
- import { FinTsStateError } from '../core/errors.js';
3
+ import { clientClosedError } from '../core/errors.js';
4
4
  import type { FinTsFeedback } from '../core/feedback.js';
5
- import type { TanAnswer, TanChallenge } from '../tan/challenge.js';
5
+ import { TanAnswer, type TanChallenge } from '../tan/challenge.js';
6
6
  import type { TanMedium, TanMethod } from '../tan/tan-models.js';
7
7
  import { Deferred } from '../util/async.js';
8
8
  import { invalidArgument } from '../util/errors.js';
9
9
 
10
- function closedError(): FinTsStateError {
11
- return new FinTsStateError('The FinTS client is closed');
12
- }
13
-
14
10
  /**
15
11
  * Everything a session tells the app, asks of it or waits for: the bank's messages, warnings and log, the TAN callback,
16
12
  * the selectors and the pauses between status requests.
@@ -24,7 +20,7 @@ export class UserPrompts {
24
20
  /** The first error an informing callback threw during the running call. */
25
21
  private callbackError: { error: unknown } | undefined;
26
22
 
27
- /** Aborts the waits under way; each wait removes its abort when it is over. */
23
+ /** Aborts the waits under way; each wait removes its abort when it is over, or when nothing waits for it any more. */
28
24
  private readonly aborts = new Set<() => void>();
29
25
 
30
26
  constructor(private readonly options: FinTsClientOptions) {}
@@ -38,7 +34,7 @@ export class UserPrompts {
38
34
 
39
35
  /** Throws `FinTsStateError` once `close` was called. */
40
36
  checkOpen(): void {
41
- if (this.closed) throw closedError();
37
+ if (this.closed) throw clientClosedError();
42
38
  }
43
39
 
44
40
  /**
@@ -93,14 +89,17 @@ export class UserPrompts {
93
89
  }
94
90
  }
95
91
 
96
- /** Shows `challenge` through the TAN callback and returns its answer. */
92
+ /**
93
+ * Shows `challenge` through the TAN callback and returns its answer; rejects with `TypeError` for anything but a
94
+ * `TanAnswer`. Once `TanChallenge.resolved` settles, nothing waits for the answer any more.
95
+ */
97
96
  askTan(challenge: TanChallenge): Promise<TanAnswer> {
98
- return this.unlessClosed(() => this.options.tanCallback(challenge));
97
+ return this.unlessClosed(async () => tanAnswerOf(await this.options.tanCallback(challenge)), challenge.resolved);
99
98
  }
100
99
 
101
100
  /** Waits for `delayMs` milliseconds, or until `interrupt` settles. */
102
101
  pause(delayMs: number, interrupt?: Promise<unknown>): Promise<void> {
103
- if (this.closed) return Promise.reject(closedError());
102
+ if (this.closed) return Promise.reject(clientClosedError());
104
103
  let timer: ReturnType<typeof setTimeout> | undefined;
105
104
  const elapsed = new Promise<void>((resolve) => {
106
105
  timer = setTimeout(resolve, Math.max(0, delayMs));
@@ -144,16 +143,20 @@ export class UserPrompts {
144
143
  }
145
144
 
146
145
  /**
147
- * Starts `wait` and ends it with `FinTsStateError` if `close` is called before it is over.
146
+ * Starts `wait` and ends it with `FinTsStateError` if `close` is called before it is over. Once `unwanted` settles,
147
+ * nothing waits for it any more: it is forgotten then, rather than kept until a callback that never answers does.
148
148
  *
149
149
  * Once the client is closed, the returned promise rejects rather than this function throwing: a caller that starts
150
150
  * several waits at once would otherwise leave the failure of those started first unheard.
151
151
  */
152
- private unlessClosed<T>(wait: () => T | Promise<T>): Promise<T> {
153
- if (this.closed) return Promise.reject(closedError());
152
+ private unlessClosed<T>(wait: () => T | Promise<T>, unwanted?: Promise<unknown>): Promise<T> {
153
+ if (this.closed) return Promise.reject(clientClosedError());
154
154
  const result = new Deferred<T>();
155
155
  const abort = (): void => {
156
- result.reject(closedError());
156
+ result.reject(clientClosedError());
157
+ };
158
+ const forget = (): void => {
159
+ this.aborts.delete(abort);
157
160
  };
158
161
  this.aborts.add(abort);
159
162
  // Runs `wait` at once, as far as its first `await`, and turns an error it throws into a rejection.
@@ -167,8 +170,24 @@ export class UserPrompts {
167
170
  result.reject(error);
168
171
  },
169
172
  )
170
- .finally(() => this.aborts.delete(abort))
173
+ .finally(forget)
171
174
  .catch(() => undefined);
175
+ void unwanted?.then(forget, forget);
172
176
  return result.promise;
173
177
  }
174
178
  }
179
+
180
+ /**
181
+ * `answer`, a result of the TAN callback, as a `TanAnswer`; throws `TypeError` for anything else, such as the
182
+ * `undefined` of a callback that returns nothing. The answer is read once, so that what was checked is what counts.
183
+ */
184
+ function tanAnswerOf(answer: unknown): TanAnswer {
185
+ const { kind, tan } = (typeof answer === 'object' && answer !== null ? answer : {}) as {
186
+ kind?: unknown;
187
+ tan?: unknown;
188
+ };
189
+ if (kind === 'tan' && typeof tan === 'string') return TanAnswer.tan(tan);
190
+ if (kind === 'approved') return TanAnswer.approved;
191
+ if (kind === 'cancelled') return TanAnswer.cancelled;
192
+ throw invalidArgument('Returned no TanAnswer', 'tanCallback');
193
+ }
@@ -1,4 +1,6 @@
1
1
  import { latin1Decode } from '../util/bytes.js';
2
+ import { copyDate } from '../util/dates.js';
3
+ import { characterReference } from '../util/text.js';
2
4
  import { challengeInstruction, FlickerCode, hasEmbeddedFlickerCode } from './flicker.js';
3
5
  import { TanImage } from './tan-image.js';
4
6
  import type { TanMedium, TanMethod } from './tan-models.js';
@@ -11,7 +13,6 @@ export function challengeText(message: string | undefined): string | undefined {
11
13
  return message === undefined || message === '' || message === noChallenge ? undefined : message;
12
14
  }
13
15
 
14
- const printableAscii = /^[\x20-\x7E]+$/;
15
16
  const lineBreak = /<br\b[^<>]*>/gi;
16
17
  const blockTag = /<\/?(?:p|li)\b[^<>]*>/gi;
17
18
 
@@ -41,19 +42,9 @@ const namedEntities: Record<string, string> = {
41
42
  euro: '€',
42
43
  };
43
44
 
44
- /**
45
- * The character `name` refers to; `undefined` for an unknown name and for a number that is not a character, as XML
46
- * defines them: `NUL`, half a surrogate pair, or beyond Unicode.
47
- */
45
+ /** The character `name` refers to; `undefined` for an unknown name and for a number that is not a character. */
48
46
  function decodeEntity(name: string): string | undefined {
49
- if (name.startsWith('#')) {
50
- const isHex = name.startsWith('#x') || name.startsWith('#X');
51
- const digits = name.substring(isHex ? 2 : 1);
52
- const code = (isHex ? /^[0-9a-fA-F]+$/ : /^[0-9]+$/).test(digits) ? parseInt(digits, isHex ? 16 : 10) : NaN;
53
- const isCharacter = code > 0 && (code < 0xd800 || code > 0xdfff) && code <= 0x10ffff;
54
- return isCharacter ? String.fromCodePoint(code) : undefined;
55
- }
56
- return Object.hasOwn(namedEntities, name) ? namedEntities[name] : undefined;
47
+ return characterReference(name) ?? (Object.hasOwn(namedEntities, name) ? namedEntities[name] : undefined);
57
48
  }
58
49
 
59
50
  /** What `TanChallenge` takes. */
@@ -76,10 +67,14 @@ export class TanChallenge {
76
67
  /** The TAN method the challenge is for. */
77
68
  readonly method: TanMethod;
78
69
 
79
- /** Bank reference of the pending action. */
70
+ /** Bank reference of the pending action, one character per byte the bank sent (ISO 8859-1). */
80
71
  readonly reference: string;
81
72
 
82
- /** Challenge text as sent by the bank; may contain simple HTML. See `plainMessage`. */
73
+ /**
74
+ * Challenge text as the bank sent it, including the formatting tags of a structured challenge
75
+ * (`TanMethod.structuredChallenge`); `plainMessage` is the text to show. Show either as text only, never as HTML
76
+ * (`innerHTML`, `v-html`): it is the bank's text, not markup to trust.
77
+ */
83
78
  readonly message: string | undefined;
84
79
 
85
80
  /** Raw challenge data for TAN generators (HHD_UC). */
@@ -104,13 +99,16 @@ export class TanChallenge {
104
99
  readonly attempt: number;
105
100
 
106
101
  /**
107
- * Settles when the client needs no more answer to this challenge: the bank confirmed the approval, or the approval
108
- * failed, timed out or was cancelled. Set where the client asks the bank by itself while the callback shows the
109
- * challenge (`TanCallback`), `undefined` otherwise. It never rejects.
102
+ * Settles when the client needs no more answer to this challenge: a later prompt for the approval replaces it, or the
103
+ * bank confirmed the approval, or the approval failed, timed out or was cancelled. Set where the client asks the bank
104
+ * by itself while the callback shows the challenge (`TanCallback`), `undefined` otherwise. It never rejects.
110
105
  */
111
106
  readonly resolved: Promise<void> | undefined;
112
107
 
113
- /** A challenge as the bank sent it. Use `TanChallenge.create` to have a flicker code or image detected in the data. */
108
+ /**
109
+ * A challenge as the bank sent it, with a copy of `validUntil`, which no other challenge shares. Use
110
+ * `TanChallenge.create` to have a flicker code or image detected in the data.
111
+ */
114
112
  constructor(init: TanChallengeInit) {
115
113
  this.method = init.method;
116
114
  this.reference = init.reference;
@@ -119,7 +117,7 @@ export class TanChallenge {
119
117
  this.flickerCode = init.flickerCode;
120
118
  this.image = init.image;
121
119
  this.isDecoupled = init.isDecoupled ?? false;
122
- this.validUntil = init.validUntil;
120
+ this.validUntil = copyDate(init.validUntil);
123
121
  this.tanMediumName = init.tanMediumName;
124
122
  this.attempt = init.attempt ?? 1;
125
123
  this.resolved = init.resolved;
@@ -132,10 +130,7 @@ export class TanChallenge {
132
130
  const data = init.hhdUc !== undefined && init.hhdUc.length > 0 ? init.hhdUc : undefined;
133
131
  if (data !== undefined) {
134
132
  image = TanImage.tryParse(data);
135
- if (image === undefined) {
136
- const text = latin1Decode(data);
137
- if (printableAscii.test(text)) flicker = FlickerCode.tryParse(text);
138
- }
133
+ if (image === undefined) flicker = FlickerCode.tryParse(latin1Decode(data));
139
134
  } else if (init.message !== undefined && hasEmbeddedFlickerCode(init.message)) {
140
135
  flicker = FlickerCode.tryParse(init.message);
141
136
  }
@@ -163,6 +158,10 @@ export class TanChallenge {
163
158
  * defines. Line breaks, paragraphs and list items become new lines. Bold, italics, underlining and list tags are
164
159
  * removed, and the entity references of such markup are resolved. Any other challenge stays as the bank sent it,
165
160
  * which is how the specification wants it shown.
161
+ *
162
+ * Show it as text, such as with `textContent`, or `{{ }}` in Vue, and `white-space: pre-line` for its line breaks;
163
+ * never as HTML (`innerHTML`, `v-html`). It keeps the tags the specification does not define, and `&lt;` becomes
164
+ * `<`, so that as HTML the bank's text could run scripts in the app.
166
165
  */
167
166
  get plainMessage(): string | undefined {
168
167
  const text = this.message;
@@ -211,7 +210,10 @@ function copyChallenge(challenge: TanChallenge, changes: Partial<TanChallengeIni
211
210
 
212
211
  /** The user's answer to a `TanChallenge`: `TanAnswer.tan(…)`, `TanAnswer.approved` or `TanAnswer.cancelled`. */
213
212
  export type TanAnswer =
214
- /** The TAN the user entered; an empty one cancels. */
213
+ /**
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).
216
+ */
215
217
  | { readonly kind: 'tan'; readonly tan: string }
216
218
  /** The user reports the approval in the banking app (`TanChallenge.isDecoupled`); the client then asks the bank. */
217
219
  | { readonly kind: 'approved' }
@@ -223,7 +225,7 @@ const cancelledAnswer: TanAnswer = Object.freeze({ kind: 'cancelled' });
223
225
 
224
226
  /** The answers a `TanCallback` returns. */
225
227
  export const TanAnswer = Object.freeze({
226
- /** The TAN the user entered; an empty one cancels. */
228
+ /** The TAN the user entered; an empty one cancels a TAN challenge (`TanAnswer`). */
227
229
  tan: (tan: string): TanAnswer => Object.freeze({ kind: 'tan', tan }),
228
230
  /** The user reports the approval in the banking app (`TanChallenge.isDecoupled`); the client then asks the bank. */
229
231
  approved: approvedAnswer,
@@ -234,12 +236,19 @@ export const TanAnswer = Object.freeze({
234
236
  /**
235
237
  * Shows the `TanChallenge` to the user and returns the `TanAnswer`: `TanAnswer.tan(…)` for a TAN, `TanAnswer.approved`
236
238
  * once the user reports an approval in the banking app (`TanChallenge.isDecoupled`), `TanAnswer.cancelled` to cancel
237
- * either. An answer that does not fit the challenge fails the call with a `TypeError`.
239
+ * either.
240
+ *
241
+ * The call fails with a `TypeError`, without reaching the bank, for anything but a `TanAnswer`, for `approved` to a TAN
242
+ * challenge, for a TAN to an app approval the user can report, and for a TAN that the method rules out: longer than
243
+ * `TanMethod.maxTanLength`, with anything but the digits 0 to 9 where `TanMethod.numericTan`, or that FinTS 3.0 cannot
244
+ * send (ISO 8859-1 only). Where the user cannot report the approval (`TanChallenge.manualConfirmation` is `false`),
245
+ * offer no confirm button: any `TanAnswer` but `cancelled` is ignored.
238
246
  *
239
- * Where the client polls the bank by itself, it sets `TanChallenge.resolved`: close the prompt when it settles, the
240
- * callback's result is no longer waited for then. If the bank has not confirmed a reported approval yet, the callback is
241
- * called again with the next `TanChallenge.attempt`. Where `TanChallenge.manualConfirmation` is `false`, offer no
242
- * confirm button: any answer but `TanAnswer.cancelled` is ignored.
247
+ * If the bank has not confirmed a reported approval yet, the callback is called again with the next
248
+ * `TanChallenge.attempt` and the bank's latest instructions. Where the client polls the bank by itself, it sets
249
+ * `TanChallenge.resolved`: close the prompt when it settles, the callback's result is no longer waited for then. New
250
+ * instructions of the bank then replace the open prompt with the next attempt, which the callback gets after the
251
+ * handlers of the earlier prompt's `resolved` have run.
243
252
  */
244
253
  export type TanCallback = (challenge: TanChallenge) => TanAnswer | Promise<TanAnswer>;
245
254