@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 +32 -1
- package/README.md +66 -4
- package/examples/customer-redact.json +9 -9
- package/examples/event-add-to-cart.json +19 -17
- package/examples/event-login-identity.json +13 -13
- package/examples/order-marketplace.json +21 -21
- package/examples/order-migrated-update.json +22 -22
- package/examples/order-subscription-renewal.json +44 -44
- package/examples/order-web-paid.json +7 -7
- package/examples/order-web-refund-partial.json +9 -9
- package/package.json +1 -1
- package/src/client.js +7 -3
- package/src/index.d.ts +24 -1
- package/src/index.js +1 -1
- package/src/schema.js +24 -8
- package/src/signature.js +87 -12
- package/src/types.d.ts +5 -3
- package/src/validate.js +14 -0
- package/src/version.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
5
|
-
"occurred_at": "
|
|
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": "
|
|
7
|
-
"updated_at": "
|
|
8
|
-
"paid_at": "
|
|
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": "
|
|
7
|
-
"updated_at": "
|
|
8
|
-
"paid_at": "
|
|
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": "
|
|
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": "
|
|
6
|
-
"updated_at": "
|
|
7
|
-
"paid_at": "
|
|
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": "
|
|
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": "
|
|
7
|
-
"updated_at": "
|
|
8
|
-
"paid_at": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
7
|
-
"updated_at": "
|
|
8
|
-
"paid_at": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
151
|
+
"captured_at": "2025-12-04T09:40:58.000+01:00"
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
}
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { signRequest, toBytes } from './signature.js';
|
|
5
5
|
|
|
6
|
-
|
|
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
|
-
|
|
120
|
-
const
|
|
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
|
-
|
|
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
|
+
"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/
|
|
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/
|
|
1117
|
+
"$ref": "#/definitions/order_id"
|
|
1102
1118
|
}
|
|
1103
1119
|
},
|
|
1104
1120
|
"requested_at": {
|
package/src/signature.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
//
|
|
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 = /^
|
|
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
|
-
*
|
|
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(
|
|
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': `
|
|
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,
|
|
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.
|
|
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
|
|
251
|
+
customer_id?: IdOrNull;
|
|
250
252
|
email?: string | null;
|
|
251
253
|
phone?: string | null;
|
|
252
|
-
order_ids?:
|
|
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.
|
|
2
|
+
export const VERSION = '0.3.0';
|