@kreiseck/kasseneck-api 0.6.46 → 0.7.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 +154 -0
- package/README.md +158 -2
- package/dist/cjs/client/aufrufe.d.ts +1 -1
- package/dist/cjs/client/aufrufe.js +19 -0
- package/dist/cjs/client/errors.d.ts +31 -1
- package/dist/cjs/client/errors.js +98 -1
- package/dist/cjs/client/transport.js +7 -7
- package/dist/cjs/partner/ablauf.d.ts +47 -0
- package/dist/cjs/partner/ablauf.js +99 -0
- package/dist/cjs/partner/api.d.ts +68 -0
- package/dist/cjs/partner/api.js +44 -0
- package/dist/cjs/partner/auth.d.ts +33 -0
- package/dist/cjs/partner/auth.js +52 -0
- package/dist/cjs/partner/betrieb.d.ts +48 -0
- package/dist/cjs/partner/betrieb.js +136 -0
- package/dist/cjs/partner/endpunkte.d.ts +142 -0
- package/dist/cjs/partner/endpunkte.js +488 -0
- package/dist/cjs/partner/fehler.d.ts +72 -0
- package/dist/cjs/partner/fehler.js +199 -0
- package/dist/cjs/partner/index.d.ts +28 -0
- package/dist/cjs/partner/index.js +84 -0
- package/dist/cjs/partner/secret.d.ts +66 -0
- package/dist/cjs/partner/secret.js +95 -0
- package/dist/cjs/partner/typen.d.ts +399 -0
- package/dist/cjs/partner/typen.js +33 -0
- package/dist/cjs/partner/webhook-signatur.d.ts +95 -0
- package/dist/cjs/partner/webhook-signatur.js +158 -0
- package/dist/cjs/partner/webhooks.d.ts +211 -0
- package/dist/cjs/partner/webhooks.js +269 -0
- package/dist/esm/client/aufrufe.d.ts +1 -1
- package/dist/esm/client/aufrufe.js +19 -0
- package/dist/esm/client/errors.d.ts +31 -1
- package/dist/esm/client/errors.js +97 -1
- package/dist/esm/client/transport.js +8 -8
- package/dist/esm/partner/ablauf.d.ts +47 -0
- package/dist/esm/partner/ablauf.js +95 -0
- package/dist/esm/partner/api.d.ts +68 -0
- package/dist/esm/partner/api.js +41 -0
- package/dist/esm/partner/auth.d.ts +33 -0
- package/dist/esm/partner/auth.js +48 -0
- package/dist/esm/partner/betrieb.d.ts +48 -0
- package/dist/esm/partner/betrieb.js +132 -0
- package/dist/esm/partner/endpunkte.d.ts +142 -0
- package/dist/esm/partner/endpunkte.js +474 -0
- package/dist/esm/partner/fehler.d.ts +72 -0
- package/dist/esm/partner/fehler.js +189 -0
- package/dist/esm/partner/index.d.ts +28 -0
- package/dist/esm/partner/index.js +27 -0
- package/dist/esm/partner/secret.d.ts +66 -0
- package/dist/esm/partner/secret.js +90 -0
- package/dist/esm/partner/typen.d.ts +399 -0
- package/dist/esm/partner/typen.js +30 -0
- package/dist/esm/partner/webhook-signatur.d.ts +95 -0
- package/dist/esm/partner/webhook-signatur.js +153 -0
- package/dist/esm/partner/webhooks.d.ts +211 -0
- package/dist/esm/partner/webhooks.js +257 -0
- package/fixtures/oberflaeche.json +131 -3
- package/package.json +13 -1
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Die Formen, die die Partner-API sendet und zurueckgibt.
|
|
3
|
+
*
|
|
4
|
+
* **Die Referenz ist das Backend**, nicht diese Datei: `docs/api/partner.md`
|
|
5
|
+
* (ausfuehrlich) und `docs/api/partner.llms.txt` (kompakt) beschreiben Felder,
|
|
6
|
+
* Fehlercodes und Ereignisse. Hier stehen sie als Typen, damit ein Aufrufer
|
|
7
|
+
* beim Tippen sieht, was es gibt — nicht als zweiter Text daneben.
|
|
8
|
+
*
|
|
9
|
+
* **Lesen ist tolerant, Schreiben ist streng.** Antworttypen fuehren `null`
|
|
10
|
+
* dort, wo das Backend `null` schickt, und lassen unbekannte Statuswerte als
|
|
11
|
+
* `string` durch (`(string & {})`): eine Fassung, die einen neuen Status
|
|
12
|
+
* einfuehrt, soll diesen Client nicht zum Absturz bringen, sondern ihn
|
|
13
|
+
* durchreichen. Eingabetypen sind dagegen eng — ein Tippfehler soll ein
|
|
14
|
+
* Compilerfehler sein und keine `validation`-Antwort vom Server.
|
|
15
|
+
*/
|
|
16
|
+
import type { KasseneckSecret } from './secret.js';
|
|
17
|
+
/**
|
|
18
|
+
* Die beiden Umgebungen. Als Liste und nicht nur als Typ, damit ein Aufrufer
|
|
19
|
+
* sie zur Laufzeit pruefen kann — und damit der Zwilling sie nachhaelt.
|
|
20
|
+
*/
|
|
21
|
+
export declare const PARTNER_ENVS: readonly ["live", "test"];
|
|
22
|
+
/** Umgebung, in der ein Partner-Schluessel bzw. ein Betrieb lebt. */
|
|
23
|
+
export type PartnerEnv = typeof PARTNER_ENVS[number];
|
|
24
|
+
/** Berechtigungen eines Partner-Schluessels. */
|
|
25
|
+
export type PartnerScope = 'partner:read' | 'customers:read' | 'customers:write' | 'webhooks:read' | 'webhooks:write' | 'credentials:read' | (string & {});
|
|
26
|
+
/**
|
|
27
|
+
* `credentials:read` gehoert **nicht** zum Standardsatz und wird keinem
|
|
28
|
+
* bestehenden Schluessel nachtraeglich hinzugefuegt: wer ihn hat, kann im Namen
|
|
29
|
+
* fremder Betriebe Belege signieren. Dafuer wird ein eigener Schluessel
|
|
30
|
+
* angelegt.
|
|
31
|
+
*/
|
|
32
|
+
export declare const SCOPE_CREDENTIALS: PartnerScope;
|
|
33
|
+
export interface PartnerApp {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
status: string;
|
|
37
|
+
platform: string | null;
|
|
38
|
+
distributions: unknown[];
|
|
39
|
+
platforms: string[];
|
|
40
|
+
symbol: {
|
|
41
|
+
url: string;
|
|
42
|
+
} | null;
|
|
43
|
+
published: boolean;
|
|
44
|
+
listingAllowed: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface PartnerInfo {
|
|
47
|
+
partner: {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
status: string;
|
|
51
|
+
/**
|
|
52
|
+
* Darf dieser Partner fuer seine Betriebe einen Zugang zum Kundenpanel
|
|
53
|
+
* einrichten lassen? **Vorgabe `false`** — die Freischaltung setzt
|
|
54
|
+
* Kasseneck je Partner. Ohne sie antworten `access:{invite:true}` und
|
|
55
|
+
* `resendPartnerCustomerInvite` mit `zugang_nicht_erlaubt`, und es
|
|
56
|
+
* entsteht nichts, auch kein Betrieb.
|
|
57
|
+
*/
|
|
58
|
+
canCreateAccess: boolean;
|
|
59
|
+
};
|
|
60
|
+
env: PartnerEnv;
|
|
61
|
+
scopes: PartnerScope[];
|
|
62
|
+
key: {
|
|
63
|
+
hint: string | null;
|
|
64
|
+
label: string | null;
|
|
65
|
+
createdAt: number | null;
|
|
66
|
+
scopes: PartnerScope[];
|
|
67
|
+
};
|
|
68
|
+
apps: PartnerApp[];
|
|
69
|
+
}
|
|
70
|
+
export type Rechtsform = 'einzel' | 'eu' | 'og' | 'kg' | 'gmbh' | 'gmbhcokg' | 'ag' | 'verein' | 'sonstige';
|
|
71
|
+
export type Bundesland = 'burgenland' | 'kaernten' | 'niederoesterreich' | 'oberoesterreich' | 'salzburg' | 'steiermark' | 'tirol' | 'vorarlberg' | 'wien';
|
|
72
|
+
export type KontaktRolle = 'geschaeftsfuehrung' | 'buchhaltung' | 'technik' | 'kasse';
|
|
73
|
+
export interface BetriebAdresse {
|
|
74
|
+
street: string;
|
|
75
|
+
/** Hausnummer, kurz und alphanumerisch: `49`, `12a`, `49/5`. */
|
|
76
|
+
number?: string;
|
|
77
|
+
/** Oesterreichische Postleitzahl, vier Ziffern. */
|
|
78
|
+
zip: string;
|
|
79
|
+
city: string;
|
|
80
|
+
}
|
|
81
|
+
export interface BetriebSteuer {
|
|
82
|
+
/** Steuernummer im Format `12-345/6789`; die Pruefziffer wird geprueft. */
|
|
83
|
+
taxNumber: string;
|
|
84
|
+
smallBusiness: boolean;
|
|
85
|
+
/** UID, z. B. `ATU12345675`. */
|
|
86
|
+
uid?: string;
|
|
87
|
+
/** GLN, 13 Ziffern. */
|
|
88
|
+
gln?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface BetriebKontakt {
|
|
91
|
+
name: string;
|
|
92
|
+
email: string;
|
|
93
|
+
phone?: string;
|
|
94
|
+
roles?: KontaktRolle[];
|
|
95
|
+
}
|
|
96
|
+
export interface BetriebSteuerberater {
|
|
97
|
+
name: string;
|
|
98
|
+
email: string;
|
|
99
|
+
phone: string;
|
|
100
|
+
mayContact: boolean;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Die Stammdaten eines Betriebs. Geprueft wird mit denselben Regeln wie in
|
|
104
|
+
* Kasseneckens eigener Kundenaufnahme (`@kreiseck/validator`); bei einem
|
|
105
|
+
* Formfehler entsteht **nichts** — die Antwort traegt `code:"validation"` und
|
|
106
|
+
* `data.errors[{field,message}]`.
|
|
107
|
+
*
|
|
108
|
+
* **Genau diese Felder, kein weiteres.** Das Backend weist ein unbekanntes
|
|
109
|
+
* Feld ab, statt es stillschweigend zu verwerfen, und nennt seinen vollen
|
|
110
|
+
* Pfad (`address.land`, `contacts.0.rolle`). Deshalb hat dieser Typ dieselbe
|
|
111
|
+
* Liste wie `partner-core.BETRIEB_FELDER` — ein ueberzaehliges Feld ist hier
|
|
112
|
+
* ein Compilerfehler und nicht erst eine Antwort vom Server. Fuer Daten, die
|
|
113
|
+
* nicht durch die Typpruefung kommen (Datenbank, Formular), beantwortet
|
|
114
|
+
* [unbekannteBetriebsfelder] dieselbe Frage zur Laufzeit.
|
|
115
|
+
*/
|
|
116
|
+
export interface Betrieb {
|
|
117
|
+
companyName: string;
|
|
118
|
+
legalForm: Rechtsform;
|
|
119
|
+
/** Anmeldung des Betriebs im Kasseneck-Panel; darf dort noch keinen Zugang haben. */
|
|
120
|
+
email: string;
|
|
121
|
+
address: BetriebAdresse;
|
|
122
|
+
state: Bundesland;
|
|
123
|
+
taxDetails: BetriebSteuer;
|
|
124
|
+
/** Mindestens einer, hoechstens zehn. */
|
|
125
|
+
contacts: BetriebKontakt[];
|
|
126
|
+
billingEmail?: string;
|
|
127
|
+
/** Firmenbuchnummer, z. B. `FN 123456 a`. */
|
|
128
|
+
companyRegister?: string;
|
|
129
|
+
/** Gericht: Code (`LG_SALZBURG`), amtlicher Name oder Freitext. */
|
|
130
|
+
court?: string;
|
|
131
|
+
web?: string;
|
|
132
|
+
phone?: string;
|
|
133
|
+
industry?: string;
|
|
134
|
+
taxAdvisor?: BetriebSteuerberater;
|
|
135
|
+
}
|
|
136
|
+
export interface CreateCustomerOptions {
|
|
137
|
+
appId: string;
|
|
138
|
+
business: Betrieb;
|
|
139
|
+
/**
|
|
140
|
+
* Eigener Schluessel gegen Doppelanlage, hoechstens 120 Zeichen. Derselbe
|
|
141
|
+
* Schluessel liefert die gespeicherte Antwort zurueck — auch bei abweichendem
|
|
142
|
+
* Rumpf. Die eigene Kundennummer ist der natuerliche Wert dafuer.
|
|
143
|
+
*/
|
|
144
|
+
idempotencyKey?: string;
|
|
145
|
+
/**
|
|
146
|
+
* `invite:true` legt zusaetzlich einen Zugang zum Kundenpanel an und
|
|
147
|
+
* schickt die Einladung an `betrieb.email`. **Vorgabe ist `false`:** viele
|
|
148
|
+
* Betriebe arbeiten ausschliesslich in der App des Partners, und ein
|
|
149
|
+
* stillschweigend erzeugter Login samt Mail waere dort etwas, das niemand
|
|
150
|
+
* erwartet. Nachholen laesst er sich mit `resendPartnerCustomerInvite`.
|
|
151
|
+
*
|
|
152
|
+
* Nur erlaubt, wenn `getPartnerInfo().partner.canCreateAccess` gilt —
|
|
153
|
+
* sonst `zugang_nicht_erlaubt`, und es entsteht nichts, auch kein Betrieb.
|
|
154
|
+
*/
|
|
155
|
+
access?: {
|
|
156
|
+
invite: boolean;
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* In welcher Umgebung der Betrieb entsteht. Ohne Angabe entscheidet der
|
|
160
|
+
* Schluessel.
|
|
161
|
+
*
|
|
162
|
+
* Ein LIVE-Schluessel darf `env:"test"` verlangen — das ist der vorgesehene
|
|
163
|
+
* Weg, die ganze Kette zu proben, ohne sich einen zweiten Schluessel zu
|
|
164
|
+
* holen. Umgekehrt nie: ein Test-Schluessel mit `env:"live"` bekommt
|
|
165
|
+
* `live_not_allowed`, und es entsteht nichts.
|
|
166
|
+
*
|
|
167
|
+
* Ein so angelegter Live-Betrieb ist **sofort freigeschaltet** und traegt das
|
|
168
|
+
* Modul `registrierkasse`; es wird auf keine Freigabe durch Kasseneck
|
|
169
|
+
* gewartet.
|
|
170
|
+
*/
|
|
171
|
+
env?: PartnerEnv;
|
|
172
|
+
}
|
|
173
|
+
export type KundenStatus = 'created' | 'fon_configured' | 'signature_requested' | 'signature_ready' | 'cashregister_created' | 'live' | 'blocked' | (string & {});
|
|
174
|
+
export interface CreateCustomerResult {
|
|
175
|
+
customerId: string;
|
|
176
|
+
status: KundenStatus;
|
|
177
|
+
env: PartnerEnv;
|
|
178
|
+
companyName: string;
|
|
179
|
+
appId: string;
|
|
180
|
+
access: {
|
|
181
|
+
invited: boolean;
|
|
182
|
+
sentTo: string | null;
|
|
183
|
+
};
|
|
184
|
+
nextSteps: string[];
|
|
185
|
+
/** `true`, wenn derselbe `idempotencyKey` schon einmal ankam. */
|
|
186
|
+
replayed: boolean;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Stand des Auftragsverarbeitungsvertrags eines Betriebs.
|
|
190
|
+
*
|
|
191
|
+
* **Vertraege wirken im Partner-Weg nicht mehr** (Stand 2026-08-31): keine
|
|
192
|
+
* Antwort fuehrt dieses Feld, kein Schritt in `naechsteSchritte` verlangt
|
|
193
|
+
* einen Vertrag, und eine Kasse geht deswegen nicht weniger live. Der Typ
|
|
194
|
+
* bleibt, damit eine Antwort, die ihn doch noch traegt, lesbar durchkommt —
|
|
195
|
+
* **vorausgesetzt wird er nirgends**. Fuer selbst registrierte Kunden gibt es
|
|
196
|
+
* die Maschinerie weiterhin, aber nicht ueber diese Schnittstelle.
|
|
197
|
+
*/
|
|
198
|
+
export interface AvvStand {
|
|
199
|
+
status: string;
|
|
200
|
+
version: string | null;
|
|
201
|
+
confirmedAt: number | null;
|
|
202
|
+
mode: string | null;
|
|
203
|
+
}
|
|
204
|
+
export interface KundenZeile {
|
|
205
|
+
customerId: string;
|
|
206
|
+
companyName: string;
|
|
207
|
+
status: KundenStatus;
|
|
208
|
+
appId: string | null;
|
|
209
|
+
env: PartnerEnv;
|
|
210
|
+
createdAt: number | null;
|
|
211
|
+
/**
|
|
212
|
+
* Vertragsstand, falls die Antwort ihn ueberhaupt fuehrt — heute tut sie das
|
|
213
|
+
* nicht, der Wert ist dann `null`. Siehe [AvvStand]: nichts in diesem Client
|
|
214
|
+
* setzt ihn voraus.
|
|
215
|
+
*/
|
|
216
|
+
avv: AvvStand | null;
|
|
217
|
+
}
|
|
218
|
+
export interface ListCustomersOptions {
|
|
219
|
+
status?: KundenStatus;
|
|
220
|
+
/** 1 bis 200; Vorgabe 50. */
|
|
221
|
+
limit?: number;
|
|
222
|
+
cursor?: string;
|
|
223
|
+
}
|
|
224
|
+
export interface KundenListe {
|
|
225
|
+
customers: KundenZeile[];
|
|
226
|
+
/** Weiter mit diesem Wert als `cursor`; `null` heisst: das war alles. */
|
|
227
|
+
cursor: string | null;
|
|
228
|
+
total: number;
|
|
229
|
+
}
|
|
230
|
+
export interface Kunde extends KundenZeile {
|
|
231
|
+
statusAt: number | null;
|
|
232
|
+
liveEnabled: boolean;
|
|
233
|
+
createdAt: number | null;
|
|
234
|
+
createdVia: string | null;
|
|
235
|
+
business: Record<string, unknown>;
|
|
236
|
+
fon: {
|
|
237
|
+
configured: boolean;
|
|
238
|
+
verifiedAt: number | null;
|
|
239
|
+
};
|
|
240
|
+
access: {
|
|
241
|
+
email: string | null;
|
|
242
|
+
invitedAt: number | null;
|
|
243
|
+
acceptedAt: number | null;
|
|
244
|
+
} | null;
|
|
245
|
+
}
|
|
246
|
+
export interface FonLinkResult {
|
|
247
|
+
customerId: string;
|
|
248
|
+
/** Empfaenger, maskiert — das Backend gibt die Adresse nie im Klartext aus. */
|
|
249
|
+
sentTo: string;
|
|
250
|
+
expiresAt: number;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* `beantragt → zugeteilt → registriert → bereit`. `registriert` heisst: die
|
|
254
|
+
* Einheit ist FinanzOnline bekannt; `bereit` heisst: sie darf signieren. In der
|
|
255
|
+
* Testumgebung wird ohne `registriert` direkt `bereit` erreicht.
|
|
256
|
+
*/
|
|
257
|
+
export type SignaturAntragStatus = 'requested' | 'assigned' | 'registered' | 'ready' | 'failed' | 'cancelled' | (string & {});
|
|
258
|
+
export interface SignaturHistorieEintrag {
|
|
259
|
+
von: string | null;
|
|
260
|
+
nach: string;
|
|
261
|
+
at: number;
|
|
262
|
+
reason: string | null;
|
|
263
|
+
}
|
|
264
|
+
export interface SignaturAntrag {
|
|
265
|
+
requestId: string;
|
|
266
|
+
status: SignaturAntragStatus;
|
|
267
|
+
statusText: string;
|
|
268
|
+
art: string;
|
|
269
|
+
vdaId: string | null;
|
|
270
|
+
signatureId: string | null;
|
|
271
|
+
error: {
|
|
272
|
+
code: string | null;
|
|
273
|
+
message: string | null;
|
|
274
|
+
rc: string | null;
|
|
275
|
+
} | null;
|
|
276
|
+
requestedVia: string | null;
|
|
277
|
+
createdAt: number | null;
|
|
278
|
+
updatedAt: number | null;
|
|
279
|
+
history: SignaturHistorieEintrag[];
|
|
280
|
+
}
|
|
281
|
+
export interface RequestSignatureResult {
|
|
282
|
+
request: SignaturAntrag;
|
|
283
|
+
/** `true`, wenn schon ein Antrag lief — dann ist es der laufende. */
|
|
284
|
+
replayed: boolean;
|
|
285
|
+
note: string | null;
|
|
286
|
+
}
|
|
287
|
+
export interface SignaturStand {
|
|
288
|
+
signatur: {
|
|
289
|
+
ready: boolean;
|
|
290
|
+
signatureId: string | null;
|
|
291
|
+
vdaId: string | null;
|
|
292
|
+
};
|
|
293
|
+
requests: SignaturAntrag[];
|
|
294
|
+
fon: {
|
|
295
|
+
present: boolean;
|
|
296
|
+
verifiedAt: number | null;
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** Die Schritte der Inbetriebnahme, in dieser Reihenfolge. */
|
|
300
|
+
export type KassenSchritt = 'signatur' | 'register_cashregister' | 'start_receipt' | 'transmit_start_receipt' | (string & {});
|
|
301
|
+
export type KassenStatus = 'draft' | 'laeuft' | 'live' | 'failed' | (string & {});
|
|
302
|
+
export interface Kasse {
|
|
303
|
+
cashregisterId: string;
|
|
304
|
+
name: string | null;
|
|
305
|
+
status: KassenStatus;
|
|
306
|
+
statusText: string;
|
|
307
|
+
/** `true`: die Kasse geht von selbst live, sobald die Signatur bereit ist. */
|
|
308
|
+
automatic: boolean;
|
|
309
|
+
/** Der naechste offene Schritt; `null`, wenn die Kasse live ist. */
|
|
310
|
+
step: KassenSchritt | null;
|
|
311
|
+
stepText: string | null;
|
|
312
|
+
completedSteps: KassenSchritt[];
|
|
313
|
+
/** Die Schritte, die fuer genau diese Kasse gelten (Testumgebung: weniger). */
|
|
314
|
+
steps: {
|
|
315
|
+
key: KassenSchritt;
|
|
316
|
+
text: string;
|
|
317
|
+
}[];
|
|
318
|
+
signatureId: string | null;
|
|
319
|
+
attempts: number;
|
|
320
|
+
lastError: {
|
|
321
|
+
code: string | null;
|
|
322
|
+
message: string | null;
|
|
323
|
+
rc: string | null;
|
|
324
|
+
step: KassenSchritt | null;
|
|
325
|
+
at: number | null;
|
|
326
|
+
} | null;
|
|
327
|
+
createdAt: number | null;
|
|
328
|
+
}
|
|
329
|
+
export interface CreateCashregisterOptions {
|
|
330
|
+
customerId: string;
|
|
331
|
+
/** Vorgabe `true`: die Kasse geht von selbst live, sobald die Signatur da ist. */
|
|
332
|
+
automatic?: boolean;
|
|
333
|
+
/**
|
|
334
|
+
* Auf welche Signatur sich die Kasse bezieht. **Jede Kasse bezieht sich auf
|
|
335
|
+
* eine**; ohne eine einzige entsteht keine (`signature_missing`).
|
|
336
|
+
*
|
|
337
|
+
* Hat der Betrieb genau eine, ist sie vorausgewaehlt und dieses Feld
|
|
338
|
+
* ueberfluessig. Bei mehreren muss es dastehen, sonst `signature_ambiguous`
|
|
339
|
+
* samt `data.choices[]`; eine fremde Kennung ist `signature_unknown`. Die
|
|
340
|
+
* Kennungen nennt `getCustomerSignatureStatus`.
|
|
341
|
+
*
|
|
342
|
+
* **Einen Namen gibt es hier nicht.** Kassennamen vergibt Kasseneck, sie
|
|
343
|
+
* sind gleich der `cashregisterId`; ein mitgesendetes `name` waere ein
|
|
344
|
+
* `validation`-Fehler.
|
|
345
|
+
*/
|
|
346
|
+
signatureRequestId?: string;
|
|
347
|
+
}
|
|
348
|
+
export interface CreateCashregisterResult {
|
|
349
|
+
cashregister: Kasse;
|
|
350
|
+
activation: {
|
|
351
|
+
started: boolean;
|
|
352
|
+
ok: boolean | null;
|
|
353
|
+
step: KassenSchritt | null;
|
|
354
|
+
/** `signature_not_ready` oder `automatik_aus`, wenn nicht gestartet wurde. */
|
|
355
|
+
reason: string | null;
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
export interface ActivateCashregisterResult {
|
|
359
|
+
cashregister: Kasse;
|
|
360
|
+
/** `true`: die Kasse war schon live, es wurde nichts getan. */
|
|
361
|
+
unchanged: boolean;
|
|
362
|
+
}
|
|
363
|
+
export interface KassenListe {
|
|
364
|
+
customerId: string;
|
|
365
|
+
cashregisters: Kasse[];
|
|
366
|
+
signatureReady: boolean;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Eine Kasse samt ihrem Token. **Der Token ist ein Geheimnis des Betriebs** —
|
|
370
|
+
* deshalb steht er als [KasseneckSecret] und nicht als `string` darin.
|
|
371
|
+
*/
|
|
372
|
+
export interface CustomerCashregisterCredential {
|
|
373
|
+
cashregisterId: string;
|
|
374
|
+
name: string | null;
|
|
375
|
+
live: boolean;
|
|
376
|
+
/** Kopfzeile `cashregister-token` fuer `createReceipt`. Verschluesselt speichern. */
|
|
377
|
+
cashregisterToken: KasseneckSecret;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Die beiden Geheimnisse, die eine App braucht, um im Namen des Betriebs
|
|
381
|
+
* Belege zu signieren.
|
|
382
|
+
*
|
|
383
|
+
* **Nur verschluesselt speichern. Nie protokollieren, nie in eine Mail, nie in
|
|
384
|
+
* einen Fehlerbericht.** Jeder Abruf wird mitgeschrieben (Partner, Schluessel,
|
|
385
|
+
* Zeitpunkt) und ist fuer den Betrieb und fuer Kasseneck sichtbar.
|
|
386
|
+
*
|
|
387
|
+
* Die Werte stecken in [KasseneckSecret]: `console.log`, `JSON.stringify` und
|
|
388
|
+
* jede Zeichenketten-Umwandlung zeigen eine Maske. Heraus kommt man nur ueber
|
|
389
|
+
* `.reveal()` — und genau diese Stellen findet eine Suche.
|
|
390
|
+
*/
|
|
391
|
+
export interface CustomerCredentials {
|
|
392
|
+
customerId: string;
|
|
393
|
+
companyName: string;
|
|
394
|
+
env: PartnerEnv;
|
|
395
|
+
/** Bearer-Schluessel des Betriebs (`kr_…`). Verschluesselt speichern. */
|
|
396
|
+
apiKey: KasseneckSecret;
|
|
397
|
+
cashregisters: CustomerCashregisterCredential[];
|
|
398
|
+
note: string;
|
|
399
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Die Formen, die die Partner-API sendet und zurueckgibt.
|
|
4
|
+
*
|
|
5
|
+
* **Die Referenz ist das Backend**, nicht diese Datei: `docs/api/partner.md`
|
|
6
|
+
* (ausfuehrlich) und `docs/api/partner.llms.txt` (kompakt) beschreiben Felder,
|
|
7
|
+
* Fehlercodes und Ereignisse. Hier stehen sie als Typen, damit ein Aufrufer
|
|
8
|
+
* beim Tippen sieht, was es gibt — nicht als zweiter Text daneben.
|
|
9
|
+
*
|
|
10
|
+
* **Lesen ist tolerant, Schreiben ist streng.** Antworttypen fuehren `null`
|
|
11
|
+
* dort, wo das Backend `null` schickt, und lassen unbekannte Statuswerte als
|
|
12
|
+
* `string` durch (`(string & {})`): eine Fassung, die einen neuen Status
|
|
13
|
+
* einfuehrt, soll diesen Client nicht zum Absturz bringen, sondern ihn
|
|
14
|
+
* durchreichen. Eingabetypen sind dagegen eng — ein Tippfehler soll ein
|
|
15
|
+
* Compilerfehler sein und keine `validation`-Antwort vom Server.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.SCOPE_CREDENTIALS = exports.PARTNER_ENVS = void 0;
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Partner und Schluessel
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* Die beiden Umgebungen. Als Liste und nicht nur als Typ, damit ein Aufrufer
|
|
24
|
+
* sie zur Laufzeit pruefen kann — und damit der Zwilling sie nachhaelt.
|
|
25
|
+
*/
|
|
26
|
+
exports.PARTNER_ENVS = ['live', 'test'];
|
|
27
|
+
/**
|
|
28
|
+
* `credentials:read` gehoert **nicht** zum Standardsatz und wird keinem
|
|
29
|
+
* bestehenden Schluessel nachtraeglich hinzugefuegt: wer ihn hat, kann im Namen
|
|
30
|
+
* fremder Betriebe Belege signieren. Dafuer wird ein eigener Schluessel
|
|
31
|
+
* angelegt.
|
|
32
|
+
*/
|
|
33
|
+
exports.SCOPE_CREDENTIALS = 'credentials:read';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pruefung der Signatur eingehender Kasseneck-Webhooks.
|
|
3
|
+
*
|
|
4
|
+
* **Das ist der Teil, den Integratoren am haeufigsten falsch bauen** — und der
|
|
5
|
+
* einzige, bei dem ein Fehler nicht auffaellt: eine zu lasche Pruefung laesst
|
|
6
|
+
* jeden durch, der die Adresse kennt, und meldet dabei nie etwas. Deshalb
|
|
7
|
+
* liegt sie fertig im Paket und nicht als Beispielschnipsel in der Doku.
|
|
8
|
+
*
|
|
9
|
+
* Vier Dinge muessen stimmen, und jedes einzelne fehlt in der Praxis regelmaessig:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Der ROHE Rumpf.** Signiert werden die Bytes, die ankommen — nicht das
|
|
12
|
+
* Ergebnis von `JSON.parse` und erneutem `JSON.stringify`. Schluesselreihen-
|
|
13
|
+
* folge, Zahlenschreibweise und Leerraum aendern sich dabei, und die
|
|
14
|
+
* Signatur passt nicht mehr. In Express heisst das `express.raw({type:'*/*'})`
|
|
15
|
+
* **vor** jedem JSON-Parser fuer diesen Pfad.
|
|
16
|
+
* 2. **Das Zeitfenster.** Ohne Pruefung von `t` ist eine einmal mitgeschnittene,
|
|
17
|
+
* gueltig signierte Zustellung fuer immer wiederverwendbar. 300 Sekunden in
|
|
18
|
+
* beide Richtungen — auch in die Zukunft, sonst hilft eine falsch gestellte
|
|
19
|
+
* Uhr auf der Gegenseite dem Angreifer.
|
|
20
|
+
* 3. **Der zeitkonstante Vergleich.** Ein `===` auf Hex-Zeichenketten bricht
|
|
21
|
+
* beim ersten abweichenden Zeichen ab. Wer messen kann, wie lange die
|
|
22
|
+
* Ablehnung dauert, raet die Signatur Zeichen fuer Zeichen.
|
|
23
|
+
* 4. **Jede Ausnahme ist eine Ablehnung.** Ein `catch`, das weiterlaufen laesst,
|
|
24
|
+
* macht aus einem Formfehler ein Ja.
|
|
25
|
+
*
|
|
26
|
+
* Der Kopf lautet `X-Kasseneck-Signature: t=<unix-sekunden>,v1=<hex>` mit
|
|
27
|
+
* `v1 = HMAC-SHA256(secret, "<t>.<roher Rumpf>")`. **Mehrere `v1=`-Anteile sind
|
|
28
|
+
* erlaubt** — so laeuft ein Schluesselwechsel ohne Zustellungsluecke; es reicht,
|
|
29
|
+
* wenn einer passt.
|
|
30
|
+
*
|
|
31
|
+
* Umgesetzt mit WebCrypto (`crypto.subtle`) statt `node:crypto`: dieses Paket
|
|
32
|
+
* haengt an keiner Laufzeit-Abhaengigkeit und wird auch fuer den Browser
|
|
33
|
+
* gebuendelt. Deshalb ist die Pruefung **asynchron**.
|
|
34
|
+
*/
|
|
35
|
+
/** Der Kopf, in dem die Signatur steht. */
|
|
36
|
+
export declare const WEBHOOK_SIGNATURE_HEADER = "X-Kasseneck-Signature";
|
|
37
|
+
/** Der Kopf mit dem Ereignisnamen (dasselbe wie `body.type`). */
|
|
38
|
+
export declare const WEBHOOK_EVENT_HEADER = "X-Kasseneck-Event";
|
|
39
|
+
/** Der Kopf mit der Zustell-Kennung — bei Wiederholungen **dieselbe**. */
|
|
40
|
+
export declare const WEBHOOK_DELIVERY_HEADER = "X-Kasseneck-Delivery";
|
|
41
|
+
/** Erlaubte Abweichung des Zeitstempels, in Sekunden (in beide Richtungen). */
|
|
42
|
+
export declare const WEBHOOK_TOLERANCE_SEC = 300;
|
|
43
|
+
/** Wartezeiten zwischen den Zustellversuchen, in Sekunden. */
|
|
44
|
+
export declare const WEBHOOK_RETRY_PLAN_SEC: readonly number[];
|
|
45
|
+
/** Erstversuch plus je eine Wiederholung pro Planeintrag. */
|
|
46
|
+
export declare const WEBHOOK_MAX_ATTEMPTS: number;
|
|
47
|
+
/** So lange wartet Kasseneck auf eine 2xx-Antwort. */
|
|
48
|
+
export declare const WEBHOOK_TIMEOUT_MS = 10000;
|
|
49
|
+
/** Hoechstzahl der Webhook-Endpunkte je Partner. */
|
|
50
|
+
export declare const WEBHOOK_LIMIT = 10;
|
|
51
|
+
/** Warum eine Zustellung abgelehnt wurde. Jeder Grund ist ein Nein. */
|
|
52
|
+
export type WebhookVerifyReason = 'secret-missing' | 'header-missing' | 'header-malformed' | 'body-missing' | 'timestamp-outside-window' | 'signature-mismatch';
|
|
53
|
+
export type WebhookVerifyResult = {
|
|
54
|
+
ok: true;
|
|
55
|
+
timestampSec: number;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
reason: WebhookVerifyReason;
|
|
59
|
+
};
|
|
60
|
+
export interface VerifyWebhookOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Das Secret aus `createPartnerWebhook` — es verlaesst den Server genau
|
|
63
|
+
* einmal. Eine Liste erlaubt den Schluesselwechsel: es reicht, wenn einer
|
|
64
|
+
* passt.
|
|
65
|
+
*/
|
|
66
|
+
secret: string | readonly string[];
|
|
67
|
+
/** Der Wert des Kopfes `X-Kasseneck-Signature`, unveraendert. */
|
|
68
|
+
signatureHeader: string | null | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Der **rohe** Rumpf — Bytes oder die daraus gelesene Zeichenkette. Nicht
|
|
71
|
+
* das Ergebnis von `JSON.parse`, und nichts, was danach wieder
|
|
72
|
+
* zusammengesetzt wurde.
|
|
73
|
+
*/
|
|
74
|
+
body: string | Uint8Array;
|
|
75
|
+
/** Jetzt, in Unix-Sekunden. Vorgabe: die Systemuhr. Fuer Tests setzbar. */
|
|
76
|
+
nowSec?: number;
|
|
77
|
+
/** Abweichender Toleranzrahmen in Sekunden; Vorgabe [WEBHOOK_TOLERANCE_SEC]. */
|
|
78
|
+
toleranceSec?: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Prueft die Signatur einer eingehenden Zustellung.
|
|
82
|
+
*
|
|
83
|
+
* Antwortet **nie** mit einem geworfenen Fehler auf schlechte Eingaben: jede
|
|
84
|
+
* Ausnahme im Inneren wird zur Ablehnung. Ein Aufrufer, der nur `ok` abfragt,
|
|
85
|
+
* kann damit nichts falsch machen.
|
|
86
|
+
*/
|
|
87
|
+
export declare function verifyWebhookSignature(optionen: VerifyWebhookOptions): Promise<WebhookVerifyResult>;
|
|
88
|
+
/**
|
|
89
|
+
* Zerlegt den Signaturkopf. `null`, wenn kein brauchbarer Zeitstempel oder gar
|
|
90
|
+
* kein `v1=`-Anteil darin steht.
|
|
91
|
+
*/
|
|
92
|
+
export declare function parseSignatureHeader(kopf: string): {
|
|
93
|
+
t: number;
|
|
94
|
+
v1: string[];
|
|
95
|
+
} | null;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pruefung der Signatur eingehender Kasseneck-Webhooks.
|
|
4
|
+
*
|
|
5
|
+
* **Das ist der Teil, den Integratoren am haeufigsten falsch bauen** — und der
|
|
6
|
+
* einzige, bei dem ein Fehler nicht auffaellt: eine zu lasche Pruefung laesst
|
|
7
|
+
* jeden durch, der die Adresse kennt, und meldet dabei nie etwas. Deshalb
|
|
8
|
+
* liegt sie fertig im Paket und nicht als Beispielschnipsel in der Doku.
|
|
9
|
+
*
|
|
10
|
+
* Vier Dinge muessen stimmen, und jedes einzelne fehlt in der Praxis regelmaessig:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Der ROHE Rumpf.** Signiert werden die Bytes, die ankommen — nicht das
|
|
13
|
+
* Ergebnis von `JSON.parse` und erneutem `JSON.stringify`. Schluesselreihen-
|
|
14
|
+
* folge, Zahlenschreibweise und Leerraum aendern sich dabei, und die
|
|
15
|
+
* Signatur passt nicht mehr. In Express heisst das `express.raw({type:'*/*'})`
|
|
16
|
+
* **vor** jedem JSON-Parser fuer diesen Pfad.
|
|
17
|
+
* 2. **Das Zeitfenster.** Ohne Pruefung von `t` ist eine einmal mitgeschnittene,
|
|
18
|
+
* gueltig signierte Zustellung fuer immer wiederverwendbar. 300 Sekunden in
|
|
19
|
+
* beide Richtungen — auch in die Zukunft, sonst hilft eine falsch gestellte
|
|
20
|
+
* Uhr auf der Gegenseite dem Angreifer.
|
|
21
|
+
* 3. **Der zeitkonstante Vergleich.** Ein `===` auf Hex-Zeichenketten bricht
|
|
22
|
+
* beim ersten abweichenden Zeichen ab. Wer messen kann, wie lange die
|
|
23
|
+
* Ablehnung dauert, raet die Signatur Zeichen fuer Zeichen.
|
|
24
|
+
* 4. **Jede Ausnahme ist eine Ablehnung.** Ein `catch`, das weiterlaufen laesst,
|
|
25
|
+
* macht aus einem Formfehler ein Ja.
|
|
26
|
+
*
|
|
27
|
+
* Der Kopf lautet `X-Kasseneck-Signature: t=<unix-sekunden>,v1=<hex>` mit
|
|
28
|
+
* `v1 = HMAC-SHA256(secret, "<t>.<roher Rumpf>")`. **Mehrere `v1=`-Anteile sind
|
|
29
|
+
* erlaubt** — so laeuft ein Schluesselwechsel ohne Zustellungsluecke; es reicht,
|
|
30
|
+
* wenn einer passt.
|
|
31
|
+
*
|
|
32
|
+
* Umgesetzt mit WebCrypto (`crypto.subtle`) statt `node:crypto`: dieses Paket
|
|
33
|
+
* haengt an keiner Laufzeit-Abhaengigkeit und wird auch fuer den Browser
|
|
34
|
+
* gebuendelt. Deshalb ist die Pruefung **asynchron**.
|
|
35
|
+
*/
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.WEBHOOK_LIMIT = exports.WEBHOOK_TIMEOUT_MS = exports.WEBHOOK_MAX_ATTEMPTS = exports.WEBHOOK_RETRY_PLAN_SEC = exports.WEBHOOK_TOLERANCE_SEC = exports.WEBHOOK_DELIVERY_HEADER = exports.WEBHOOK_EVENT_HEADER = exports.WEBHOOK_SIGNATURE_HEADER = void 0;
|
|
38
|
+
exports.verifyWebhookSignature = verifyWebhookSignature;
|
|
39
|
+
exports.parseSignatureHeader = parseSignatureHeader;
|
|
40
|
+
/** Der Kopf, in dem die Signatur steht. */
|
|
41
|
+
exports.WEBHOOK_SIGNATURE_HEADER = 'X-Kasseneck-Signature';
|
|
42
|
+
/** Der Kopf mit dem Ereignisnamen (dasselbe wie `body.type`). */
|
|
43
|
+
exports.WEBHOOK_EVENT_HEADER = 'X-Kasseneck-Event';
|
|
44
|
+
/** Der Kopf mit der Zustell-Kennung — bei Wiederholungen **dieselbe**. */
|
|
45
|
+
exports.WEBHOOK_DELIVERY_HEADER = 'X-Kasseneck-Delivery';
|
|
46
|
+
/** Erlaubte Abweichung des Zeitstempels, in Sekunden (in beide Richtungen). */
|
|
47
|
+
exports.WEBHOOK_TOLERANCE_SEC = 300;
|
|
48
|
+
/** Wartezeiten zwischen den Zustellversuchen, in Sekunden. */
|
|
49
|
+
exports.WEBHOOK_RETRY_PLAN_SEC = [60, 300, 1800, 7200, 43200];
|
|
50
|
+
/** Erstversuch plus je eine Wiederholung pro Planeintrag. */
|
|
51
|
+
exports.WEBHOOK_MAX_ATTEMPTS = exports.WEBHOOK_RETRY_PLAN_SEC.length + 1;
|
|
52
|
+
/** So lange wartet Kasseneck auf eine 2xx-Antwort. */
|
|
53
|
+
exports.WEBHOOK_TIMEOUT_MS = 10_000;
|
|
54
|
+
/** Hoechstzahl der Webhook-Endpunkte je Partner. */
|
|
55
|
+
exports.WEBHOOK_LIMIT = 10;
|
|
56
|
+
/**
|
|
57
|
+
* Prueft die Signatur einer eingehenden Zustellung.
|
|
58
|
+
*
|
|
59
|
+
* Antwortet **nie** mit einem geworfenen Fehler auf schlechte Eingaben: jede
|
|
60
|
+
* Ausnahme im Inneren wird zur Ablehnung. Ein Aufrufer, der nur `ok` abfragt,
|
|
61
|
+
* kann damit nichts falsch machen.
|
|
62
|
+
*/
|
|
63
|
+
async function verifyWebhookSignature(optionen) {
|
|
64
|
+
try {
|
|
65
|
+
const secrets = (Array.isArray(optionen.secret) ? optionen.secret : [optionen.secret]).filter((s) => typeof s === 'string' && s.length > 0);
|
|
66
|
+
if (!secrets.length)
|
|
67
|
+
return { ok: false, reason: 'secret-missing' };
|
|
68
|
+
if (typeof optionen.signatureHeader !== 'string' || !optionen.signatureHeader.trim()) {
|
|
69
|
+
return { ok: false, reason: 'header-missing' };
|
|
70
|
+
}
|
|
71
|
+
const kopf = parseSignatureHeader(optionen.signatureHeader);
|
|
72
|
+
if (kopf === null)
|
|
73
|
+
return { ok: false, reason: 'header-malformed' };
|
|
74
|
+
if (optionen.body === undefined || optionen.body === null)
|
|
75
|
+
return { ok: false, reason: 'body-missing' };
|
|
76
|
+
const jetzt = typeof optionen.nowSec === 'number' && Number.isFinite(optionen.nowSec)
|
|
77
|
+
? optionen.nowSec
|
|
78
|
+
: Math.floor(Date.now() / 1000);
|
|
79
|
+
const toleranz = typeof optionen.toleranceSec === 'number' && Number.isFinite(optionen.toleranceSec)
|
|
80
|
+
? optionen.toleranceSec
|
|
81
|
+
: exports.WEBHOOK_TOLERANCE_SEC;
|
|
82
|
+
// In BEIDE Richtungen: eine vorgehende Uhr auf der Gegenseite darf ein
|
|
83
|
+
// altes Ereignis nicht wieder gueltig machen.
|
|
84
|
+
if (Math.abs(jetzt - kopf.t) > toleranz)
|
|
85
|
+
return { ok: false, reason: 'timestamp-outside-window' };
|
|
86
|
+
const nachricht = signierteBytes(kopf.t, optionen.body);
|
|
87
|
+
for (const secret of secrets) {
|
|
88
|
+
const soll = await hmacSha256(secret, nachricht);
|
|
89
|
+
for (const v1 of kopf.v1) {
|
|
90
|
+
const ist = hexZuBytes(v1);
|
|
91
|
+
if (ist !== null && gleichZeitkonstant(ist, soll))
|
|
92
|
+
return { ok: true, timestampSec: kopf.t };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { ok: false, reason: 'signature-mismatch' };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Punkt 4 im Modulkommentar: eine Ausnahme ist eine Ablehnung, nie ein Ja.
|
|
99
|
+
return { ok: false, reason: 'signature-mismatch' };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Zerlegt den Signaturkopf. `null`, wenn kein brauchbarer Zeitstempel oder gar
|
|
104
|
+
* kein `v1=`-Anteil darin steht.
|
|
105
|
+
*/
|
|
106
|
+
function parseSignatureHeader(kopf) {
|
|
107
|
+
const teile = String(kopf).split(',').map((x) => x.trim());
|
|
108
|
+
const tTeil = teile.find((x) => x.startsWith('t='));
|
|
109
|
+
const v1 = teile.filter((x) => x.startsWith('v1=')).map((x) => x.slice(3));
|
|
110
|
+
if (!tTeil || !v1.length)
|
|
111
|
+
return null;
|
|
112
|
+
const roh = tTeil.slice(2);
|
|
113
|
+
// Nur ganze Zahlen: `Number('1e9')` und `Number(' 12 ')` waeren sonst gueltige
|
|
114
|
+
// Zeitstempel, und `parseInt('12abc')` ebenfalls.
|
|
115
|
+
if (!/^\d{1,15}$/.test(roh))
|
|
116
|
+
return null;
|
|
117
|
+
return { t: Number(roh), v1 };
|
|
118
|
+
}
|
|
119
|
+
/** `<t>.<roher Rumpf>` als Bytes — genau das, was das Backend signiert. */
|
|
120
|
+
function signierteBytes(t, body) {
|
|
121
|
+
const praefix = new TextEncoder().encode(`${t}.`);
|
|
122
|
+
const rumpf = typeof body === 'string' ? new TextEncoder().encode(body) : body;
|
|
123
|
+
const zusammen = new Uint8Array(praefix.length + rumpf.length);
|
|
124
|
+
zusammen.set(praefix, 0);
|
|
125
|
+
zusammen.set(rumpf, praefix.length);
|
|
126
|
+
return zusammen;
|
|
127
|
+
}
|
|
128
|
+
async function hmacSha256(secret, nachricht) {
|
|
129
|
+
const subtle = globalThis.crypto?.subtle;
|
|
130
|
+
if (!subtle) {
|
|
131
|
+
throw new Error('verifyWebhookSignature: kein WebCrypto vorhanden — Node >= 20.18 verwenden (globalThis.crypto.subtle)');
|
|
132
|
+
}
|
|
133
|
+
const key = await subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
134
|
+
const signatur = await subtle.sign('HMAC', key, nachricht);
|
|
135
|
+
return new Uint8Array(signatur);
|
|
136
|
+
}
|
|
137
|
+
/** Hex zu Bytes; `null` bei ungerader Laenge oder Nicht-Hex. */
|
|
138
|
+
function hexZuBytes(hex) {
|
|
139
|
+
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex))
|
|
140
|
+
return null;
|
|
141
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
142
|
+
for (let i = 0; i < bytes.length; i++)
|
|
143
|
+
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
144
|
+
return bytes;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Vergleich ohne frueh Abbrechen. Die Laengenpruefung davor verraet nur die
|
|
148
|
+
* Laenge des Hashs, und die ist bekannt (SHA-256, 32 Bytes); der Inhalt wird
|
|
149
|
+
* immer vollstaendig durchlaufen.
|
|
150
|
+
*/
|
|
151
|
+
function gleichZeitkonstant(a, b) {
|
|
152
|
+
if (a.length !== b.length)
|
|
153
|
+
return false;
|
|
154
|
+
let unterschied = 0;
|
|
155
|
+
for (let i = 0; i < a.length; i++)
|
|
156
|
+
unterschied |= a[i] ^ b[i];
|
|
157
|
+
return unterschied === 0;
|
|
158
|
+
}
|