@scaletrail/connector 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/README.md +49 -4
- package/examples/customer-redact.json +9 -9
- package/examples/event-add-to-cart.json +17 -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 +3 -4
- package/src/signature.js +87 -12
- package/src/types.d.ts +3 -3
- package/src/validate.js +11 -0
- package/src/version.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.2.0 (11.11.2026)
|
|
4
|
+
|
|
5
|
+
- **Signatur `v2`, nicht kompatibel mit 0.1.0.** Signiert werden Zweck, Zeitstempel, Pfad, Testmodus und Body.
|
|
6
|
+
`signRequest()` verlangt `path` (z.B. `/connect/v1/orders`) und nimmt `dryRun`; `computeSignature()` und
|
|
7
|
+
`verifySignature()` nehmen einen `context` (`{purpose: 'push', path, dryRun}` oder `{purpose: 'read'}`). Eine
|
|
8
|
+
Signatur von 0.1.0 (`v1=`) nimmt die API nicht mehr an.
|
|
9
|
+
- `validateMessage('redact', …)`: `customer_id` ist optional, mindestens eines von `email`, `phone` und `order_ids`
|
|
10
|
+
ist Pflicht, `order_ids` im Format von `order_id`. Schema-Version `1.0.0-draft.7`.
|
|
11
|
+
- Beispiele unter `examples/` um 52 Wochen zurückdatiert, damit sie die Grenze für `updated_at` (höchstens 300 s in
|
|
12
|
+
der Zukunft) bestehen.
|
|
13
|
+
- `signResponse()`: signiert eine Antwort der Lese-API des Shops mit Schlüssel und Secret des Sendens.
|
|
14
|
+
- `verifyResponse()`: prüft eine solche Antwort wie ScaleTrail, auch mit zwei Schlüsseln während eines Wechsels.
|
|
15
|
+
- README: Abschnitt Lese-API; weitere Auslöser für einen neuen Stand (Zahlung, Gebühr, Porto, verworfene Sendung).
|
|
16
|
+
- Beispiel `examples/order-migrated-update.json`: `refund_id` ist die ID der Erstattung im alten Shop (`1201`), so wie
|
|
17
|
+
das frühere Shopsystem sie an ScaleTrail meldete.
|
|
18
|
+
|
|
19
|
+
## 0.1.0 (10.11.2026)
|
|
4
20
|
|
|
5
21
|
Erste Version.
|
|
6
22
|
|
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:
|
|
@@ -133,6 +134,44 @@ const file = createRequire(import.meta.url).resolve('@scaletrail/connector/examp
|
|
|
133
134
|
const example = JSON.parse(await readFile(file, 'utf8'));
|
|
134
135
|
```
|
|
135
136
|
|
|
137
|
+
## Lese-API für den Abgleich
|
|
138
|
+
|
|
139
|
+
ScaleTrail holt jede Nacht die geänderten Bestellungen beim Shop ab und gleicht sie mit dem ab, was angekommen ist.
|
|
140
|
+
Dafür stellt der Shop drei Routen unter einer eigenen Basis-URL auf seiner Domain bereit (die URL hinterlegt
|
|
141
|
+
ScaleTrail):
|
|
142
|
+
|
|
143
|
+
| Route | Inhalt |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `GET {base}/orders?updated_since=<Zeit>&limit=<1..100>&cursor=<c>` | Stände nach `updated_at` aufsteigend, bei Gleichstand nach `order_id` |
|
|
146
|
+
| `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` |
|
|
147
|
+
| `GET {base}/orders/{order_id}` | eine Bestellung, sonst `404` |
|
|
148
|
+
|
|
149
|
+
- Zeiten in UTC mit Millisekunden und `Z`, z.B. `2026-12-03T08:42:05.318Z`; `updated_since` und `created_since`
|
|
150
|
+
einschliesslich, `created_until` ausschliesslich, verglichen mit der vollen Genauigkeit der eigenen Zeitstempel.
|
|
151
|
+
- Antwort `{"as_of": "<Zeit der Datenbank>", "orders": [ … ], "next_cursor": "<c>" | null}`, Einzelroute
|
|
152
|
+
`{"as_of": …, "order": { … }}`. Jeder Stand **genau** wie beim Senden, aus derselben Funktion; dabei auch Test-,
|
|
153
|
+
übernommene und unbezahlte Bestellungen.
|
|
154
|
+
- Cursor als Keyset über `(updated_at, order_id)` bzw. `(created_at, order_id)`, nie ein Versatz; ohne Zustand im
|
|
155
|
+
Shop, mindestens 7 Tage gültig, an die Filter gebunden. `limit` darf zwischen Seiten wechseln. Ein ungültiger oder
|
|
156
|
+
abgelaufener Cursor ist `400` mit `{"error": "invalid_cursor"}`.
|
|
157
|
+
- Anmeldung mit `Authorization: Bearer <Lese-Schlüssel>`, einem eigenen Geheimnis des Shops, nur für diese Routen.
|
|
158
|
+
`401` bzw. `403` nur bei falschem Zugang, nie bei einer vorübergehenden Sperre (dann `429` oder `503`).
|
|
159
|
+
- ScaleTrail fragt eine Seite nach der anderen, höchstens 2 Anfragen je Sekunde.
|
|
160
|
+
|
|
161
|
+
**Jede `200` signiert der Shop**, mit derselben Schlüssel-ID und demselben Secret wie beim Senden, über den Body ohne
|
|
162
|
+
Kompression:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
import { signResponse } from '@scaletrail/connector';
|
|
166
|
+
|
|
167
|
+
const body = JSON.stringify({ as_of, orders, next_cursor });
|
|
168
|
+
const headers = await signResponse({ keyId: env.SCALETRAIL_KEY_ID, secret: env.SCALETRAIL_SECRET, body });
|
|
169
|
+
return new Response(body, { status: 200, headers });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Ohne gültige Signatur verarbeitet ScaleTrail keinen Stand der Antwort. Für die eigenen Tests prüft
|
|
173
|
+
`verifyResponse({ keys: { [keyId]: secret }, headers, body })` eine Antwort so, wie ScaleTrail es tut.
|
|
174
|
+
|
|
136
175
|
## Signatur ohne Client
|
|
137
176
|
|
|
138
177
|
Wer einen eigenen HTTP-Client nutzt:
|
|
@@ -141,11 +180,17 @@ Wer einen eigenen HTTP-Client nutzt:
|
|
|
141
180
|
import { signRequest } from '@scaletrail/connector';
|
|
142
181
|
|
|
143
182
|
const body = JSON.stringify({ order });
|
|
144
|
-
const headers = await signRequest({ keyId, secret, body, connector: 'mein-shop/2.3.0' });
|
|
183
|
+
const headers = await signRequest({ keyId, secret, body, path: '/connect/v1/orders', connector: 'mein-shop/2.3.0' });
|
|
145
184
|
await fetch('https://api.scaletrail.io/connect/v1/orders', { method: 'POST', headers, body, redirect: 'manual' });
|
|
146
185
|
```
|
|
147
186
|
|
|
148
|
-
Signiert wird der Body Byte für Byte so, wie er gesendet wird: nach dem Signieren nicht neu serialisieren.
|
|
187
|
+
Signiert wird der Body Byte für Byte so, wie er gesendet wird: nach dem Signieren nicht neu serialisieren. Die
|
|
188
|
+
Signatur (`v2=`) deckt auch den Pfad und den Testmodus: für einen Aufruf mit `?dry_run=1` gehört `dryRun: true` in
|
|
189
|
+
`signRequest`. Eine Signatur gilt damit nur für genau diesen Weg und Modus. Als Query ist nur `?dry_run=1` erlaubt,
|
|
190
|
+
jede andere ergibt `400` mit `{"error": "invalid_query"}`.
|
|
191
|
+
|
|
192
|
+
`updated_at` darf höchstens 300 Sekunden nach der Uhr von ScaleTrail liegen, sonst ist der Stand ungültig (`400`).
|
|
193
|
+
Die Uhr des Shops-Servers sollte deshalb per NTP gestellt sein.
|
|
149
194
|
|
|
150
195
|
## Lizenz
|
|
151
196
|
|
|
@@ -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,17 @@
|
|
|
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
|
+
}
|
|
17
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
{
|
|
2
|
-
"event": {
|
|
3
|
-
"type": "login_identity",
|
|
4
|
-
"event_id": "login_c-58210_2026-12-03",
|
|
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": "login_c-58210_2026-12-03",
|
|
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.7",
|
|
7
7
|
"oneOf": [
|
|
8
8
|
{
|
|
9
9
|
"$ref": "#/definitions/order_message"
|
|
@@ -1073,12 +1073,11 @@ export const SCHEMA = Object.freeze({
|
|
|
1073
1073
|
"type": "object",
|
|
1074
1074
|
"additionalProperties": false,
|
|
1075
1075
|
"required": [
|
|
1076
|
-
"customer_id",
|
|
1077
1076
|
"requested_at"
|
|
1078
1077
|
],
|
|
1079
1078
|
"properties": {
|
|
1080
1079
|
"customer_id": {
|
|
1081
|
-
"$ref": "#/definitions/
|
|
1080
|
+
"$ref": "#/definitions/id_or_null"
|
|
1082
1081
|
},
|
|
1083
1082
|
"email": {
|
|
1084
1083
|
"type": [
|
|
@@ -1098,7 +1097,7 @@ export const SCHEMA = Object.freeze({
|
|
|
1098
1097
|
"type": "array",
|
|
1099
1098
|
"maxItems": 1000,
|
|
1100
1099
|
"items": {
|
|
1101
|
-
"$ref": "#/definitions/
|
|
1100
|
+
"$ref": "#/definitions/order_id"
|
|
1102
1101
|
}
|
|
1103
1102
|
},
|
|
1104
1103
|
"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.7.
|
|
3
3
|
|
|
4
4
|
export type Cents = number;
|
|
5
5
|
|
|
@@ -246,10 +246,10 @@ export interface EventLoginIdentity {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
export interface Redact {
|
|
249
|
-
customer_id
|
|
249
|
+
customer_id?: IdOrNull;
|
|
250
250
|
email?: string | null;
|
|
251
251
|
phone?: string | null;
|
|
252
|
-
order_ids?:
|
|
252
|
+
order_ids?: OrderId[];
|
|
253
253
|
requested_at: Datetime;
|
|
254
254
|
}
|
|
255
255
|
|
package/src/validate.js
CHANGED
|
@@ -140,9 +140,20 @@ 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 of email, phone or a non-empty order_ids list: ScaleTrail finds the
|
|
150
|
+
// customer by these, not by the shop's customer ID. The schema cannot express "at least one", the API checks it.
|
|
151
|
+
function redactHasIdentifier(redact) {
|
|
152
|
+
if (!redact || typeof redact !== 'object') return false;
|
|
153
|
+
const filled = (v) => typeof v === 'string' && v.trim() !== '';
|
|
154
|
+
return filled(redact.email) || filled(redact.phone) || (Array.isArray(redact.order_ids) && redact.order_ids.length > 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
146
157
|
/** @param {unknown} message @param {{strict?: boolean}} [opts] */
|
|
147
158
|
export const validateOrderMessage = (message, opts) => validateMessage('orders', message, opts);
|
|
148
159
|
/** @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.2.0';
|