@scaletrail/connector 0.1.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 CHANGED
@@ -1,6 +1,37 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unveröffentlicht)
3
+ ## 0.3.0 (12.11.2026)
4
+
5
+ - `validateMessage('redact', …)`: `phone` nur noch in E.164 (`^\+[1-9][0-9]{6,14}$`). `email` zählt nur mit `@`
6
+ als Merkmal; ohne gültiges Merkmal braucht die Anfrage `order_ids` (eingeführt mit Schema `1.0.0-draft.8`;
7
+ ausgeliefert wird `draft.9`, siehe unten).
8
+ - Jeder Betrag (`*_cents`) höchstens `10000000000`, sonst ungültig.
9
+ - README: Löschanfrage mit allen Bestellungen der Kundin, Telefon in E.164, Beispiel.
10
+ - `add_to_cart` nimmt zwei optionale Felder: `dedup_id` (UUID aus dem Browser, dieselbe ID wie das Browser-Tag
11
+ desselben Klicks) und `consent` (Form wie an der Bestellung). `quantity` höchstens `10000`. Schema-Version
12
+ `1.0.0-draft.9`.
13
+ - Beispiele: `event-add-to-cart.json` mit `dedup_id` und `consent`, `event-login-identity.json` mit einer UUID als
14
+ `event_id` (je Login neu).
15
+ - README: Ereignisse sofort senden, `occurred_at` höchstens 300 s in der Zukunft, älter als 24 Stunden nicht mehr
16
+ berücksichtigt, `event_id` je Ereignis neu.
17
+
18
+ ## 0.2.0 (11.11.2026)
19
+
20
+ - **Signatur `v2`, nicht kompatibel mit 0.1.0.** Signiert werden Zweck, Zeitstempel, Pfad, Testmodus und Body.
21
+ `signRequest()` verlangt `path` (z.B. `/connect/v1/orders`) und nimmt `dryRun`; `computeSignature()` und
22
+ `verifySignature()` nehmen einen `context` (`{purpose: 'push', path, dryRun}` oder `{purpose: 'read'}`). Eine
23
+ Signatur von 0.1.0 (`v1=`) nimmt die API nicht mehr an.
24
+ - `validateMessage('redact', …)`: `customer_id` ist optional, mindestens eines von `email`, `phone` und `order_ids`
25
+ ist Pflicht, `order_ids` im Format von `order_id`. Schema-Version `1.0.0-draft.7`.
26
+ - Beispiele unter `examples/` um 52 Wochen zurückdatiert, damit sie die Grenze für `updated_at` (höchstens 300 s in
27
+ der Zukunft) bestehen.
28
+ - `signResponse()`: signiert eine Antwort der Lese-API des Shops mit Schlüssel und Secret des Sendens.
29
+ - `verifyResponse()`: prüft eine solche Antwort wie ScaleTrail, auch mit zwei Schlüsseln während eines Wechsels.
30
+ - README: Abschnitt Lese-API; weitere Auslöser für einen neuen Stand (Zahlung, Gebühr, Porto, verworfene Sendung).
31
+ - Beispiel `examples/order-migrated-update.json`: `refund_id` ist die ID der Erstattung im alten Shop (`1201`), so wie
32
+ das frühere Shopsystem sie an ScaleTrail meldete.
33
+
34
+ ## 0.1.0 (10.11.2026)
4
35
 
5
36
  Erste Version.
6
37
 
package/README.md CHANGED
@@ -23,7 +23,8 @@ wenn sie in einem Browser läuft.
23
23
  ## Bestellungen senden
24
24
 
25
25
  Bei **jeder** Änderung einer Bestellung den **vollständigen** Stand senden: angelegt, bezahlt, Status gewechselt,
26
- bearbeitet, erstattet, Sendung angelegt.
26
+ bearbeitet, erstattet, Sendung angelegt oder verworfen, Zahlung, Gebühr oder Porto geändert. Jeder neue Stand trägt
27
+ ein höheres `updated_at` (mit Millisekunden); ein Stand mit gleichem `updated_at` gilt als schon bekannt.
27
28
 
28
29
  ```js
29
30
  import { createClient } from '@scaletrail/connector';
@@ -43,7 +44,7 @@ const result = await scaletrail.sendOrder(order);
43
44
  |---|---|---|
44
45
  | `accepted` | angenommen (`requestId`) | Übergabe als erledigt markieren |
45
46
  | `valid` | nur bei `{ dryRun: true }`: Form gültig, nichts gespeichert | nichts |
46
- | `rejected` | ungültig (`paths` nennt die Felder), Zugang falsch oder Body zu gross | **nicht** wiederholen, Alarm |
47
+ | `rejected` | ungültig (`paths` nennt die Felder), Zugang falsch, Pfad unbekannt, Weiterleitung oder Body zu gross | **nicht** wiederholen, Alarm |
47
48
  | `retry` | vorübergehend: Netz, Timeout, Überlast, Wartung | später wiederholen |
48
49
 
49
50
  Wiederholen ist immer sicher. Derselbe Stand zweimal ändert nichts. Die Pausen dafür:
@@ -61,6 +62,23 @@ ScaleTrail warten.
61
62
  Ebenso: `sendEvent(event)` für `add_to_cart` und `login_identity`, `sendRedact(redact)`, wenn eine Kundin im Shop
62
63
  anonymisiert wird.
63
64
 
65
+ Ereignisse (`sendEvent`): sofort senden, `occurred_at` ist die Zeit des Ereignisses (höchstens 300 s nach der Uhr von
66
+ ScaleTrail, sonst `rejected`). Was älter als 24 Stunden ist, wird angenommen und nicht mehr berücksichtigt. `event_id`
67
+ ist je Ereignis eine neue `uuidV7()`, beim Wiederholen desselben Aufrufs dieselbe. Ohne `sa_uid` (Warenkorb und
68
+ Anmeldung) oder ohne gültige `email` (Anmeldung) wird das Ereignis angenommen und nicht berücksichtigt. Beim Warenkorb
69
+ optional `dedup_id` (Browser-ID aus dem Cookie `_sa_eid`) und `consent` (Stand der Einwilligung, Form wie an der
70
+ Bestellung): `statistics: false` oder `marketing: false` gelten für dieses Ereignis.
71
+
72
+ Löschanfrage (`sendRedact`): senden, **bevor** der Shop die Werte anonymisiert, und die Werte halten, bis `accepted`
73
+ zurückkommt. `order_ids` enthält **alle** Bestellungen der Kundin, die ihre Daten tragen, auch offene und unbezahlte.
74
+ `phone` nur in E.164 (`+41791234567`), sonst `rejected`. Mindestens eines von `email` (mit `@`), `phone` oder
75
+ `order_ids` muss gesetzt sein. `retry` (auch bei sehr vielen Bestellungen) heisst: dieselbe Anfrage wiederholen.
76
+
77
+ ```js
78
+ await client.sendRedact({ email: 'kundin@example.ch', phone: '+41791234567', order_ids: ['200123', '200456'],
79
+ requested_at: new Date().toISOString() });
80
+ ```
81
+
64
82
  ## Cookies am Server des Shops
65
83
 
66
84
  Der Server des Shops setzt bei **jeder** Antwort den Spiegel der Besucher-ID `_sa_uid_shop` und bei einer Landung mit
@@ -133,6 +151,44 @@ const file = createRequire(import.meta.url).resolve('@scaletrail/connector/examp
133
151
  const example = JSON.parse(await readFile(file, 'utf8'));
134
152
  ```
135
153
 
154
+ ## Lese-API für den Abgleich
155
+
156
+ ScaleTrail holt jede Nacht die geänderten Bestellungen beim Shop ab und gleicht sie mit dem ab, was angekommen ist.
157
+ Dafür stellt der Shop drei Routen unter einer eigenen Basis-URL auf seiner Domain bereit (die URL hinterlegt
158
+ ScaleTrail):
159
+
160
+ | Route | Inhalt |
161
+ |---|---|
162
+ | `GET {base}/orders?updated_since=<Zeit>&limit=<1..100>&cursor=<c>` | Stände nach `updated_at` aufsteigend, bei Gleichstand nach `order_id` |
163
+ | `GET {base}/orders?created_since=<Zeit>&created_until=<Zeit>&limit=<1..100>&cursor=<c>` | Stände nach `created_at` aufsteigend, bei Gleichstand nach `order_id` |
164
+ | `GET {base}/orders/{order_id}` | eine Bestellung, sonst `404` |
165
+
166
+ - Zeiten in UTC mit Millisekunden und `Z`, z.B. `2026-12-03T08:42:05.318Z`; `updated_since` und `created_since`
167
+ einschliesslich, `created_until` ausschliesslich, verglichen mit der vollen Genauigkeit der eigenen Zeitstempel.
168
+ - Antwort `{"as_of": "<Zeit der Datenbank>", "orders": [ … ], "next_cursor": "<c>" | null}`, Einzelroute
169
+ `{"as_of": …, "order": { … }}`. Jeder Stand **genau** wie beim Senden, aus derselben Funktion; dabei auch Test-,
170
+ übernommene und unbezahlte Bestellungen.
171
+ - Cursor als Keyset über `(updated_at, order_id)` bzw. `(created_at, order_id)`, nie ein Versatz; ohne Zustand im
172
+ Shop, mindestens 7 Tage gültig, an die Filter gebunden. `limit` darf zwischen Seiten wechseln. Ein ungültiger oder
173
+ abgelaufener Cursor ist `400` mit `{"error": "invalid_cursor"}`.
174
+ - Anmeldung mit `Authorization: Bearer <Lese-Schlüssel>`, einem eigenen Geheimnis des Shops, nur für diese Routen.
175
+ `401` bzw. `403` nur bei falschem Zugang, nie bei einer vorübergehenden Sperre (dann `429` oder `503`).
176
+ - ScaleTrail fragt eine Seite nach der anderen, höchstens 2 Anfragen je Sekunde.
177
+
178
+ **Jede `200` signiert der Shop**, mit derselben Schlüssel-ID und demselben Secret wie beim Senden, über den Body ohne
179
+ Kompression:
180
+
181
+ ```js
182
+ import { signResponse } from '@scaletrail/connector';
183
+
184
+ const body = JSON.stringify({ as_of, orders, next_cursor });
185
+ const headers = await signResponse({ keyId: env.SCALETRAIL_KEY_ID, secret: env.SCALETRAIL_SECRET, body });
186
+ return new Response(body, { status: 200, headers });
187
+ ```
188
+
189
+ Ohne gültige Signatur verarbeitet ScaleTrail keinen Stand der Antwort. Für die eigenen Tests prüft
190
+ `verifyResponse({ keys: { [keyId]: secret }, headers, body })` eine Antwort so, wie ScaleTrail es tut.
191
+
136
192
  ## Signatur ohne Client
137
193
 
138
194
  Wer einen eigenen HTTP-Client nutzt:
@@ -141,11 +197,17 @@ Wer einen eigenen HTTP-Client nutzt:
141
197
  import { signRequest } from '@scaletrail/connector';
142
198
 
143
199
  const body = JSON.stringify({ order });
144
- const headers = await signRequest({ keyId, secret, body, connector: 'mein-shop/2.3.0' });
200
+ const headers = await signRequest({ keyId, secret, body, path: '/connect/v1/orders', connector: 'mein-shop/2.3.0' });
145
201
  await fetch('https://api.scaletrail.io/connect/v1/orders', { method: 'POST', headers, body, redirect: 'manual' });
146
202
  ```
147
203
 
148
- Signiert wird der Body Byte für Byte so, wie er gesendet wird: nach dem Signieren nicht neu serialisieren.
204
+ Signiert wird der Body Byte für Byte so, wie er gesendet wird: nach dem Signieren nicht neu serialisieren. Die
205
+ Signatur (`v2=`) deckt auch den Pfad und den Testmodus: für einen Aufruf mit `?dry_run=1` gehört `dryRun: true` in
206
+ `signRequest`. Eine Signatur gilt damit nur für genau diesen Weg und Modus. Als Query ist nur `?dry_run=1` erlaubt,
207
+ jede andere ergibt `400` mit `{"error": "invalid_query"}`.
208
+
209
+ `updated_at` darf höchstens 300 Sekunden nach der Uhr von ScaleTrail liegen, sonst ist der Stand ungültig (`400`).
210
+ Die Uhr des Shops-Servers sollte deshalb per NTP gestellt sein.
149
211
 
150
212
  ## Lizenz
151
213
 
@@ -1,9 +1,9 @@
1
- {
2
- "redact": {
3
- "customer_id": "c-33018",
4
- "email": "alt.kundin@example.ch",
5
- "phone": null,
6
- "order_ids": ["111842"],
7
- "requested_at": "2027-02-01T08:00:00.000+01:00"
8
- }
9
- }
1
+ {
2
+ "redact": {
3
+ "customer_id": "c-33018",
4
+ "email": "alt.kundin@example.ch",
5
+ "phone": null,
6
+ "order_ids": ["111842"],
7
+ "requested_at": "2026-02-02T08:00:00.000+01:00"
8
+ }
9
+ }
@@ -1,17 +1,19 @@
1
- {
2
- "event": {
3
- "type": "add_to_cart",
4
- "event_id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
5
- "occurred_at": "2026-12-03T09:38:47.211+01:00",
6
- "sa_uid": "0192f3a4-5b6c-7d8e-9f01-23456789abcd",
7
- "product_id": "4711",
8
- "variant_id": null,
9
- "sku": "1020",
10
- "name": "Beispiel Serum 50 ml",
11
- "quantity": 1,
12
- "unit_price_cents": 4990,
13
- "currency": "CHF",
14
- "page_url": "https://www.example.ch/produkt/beispiel-serum/",
15
- "client": { "ip": "203.0.113.24", "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 26_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1" }
16
- }
17
- }
1
+ {
2
+ "event": {
3
+ "type": "add_to_cart",
4
+ "event_id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
5
+ "occurred_at": "2025-12-04T09:38:47.211+01:00",
6
+ "sa_uid": "0192f3a4-5b6c-7d8e-9f01-23456789abcd",
7
+ "product_id": "4711",
8
+ "variant_id": null,
9
+ "sku": "1020",
10
+ "name": "Beispiel Serum 50 ml",
11
+ "quantity": 1,
12
+ "unit_price_cents": 4990,
13
+ "currency": "CHF",
14
+ "page_url": "https://www.example.ch/produkt/beispiel-serum/",
15
+ "client": { "ip": "203.0.113.24", "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 26_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1" },
16
+ "dedup_id": "0192f3b0-19f8-7a21-9c3e-2b4d6f8a0c1e",
17
+ "consent": { "source": "custom", "statistics": true, "marketing": true, "captured_at": "2025-12-04T09:31:15.000+01:00" }
18
+ }
19
+ }
@@ -1,13 +1,13 @@
1
- {
2
- "event": {
3
- "type": "login_identity",
4
- "event_id": "login_c-58210_2026-12-03",
5
- "occurred_at": "2026-12-03T09:36:02.000+01:00",
6
- "sa_uid": "0192f3a4-5b6c-7d8e-9f01-23456789abcd",
7
- "customer_id": "c-58210",
8
- "email": "beispiel.kundin@example.ch",
9
- "phone": "+41791234567",
10
- "first_name": "Anna",
11
- "last_name": "Muster"
12
- }
13
- }
1
+ {
2
+ "event": {
3
+ "type": "login_identity",
4
+ "event_id": "0192f3ad-77c2-7e10-8b5a-4c6d8e0f1a23",
5
+ "occurred_at": "2025-12-04T09:36:02.000+01:00",
6
+ "sa_uid": "0192f3a4-5b6c-7d8e-9f01-23456789abcd",
7
+ "customer_id": "c-58210",
8
+ "email": "beispiel.kundin@example.ch",
9
+ "phone": "+41791234567",
10
+ "first_name": "Anna",
11
+ "last_name": "Muster"
12
+ }
13
+ }
@@ -1,21 +1,21 @@
1
- {
2
- "order": {
3
- "schema_version": "1",
4
- "order_id": "1000401",
5
- "order_number": "GX-55120931",
6
- "created_at": "2027-01-06T11:12:00.000+01:00",
7
- "updated_at": "2027-01-06T11:12:01.010+01:00",
8
- "paid_at": "2027-01-06T11:12:00.000+01:00",
9
- "status": "processing",
10
- "sales_channel": "galaxus",
11
- "is_test": false,
12
- "currency": "CHF",
13
- "totals": { "total_cents": 4290, "tax_cents": 321, "taxes_included": true },
14
- "items": [
15
- { "line_id": "l1", "product_id": "4712", "sku": "1031", "name": "Beispiel Shampoo 200 ml", "quantity": 1, "unit_price_cents": 4290, "line_total_cents": 4290, "unit_cost_cents": 380 }
16
- ],
17
- "payments": [{ "gateway_id": "galaxus", "amount_cents": 4290 }],
18
- "customer": { "customer_id": "c-90311", "is_guest": true, "email": "marktplatz.kundin@example.ch" },
19
- "attribution": null
20
- }
21
- }
1
+ {
2
+ "order": {
3
+ "schema_version": "1",
4
+ "order_id": "1000401",
5
+ "order_number": "GX-55120931",
6
+ "created_at": "2026-01-07T11:12:00.000+01:00",
7
+ "updated_at": "2026-01-07T11:12:01.010+01:00",
8
+ "paid_at": "2026-01-07T11:12:00.000+01:00",
9
+ "status": "processing",
10
+ "sales_channel": "galaxus",
11
+ "is_test": false,
12
+ "currency": "CHF",
13
+ "totals": { "total_cents": 4290, "tax_cents": 321, "taxes_included": true },
14
+ "items": [
15
+ { "line_id": "l1", "product_id": "4712", "sku": "1031", "name": "Beispiel Shampoo 200 ml", "quantity": 1, "unit_price_cents": 4290, "line_total_cents": 4290, "unit_cost_cents": 380 }
16
+ ],
17
+ "payments": [{ "gateway_id": "galaxus", "amount_cents": 4290 }],
18
+ "customer": { "customer_id": "c-90311", "is_guest": true, "email": "marktplatz.kundin@example.ch" },
19
+ "attribution": null
20
+ }
21
+ }
@@ -1,22 +1,22 @@
1
- {
2
- "order": {
3
- "schema_version": "1",
4
- "order_id": "111842",
5
- "origin": "migrated",
6
- "created_at": "2026-10-28T19:05:44.000+01:00",
7
- "updated_at": "2027-01-08T10:30:02.120+01:00",
8
- "paid_at": "2026-10-28T19:06:10.000+01:00",
9
- "status": "refunded",
10
- "sales_channel": "web",
11
- "is_test": false,
12
- "currency": "CHF",
13
- "totals": { "total_cents": 6480, "taxes_included": true },
14
- "items": [
15
- { "line_id": "w-90021", "product_id": "4713", "sku": "Y1003", "name": "Beispiel Wimpernserum", "quantity": 1, "unit_price_cents": 6480, "line_total_cents": 6480 }
16
- ],
17
- "refunds": [
18
- { "refund_id": "w-r-1201", "created_at": "2027-01-08T10:30:00.000+01:00", "amount_cents": 6480, "reason": "Rücksendung" }
19
- ],
20
- "customer": { "customer_id": "c-33018", "email": "alt.kundin@example.ch" }
21
- }
22
- }
1
+ {
2
+ "order": {
3
+ "schema_version": "1",
4
+ "order_id": "111842",
5
+ "origin": "migrated",
6
+ "created_at": "2025-10-29T19:05:44.000+01:00",
7
+ "updated_at": "2026-01-09T10:30:02.120+01:00",
8
+ "paid_at": "2025-10-29T19:06:10.000+01:00",
9
+ "status": "refunded",
10
+ "sales_channel": "web",
11
+ "is_test": false,
12
+ "currency": "CHF",
13
+ "totals": { "total_cents": 6480, "taxes_included": true },
14
+ "items": [
15
+ { "line_id": "w-90021", "product_id": "4713", "sku": "Y1003", "name": "Beispiel Wimpernserum", "quantity": 1, "unit_price_cents": 6480, "line_total_cents": 6480 }
16
+ ],
17
+ "refunds": [
18
+ { "refund_id": "1201", "created_at": "2026-01-09T10:30:00.000+01:00", "amount_cents": 6480, "reason": "Rücksendung" }
19
+ ],
20
+ "customer": { "customer_id": "c-33018", "email": "alt.kundin@example.ch" }
21
+ }
22
+ }
@@ -1,44 +1,44 @@
1
- {
2
- "order": {
3
- "schema_version": "1",
4
- "order_id": "1000377",
5
- "created_at": "2027-01-05T03:00:12.000+01:00",
6
- "updated_at": "2027-01-05T03:00:14.551+01:00",
7
- "paid_at": "2027-01-05T03:00:13.000+01:00",
8
- "status": "processing",
9
- "sales_channel": "web",
10
- "is_test": false,
11
- "currency": "CHF",
12
- "language": "fr",
13
- "totals": { "total_cents": 5490, "shipping_cents": 0, "tax_cents": 411, "taxes_included": true },
14
- "items": [
15
- {
16
- "line_id": "l1",
17
- "product_id": "4711",
18
- "sku": "1020",
19
- "name": "Sérum exemple 50 ml",
20
- "quantity": 1,
21
- "unit_price_cents": 5490,
22
- "line_total_cents": 5490,
23
- "tax_rate": 8.1,
24
- "unit_cost_cents": 610,
25
- "is_subscription": true
26
- }
27
- ],
28
- "payments": [
29
- { "gateway_id": "stripe_card", "method": "Carte de crédit", "amount_cents": 5490, "fee_cents": 112, "fee_source": "provider", "status": "captured" }
30
- ],
31
- "subscription": {
32
- "subscription_id": "sub-2291",
33
- "is_renewal": true,
34
- "renewal_number": 4,
35
- "status": "active",
36
- "interval": "P2M",
37
- "started_at": "2026-05-05T11:20:00.000+02:00"
38
- },
39
- "customer": { "customer_id": "c-41007", "email": "client.exemple@example.ch", "order_count": 5 },
40
- "attribution": null,
41
- "client": null,
42
- "consent": null
43
- }
44
- }
1
+ {
2
+ "order": {
3
+ "schema_version": "1",
4
+ "order_id": "1000377",
5
+ "created_at": "2026-01-06T03:00:12.000+01:00",
6
+ "updated_at": "2026-01-06T03:00:14.551+01:00",
7
+ "paid_at": "2026-01-06T03:00:13.000+01:00",
8
+ "status": "processing",
9
+ "sales_channel": "web",
10
+ "is_test": false,
11
+ "currency": "CHF",
12
+ "language": "fr",
13
+ "totals": { "total_cents": 5490, "shipping_cents": 0, "tax_cents": 411, "taxes_included": true },
14
+ "items": [
15
+ {
16
+ "line_id": "l1",
17
+ "product_id": "4711",
18
+ "sku": "1020",
19
+ "name": "Sérum exemple 50 ml",
20
+ "quantity": 1,
21
+ "unit_price_cents": 5490,
22
+ "line_total_cents": 5490,
23
+ "tax_rate": 8.1,
24
+ "unit_cost_cents": 610,
25
+ "is_subscription": true
26
+ }
27
+ ],
28
+ "payments": [
29
+ { "gateway_id": "stripe_card", "method": "Carte de crédit", "amount_cents": 5490, "fee_cents": 112, "fee_source": "provider", "status": "captured" }
30
+ ],
31
+ "subscription": {
32
+ "subscription_id": "sub-2291",
33
+ "is_renewal": true,
34
+ "renewal_number": 4,
35
+ "status": "active",
36
+ "interval": "P2M",
37
+ "started_at": "2025-05-06T11:20:00.000+02:00"
38
+ },
39
+ "customer": { "customer_id": "c-41007", "email": "client.exemple@example.ch", "order_count": 5 },
40
+ "attribution": null,
41
+ "client": null,
42
+ "consent": null
43
+ }
44
+ }
@@ -3,9 +3,9 @@
3
3
  "schema_version": "1",
4
4
  "order_id": "1000123",
5
5
  "origin": "native",
6
- "created_at": "2026-12-03T09:41:12.000+01:00",
7
- "updated_at": "2026-12-03T09:42:05.318+01:00",
8
- "paid_at": "2026-12-03T09:42:03.000+01:00",
6
+ "created_at": "2025-12-04T09:41:12.000+01:00",
7
+ "updated_at": "2025-12-04T09:42:05.318+01:00",
8
+ "paid_at": "2025-12-04T09:42:03.000+01:00",
9
9
  "status": "processing",
10
10
  "sales_channel": "web",
11
11
  "is_test": false,
@@ -41,7 +41,7 @@
41
41
  { "source": "rule", "amount_cents": 1000, "rule_id": "mengenpreis-2", "label": "Mengenpreis ab 2", "line_ids": ["l1"] }
42
42
  ],
43
43
  "payments": [
44
- { "gateway_id": "saferpay_twint", "method": "TWINT", "amount_cents": 9770, "fee_cents": null, "fee_source": null, "status": "captured", "captured_at": "2026-12-03T09:42:03.000+01:00" }
44
+ { "gateway_id": "saferpay_twint", "method": "TWINT", "amount_cents": 9770, "fee_cents": null, "fee_source": null, "status": "captured", "captured_at": "2025-12-04T09:42:03.000+01:00" }
45
45
  ],
46
46
  "shipments": [],
47
47
  "refunds": [],
@@ -54,7 +54,7 @@
54
54
  "first_name": "Anna",
55
55
  "last_name": "Muster",
56
56
  "order_count": 3,
57
- "first_order_at": "2025-04-17T18:03:00.000+02:00"
57
+ "first_order_at": "2024-04-18T18:03:00.000+02:00"
58
58
  },
59
59
  "billing_address": { "street": "Musterweg 1", "postcode": "6343", "city": "Rotkreuz", "region": "ZG", "country": "CH" },
60
60
  "shipping_address": { "street": "Musterweg 1", "postcode": "6343", "city": "Rotkreuz", "region": "ZG", "country": "CH" },
@@ -65,9 +65,9 @@
65
65
  "landing_url": "https://www.example.ch/produkt/beispiel-serum/?gclid=Cj0KCQiA_beispiel_gclid_0123456789",
66
66
  "referrer": "https://www.google.com/",
67
67
  "browser_ids": { "ga": "GA1.1.123456789.1764750000", "fbp": "fb.1.1764750000000.1234567890", "fbc": null, "ttp": null },
68
- "captured_at": "2026-11-28T20:15:44.000+01:00"
68
+ "captured_at": "2025-11-29T20:15:44.000+01:00"
69
69
  },
70
70
  "client": { "ip": "203.0.113.24", "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 26_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1" },
71
- "consent": { "source": "cookiebot", "statistics": true, "marketing": true, "captured_at": "2026-12-03T09:40:58.000+01:00" }
71
+ "consent": { "source": "cookiebot", "statistics": true, "marketing": true, "captured_at": "2025-12-04T09:40:58.000+01:00" }
72
72
  }
73
73
  }
@@ -3,9 +3,9 @@
3
3
  "schema_version": "1",
4
4
  "order_id": "1000123",
5
5
  "origin": "native",
6
- "created_at": "2026-12-03T09:41:12.000+01:00",
7
- "updated_at": "2026-12-09T14:02:51.904+01:00",
8
- "paid_at": "2026-12-03T09:42:03.000+01:00",
6
+ "created_at": "2025-12-04T09:41:12.000+01:00",
7
+ "updated_at": "2025-12-10T14:02:51.904+01:00",
8
+ "paid_at": "2025-12-04T09:42:03.000+01:00",
9
9
  "status": "completed",
10
10
  "sales_channel": "web",
11
11
  "is_test": false,
@@ -62,7 +62,7 @@
62
62
  "fee_cents": null,
63
63
  "fee_source": null,
64
64
  "status": "captured",
65
- "captured_at": "2026-12-03T09:42:03.000+01:00"
65
+ "captured_at": "2025-12-04T09:42:03.000+01:00"
66
66
  }
67
67
  ],
68
68
  "shipments": [
@@ -71,13 +71,13 @@
71
71
  "method": "post_a_post",
72
72
  "postage_cents": 700,
73
73
  "weight_grams": 180,
74
- "created_at": "2026-12-03T15:10:00.000+01:00"
74
+ "created_at": "2025-12-04T15:10:00.000+01:00"
75
75
  }
76
76
  ],
77
77
  "refunds": [
78
78
  {
79
79
  "refund_id": "r-88",
80
- "created_at": "2026-12-09T14:02:50.000+01:00",
80
+ "created_at": "2025-12-10T14:02:50.000+01:00",
81
81
  "amount_cents": 4490,
82
82
  "tax_cents": 336,
83
83
  "shipping_cents": 0,
@@ -100,7 +100,7 @@
100
100
  "first_name": "Anna",
101
101
  "last_name": "Muster",
102
102
  "order_count": 3,
103
- "first_order_at": "2025-04-17T18:03:00.000+02:00"
103
+ "first_order_at": "2024-04-18T18:03:00.000+02:00"
104
104
  },
105
105
  "billing_address": {
106
106
  "street": "Musterweg 1",
@@ -138,7 +138,7 @@
138
138
  "fbc": null,
139
139
  "ttp": null
140
140
  },
141
- "captured_at": "2026-11-28T20:15:44.000+01:00"
141
+ "captured_at": "2025-11-29T20:15:44.000+01:00"
142
142
  },
143
143
  "client": {
144
144
  "ip": "203.0.113.24",
@@ -148,7 +148,7 @@
148
148
  "source": "cookiebot",
149
149
  "statistics": true,
150
150
  "marketing": true,
151
- "captured_at": "2026-12-03T09:40:58.000+01:00"
151
+ "captured_at": "2025-12-04T09:40:58.000+01:00"
152
152
  }
153
153
  }
154
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scaletrail/connector",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Connect a custom-built shop to ScaleTrail: signed order snapshots, edge cookies, schema validation.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://scaletrail.io",
package/src/client.js CHANGED
@@ -3,7 +3,9 @@
3
3
 
4
4
  import { signRequest, toBytes } from './signature.js';
5
5
 
6
- export const API_BASE = 'https://api.scaletrail.io/connect/v1';
6
+ const API_ORIGIN = 'https://api.scaletrail.io';
7
+ const API_PATH = '/connect/v1';
8
+ export const API_BASE = `${API_ORIGIN}${API_PATH}`;
7
9
  export const MAX_BODY_BYTES = 256 * 1024;
8
10
  export const DEFAULT_TIMEOUT_MS = 15_000;
9
11
 
@@ -116,8 +118,10 @@ export function createClient({ keyId, secret, connector, fetch: fetchImpl = glob
116
118
  async function send(route, message, { dryRun = false } = {}) {
117
119
  const body = JSON.stringify(message);
118
120
  if (toBytes(body).length > MAX_BODY_BYTES) return { outcome: 'rejected', status: null, reason: 'too_large' };
119
- const headers = await signRequest({ keyId, secret, body, connector });
120
- const url = `${API_BASE}/${ROUTES[route]}${dryRun ? '?dry_run=1' : ''}`;
121
+ // The signature covers the path and the test mode, so a test call cannot be replayed as a real one.
122
+ const path = `${API_PATH}/${ROUTES[route]}`;
123
+ const headers = await signRequest({ keyId, secret, body, path, dryRun: dryRun === true, connector });
124
+ const url = `${API_ORIGIN}${path}${dryRun === true ? '?dry_run=1' : ''}`;
121
125
  const controller = new AbortController();
122
126
  const timer = setTimeout(() => controller.abort(), timeoutMs);
123
127
  let res;
package/src/index.d.ts CHANGED
@@ -14,12 +14,20 @@ export function normalizeVisitorId(value: unknown): string | null;
14
14
  // Signing
15
15
  export const TIMESTAMP_TOLERANCE_SECONDS: number;
16
16
  export type Body = string | Uint8Array | ArrayBuffer;
17
- export function computeSignature(secret: string, timestamp: number, body: Body): Promise<string>;
17
+ /** What a signature covers: a request to ScaleTrail (with its path and test mode) or a response of the read API. */
18
+ export type SignatureContext =
19
+ | { purpose: 'push'; path: string; dryRun?: boolean }
20
+ | { purpose: 'read' };
21
+ export function computeSignature(secret: string, timestamp: number, body: Body, context: SignatureContext): Promise<string>;
18
22
  export function connectorHeader(name?: string): string;
19
23
  export function signRequest(opts: {
20
24
  keyId: string;
21
25
  secret: string;
22
26
  body: Body;
27
+ /** Request path, e.g. "/connect/v1/orders". */
28
+ path: string;
29
+ /** True for a call with ?dry_run=1. */
30
+ dryRun?: boolean;
23
31
  timestamp?: number;
24
32
  connector?: string;
25
33
  }): Promise<Record<string, string>>;
@@ -28,6 +36,21 @@ export function verifySignature(opts: {
28
36
  timestamp: number | string;
29
37
  body: Body;
30
38
  signature: string;
39
+ context: SignatureContext;
40
+ nowMs?: number;
41
+ }): Promise<boolean>;
42
+ /** Headers for a 200 response of the shop's read API, signed with the same key and secret as for sending. */
43
+ export function signResponse(opts: {
44
+ keyId: string;
45
+ secret: string;
46
+ body: Body;
47
+ timestamp?: number;
48
+ }): Promise<Record<string, string>>;
49
+ /** Checks a signed read API response like ScaleTrail does. `keys` maps key ID to secret. Never throws. */
50
+ export function verifyResponse(opts: {
51
+ keys: Record<string, string>;
52
+ headers: Headers | Record<string, string>;
53
+ body: Body;
31
54
  nowMs?: number;
32
55
  }): Promise<boolean>;
33
56
 
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { VERSION } from './version.js';
2
2
  export { uuidV7, isVisitorId, normalizeVisitorId } from './uuid.js';
3
3
  export {
4
- computeSignature, signRequest, verifySignature, connectorHeader, TIMESTAMP_TOLERANCE_SECONDS,
4
+ computeSignature, signRequest, verifySignature, signResponse, verifyResponse, connectorHeader, TIMESTAMP_TOLERANCE_SECONDS,
5
5
  } from './signature.js';
6
6
  export {
7
7
  createClient, classifyResponse, retryDelaySeconds,
package/src/schema.js CHANGED
@@ -3,7 +3,7 @@ export const SCHEMA = Object.freeze({
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "$id": "https://api.scaletrail.io/connect/v1/schema.json",
5
5
  "title": "ScaleTrail Connector API v1 (Eigenbau-Shops)",
6
- "version": "1.0.0-draft.6",
6
+ "version": "1.0.0-draft.9",
7
7
  "oneOf": [
8
8
  {
9
9
  "$ref": "#/definitions/order_message"
@@ -18,14 +18,16 @@ export const SCHEMA = Object.freeze({
18
18
  "definitions": {
19
19
  "cents": {
20
20
  "type": "integer",
21
- "minimum": 0
21
+ "minimum": 0,
22
+ "maximum": 10000000000
22
23
  },
23
24
  "cents_or_null": {
24
25
  "type": [
25
26
  "integer",
26
27
  "null"
27
28
  ],
28
- "minimum": 0
29
+ "minimum": 0,
30
+ "maximum": 10000000000
29
31
  },
30
32
  "datetime": {
31
33
  "type": "string",
@@ -991,7 +993,8 @@ export const SCHEMA = Object.freeze({
991
993
  },
992
994
  "quantity": {
993
995
  "type": "integer",
994
- "minimum": 1
996
+ "minimum": 1,
997
+ "maximum": 10000
995
998
  },
996
999
  "unit_price_cents": {
997
1000
  "$ref": "#/definitions/cents"
@@ -1016,6 +1019,19 @@ export const SCHEMA = Object.freeze({
1016
1019
  "$ref": "#/definitions/client"
1017
1020
  }
1018
1021
  ]
1022
+ },
1023
+ "dedup_id": {
1024
+ "$ref": "#/definitions/uuid"
1025
+ },
1026
+ "consent": {
1027
+ "oneOf": [
1028
+ {
1029
+ "type": "null"
1030
+ },
1031
+ {
1032
+ "$ref": "#/definitions/consent"
1033
+ }
1034
+ ]
1019
1035
  }
1020
1036
  }
1021
1037
  },
@@ -1073,12 +1089,11 @@ export const SCHEMA = Object.freeze({
1073
1089
  "type": "object",
1074
1090
  "additionalProperties": false,
1075
1091
  "required": [
1076
- "customer_id",
1077
1092
  "requested_at"
1078
1093
  ],
1079
1094
  "properties": {
1080
1095
  "customer_id": {
1081
- "$ref": "#/definitions/id"
1096
+ "$ref": "#/definitions/id_or_null"
1082
1097
  },
1083
1098
  "email": {
1084
1099
  "type": [
@@ -1092,13 +1107,14 @@ export const SCHEMA = Object.freeze({
1092
1107
  "string",
1093
1108
  "null"
1094
1109
  ],
1095
- "maxLength": 32
1110
+ "maxLength": 32,
1111
+ "pattern": "^\\+[1-9][0-9]{6,14}$"
1096
1112
  },
1097
1113
  "order_ids": {
1098
1114
  "type": "array",
1099
1115
  "maxItems": 1000,
1100
1116
  "items": {
1101
- "$ref": "#/definitions/id"
1117
+ "$ref": "#/definitions/order_id"
1102
1118
  }
1103
1119
  },
1104
1120
  "requested_at": {
package/src/signature.js CHANGED
@@ -1,4 +1,7 @@
1
- // Request signing: HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex, sent as "v1=<hex>".
1
+ // Signing, version 2: HMAC-SHA256(secret, "<prefix><raw body>"), hex, sent as "v2=<hex>".
2
+ // requests to ScaleTrail: prefix "st-push.<timestamp>.<path>.<test mode 0|1>."
3
+ // responses of the read API: prefix "st-read.<timestamp>."
4
+ // The purpose keeps the two apart, the path and the test mode bind a request to exactly one route and mode.
2
5
  // Uses Web Crypto only, so it runs unchanged in Cloudflare Workers, Deno, Bun and Node 20+.
3
6
 
4
7
  import { VERSION } from './version.js';
@@ -6,8 +9,9 @@ import { VERSION } from './version.js';
6
9
  export const TIMESTAMP_TOLERANCE_SECONDS = 300;
7
10
 
8
11
  const KEY_ID_RE = /^ck_[0-9a-f]{24}$/;
9
- const SIGNATURE_RE = /^v1=([0-9a-f]{64})$/;
12
+ const SIGNATURE_RE = /^v2=([0-9a-f]{64})$/;
10
13
  const CONNECTOR_RE = /^[\x21-\x7e][\x20-\x7e]{0,119}$/;
14
+ const PATH_RE = /^\/connect\/v1\/[a-z/]{1,40}$/;
11
15
  const enc = new TextEncoder();
12
16
 
13
17
  /**
@@ -44,18 +48,37 @@ function assertTimestamp(timestamp) {
44
48
  }
45
49
 
46
50
  /**
47
- * Signature of a request body as 64 lower-case hex characters.
51
+ * Signed prefix for a context.
52
+ * @param {number} timestamp
53
+ * @param {{purpose: 'push', path: string, dryRun?: boolean} | {purpose: 'read'}} context
54
+ * @returns {string}
55
+ */
56
+ function signedPrefix(timestamp, context) {
57
+ if (context && context.purpose === 'read') return `st-read.${timestamp}.`;
58
+ if (context && context.purpose === 'push') {
59
+ if (typeof context.path !== 'string' || !PATH_RE.test(context.path)) {
60
+ throw new TypeError('path must be the request path, e.g. /connect/v1/orders');
61
+ }
62
+ return `st-push.${timestamp}.${context.path}.${context.dryRun === true ? '1' : '0'}.`;
63
+ }
64
+ throw new TypeError("context.purpose must be 'push' or 'read'");
65
+ }
66
+
67
+ /**
68
+ * Signature as 64 lower-case hex characters.
48
69
  * @param {string} secret signing secret
49
70
  * @param {number} timestamp Unix seconds
50
71
  * @param {string|Uint8Array|ArrayBuffer} body the exact bytes that are sent
72
+ * @param {{purpose: 'push', path: string, dryRun?: boolean} | {purpose: 'read'}} context
73
+ * push: a request to ScaleTrail, with its path and whether it is a test call; read: a response of the read API
51
74
  * @returns {Promise<string>}
52
75
  */
53
- export async function computeSignature(secret, timestamp, body) {
76
+ export async function computeSignature(secret, timestamp, body, context) {
54
77
  assertServerSide();
55
78
  assertSecret(secret);
56
79
  assertTimestamp(timestamp);
57
80
  const raw = toBytes(body);
58
- const prefix = enc.encode(`${timestamp}.`);
81
+ const prefix = enc.encode(signedPrefix(timestamp, context));
59
82
  const msg = new Uint8Array(prefix.length + raw.length);
60
83
  msg.set(prefix, 0);
61
84
  msg.set(raw, prefix.length);
@@ -81,21 +104,72 @@ export function connectorHeader(name) {
81
104
 
82
105
  /**
83
106
  * Headers for a signed request.
84
- * @param {{keyId: string, secret: string, body: string|Uint8Array|ArrayBuffer, timestamp?: number, connector?: string}} opts
107
+ * @param {{keyId: string, secret: string, body: string|Uint8Array|ArrayBuffer, path: string, dryRun?: boolean, timestamp?: number, connector?: string}} opts
108
+ * path: the request path, e.g. "/connect/v1/orders"; dryRun: true for a call with ?dry_run=1
85
109
  * @returns {Promise<Record<string, string>>}
86
110
  */
87
- export async function signRequest({ keyId, secret, body, timestamp = Math.floor(Date.now() / 1000), connector }) {
111
+ export async function signRequest({ keyId, secret, body, path, dryRun = false, timestamp = Math.floor(Date.now() / 1000), connector }) {
88
112
  if (typeof keyId !== 'string' || !KEY_ID_RE.test(keyId)) throw new TypeError('keyId must look like ck_ plus 24 hex characters');
89
- const signature = await computeSignature(secret, timestamp, body);
113
+ const signature = await computeSignature(secret, timestamp, body, { purpose: 'push', path, dryRun });
90
114
  return {
91
115
  'Content-Type': 'application/json',
92
116
  'X-ST-Key-Id': keyId,
93
117
  'X-ST-Timestamp': String(timestamp),
94
- 'X-ST-Signature': `v1=${signature}`,
118
+ 'X-ST-Signature': `v2=${signature}`,
95
119
  'X-ST-Connector': connectorHeader(connector),
96
120
  };
97
121
  }
98
122
 
123
+ /**
124
+ * Headers for a signed response of the shop's read API: same key and secret as for sending, signed over the
125
+ * body bytes before any compression. The shop sets them on every 200 response.
126
+ * @param {{keyId: string, secret: string, body: string|Uint8Array|ArrayBuffer, timestamp?: number}} opts
127
+ * @returns {Promise<Record<string, string>>}
128
+ */
129
+ export async function signResponse({ keyId, secret, body, timestamp = Math.floor(Date.now() / 1000) }) {
130
+ if (typeof keyId !== 'string' || !KEY_ID_RE.test(keyId)) throw new TypeError('keyId must look like ck_ plus 24 hex characters');
131
+ const signature = await computeSignature(secret, timestamp, body, { purpose: 'read' });
132
+ return {
133
+ 'Content-Type': 'application/json',
134
+ 'X-ST-Key-Id': keyId,
135
+ 'X-ST-Timestamp': String(timestamp),
136
+ 'X-ST-Signature': `v2=${signature}`,
137
+ };
138
+ }
139
+
140
+ function headerValue(headers, name) {
141
+ if (headers && typeof headers.get === 'function') return headers.get(name);
142
+ if (headers && typeof headers === 'object') {
143
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
144
+ return key === undefined ? null : headers[key];
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Checks a signed response the way ScaleTrail does: the key ID must be one of `keys`, then format, time window
151
+ * and constant-time comparison. Meant for the shop's own tests. Never throws; any problem is false.
152
+ * @param {{keys: Record<string, string>, headers: Headers|Record<string, string>, body: string|Uint8Array|ArrayBuffer, nowMs?: number}} opts
153
+ * keys: key ID to secret, both keys while a key change is under way
154
+ * @returns {Promise<boolean>}
155
+ */
156
+ export async function verifyResponse({ keys, headers, body, nowMs = Date.now() }) {
157
+ try {
158
+ const keyId = headerValue(headers, 'X-ST-Key-Id');
159
+ if (typeof keyId !== 'string' || !KEY_ID_RE.test(keyId) || !keys || !Object.hasOwn(keys, keyId)) return false;
160
+ return await verifySignature({
161
+ secret: keys[keyId],
162
+ timestamp: headerValue(headers, 'X-ST-Timestamp'),
163
+ body,
164
+ signature: headerValue(headers, 'X-ST-Signature'),
165
+ context: { purpose: 'read' },
166
+ nowMs,
167
+ });
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
99
173
  function hexEqual(a, b) {
100
174
  if (a.length !== b.length) return false;
101
175
  let diff = 0;
@@ -106,17 +180,18 @@ function hexEqual(a, b) {
106
180
  /**
107
181
  * Checks a signature the way ScaleTrail does: format, time window, constant-time comparison.
108
182
  * Meant for tests and mock servers. Never throws; any problem is false.
109
- * @param {{secret: string, timestamp: number|string, body: string|Uint8Array|ArrayBuffer, signature: string, nowMs?: number}} opts
183
+ * @param {{secret: string, timestamp: number|string, body: string|Uint8Array|ArrayBuffer, signature: string,
184
+ * context: {purpose: 'push', path: string, dryRun?: boolean} | {purpose: 'read'}, nowMs?: number}} opts
110
185
  * @returns {Promise<boolean>}
111
186
  */
112
- export async function verifySignature({ secret, timestamp, body, signature, nowMs = Date.now() }) {
187
+ export async function verifySignature({ secret, timestamp, body, signature, context, nowMs = Date.now() }) {
113
188
  try {
114
189
  const ts = typeof timestamp === 'string' && /^\d{10}$/.test(timestamp) ? Number(timestamp) : timestamp;
115
190
  if (!Number.isInteger(ts)) return false;
116
191
  if (Math.abs(Math.floor(nowMs / 1000) - ts) > TIMESTAMP_TOLERANCE_SECONDS) return false;
117
192
  const m = typeof signature === 'string' ? SIGNATURE_RE.exec(signature) : null;
118
193
  if (!m) return false;
119
- return hexEqual(await computeSignature(secret, ts, body), m[1]);
194
+ return hexEqual(await computeSignature(secret, ts, body, context), m[1]);
120
195
  } catch {
121
196
  return false;
122
197
  }
package/src/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Generated from the ScaleTrail Connector API v1 JSON Schema. Do not edit by hand.
2
- // Schema version 1.0.0-draft.6.
2
+ // Schema version 1.0.0-draft.9.
3
3
 
4
4
  export type Cents = number;
5
5
 
@@ -231,6 +231,8 @@ export interface EventAddToCart {
231
231
  currency: Currency;
232
232
  page_url?: string | null;
233
233
  client?: null | Client;
234
+ dedup_id?: Uuid;
235
+ consent?: null | Consent;
234
236
  }
235
237
 
236
238
  export interface EventLoginIdentity {
@@ -246,10 +248,10 @@ export interface EventLoginIdentity {
246
248
  }
247
249
 
248
250
  export interface Redact {
249
- customer_id: Id;
251
+ customer_id?: IdOrNull;
250
252
  email?: string | null;
251
253
  phone?: string | null;
252
- order_ids?: Id[];
254
+ order_ids?: OrderId[];
253
255
  requested_at: Datetime;
254
256
  }
255
257
 
package/src/validate.js CHANGED
@@ -140,9 +140,23 @@ export function validateMessage(route, message, { strict = true } = {}) {
140
140
  if (!def) throw new TypeError("route must be 'orders', 'events' or 'redact'");
141
141
  const errors = [];
142
142
  check({ $ref: `#/definitions/${def}` }, message, '', errors, strict);
143
+ if (errors.length === 0 && route === 'redact' && !redactHasIdentifier(/** @type {any} */ (message)?.redact)) {
144
+ errors.push('redact');
145
+ }
143
146
  return [...new Set(errors)].slice(0, MAX_PATHS);
144
147
  }
145
148
 
149
+ // A redact request needs at least one usable identifier: an email address (with "@"), a phone number in E.164
150
+ // (the schema checks the format), or a non-empty order_ids list. ScaleTrail finds the customer by these, not by
151
+ // the shop's customer ID. The schema cannot express "at least one", the API checks it. The API also rejects a
152
+ // phone number that is not valid for its country code.
153
+ function redactHasIdentifier(redact) {
154
+ if (!redact || typeof redact !== 'object') return false;
155
+ const email = typeof redact.email === 'string' && redact.email.trim().includes('@');
156
+ const phone = typeof redact.phone === 'string' && redact.phone.trim() !== '';
157
+ return email || phone || (Array.isArray(redact.order_ids) && redact.order_ids.length > 0);
158
+ }
159
+
146
160
  /** @param {unknown} message @param {{strict?: boolean}} [opts] */
147
161
  export const validateOrderMessage = (message, opts) => validateMessage('orders', message, opts);
148
162
  /** @param {unknown} message @param {{strict?: boolean}} [opts] */
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Kept in sync with package.json by a test.
2
- export const VERSION = '0.1.0';
2
+ export const VERSION = '0.3.0';