@scaletrail/connector 0.0.0-stage → 0.1.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 +16 -0
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/examples/customer-redact.json +9 -0
- package/examples/event-add-to-cart.json +17 -0
- package/examples/event-login-identity.json +13 -0
- package/examples/order-marketplace.json +21 -0
- package/examples/order-migrated-update.json +22 -0
- package/examples/order-subscription-renewal.json +44 -0
- package/examples/order-web-paid.json +73 -0
- package/examples/order-web-refund-partial.json +154 -0
- package/package.json +43 -4
- package/src/client.js +143 -0
- package/src/cookies.js +251 -0
- package/src/index.d.ts +109 -0
- package/src/index.js +18 -0
- package/src/schema.js +1110 -0
- package/src/signature.js +123 -0
- package/src/types.d.ts +256 -0
- package/src/types.js +2 -0
- package/src/uuid.js +38 -0
- package/src/validate.js +154 -0
- package/src/version.js +2 -0
package/src/signature.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Request signing: HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex, sent as "v1=<hex>".
|
|
2
|
+
// Uses Web Crypto only, so it runs unchanged in Cloudflare Workers, Deno, Bun and Node 20+.
|
|
3
|
+
|
|
4
|
+
import { VERSION } from './version.js';
|
|
5
|
+
|
|
6
|
+
export const TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
7
|
+
|
|
8
|
+
const KEY_ID_RE = /^ck_[0-9a-f]{24}$/;
|
|
9
|
+
const SIGNATURE_RE = /^v1=([0-9a-f]{64})$/;
|
|
10
|
+
const CONNECTOR_RE = /^[\x21-\x7e][\x20-\x7e]{0,119}$/;
|
|
11
|
+
const enc = new TextEncoder();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string|Uint8Array|ArrayBuffer} body
|
|
15
|
+
* @returns {Uint8Array}
|
|
16
|
+
*/
|
|
17
|
+
export function toBytes(body) {
|
|
18
|
+
if (typeof body === 'string') return enc.encode(body);
|
|
19
|
+
if (body instanceof Uint8Array) return body;
|
|
20
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
|
21
|
+
throw new TypeError('body must be a string, Uint8Array or ArrayBuffer');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toHex(buf) {
|
|
25
|
+
return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertSecret(secret) {
|
|
29
|
+
if (typeof secret !== 'string' || secret.length === 0) throw new TypeError('secret must be a non-empty string');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The signing secret belongs on the shop's server. A page that could sign requests would hand the
|
|
33
|
+
// secret to every visitor.
|
|
34
|
+
function assertServerSide() {
|
|
35
|
+
if (typeof globalThis.window !== 'undefined' && typeof globalThis.document !== 'undefined') {
|
|
36
|
+
throw new Error('signing must run on the server, never in a browser');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertTimestamp(timestamp) {
|
|
41
|
+
if (!Number.isInteger(timestamp) || timestamp < 1e9 || timestamp >= 1e10) {
|
|
42
|
+
throw new TypeError('timestamp must be Unix seconds with 10 digits');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Signature of a request body as 64 lower-case hex characters.
|
|
48
|
+
* @param {string} secret signing secret
|
|
49
|
+
* @param {number} timestamp Unix seconds
|
|
50
|
+
* @param {string|Uint8Array|ArrayBuffer} body the exact bytes that are sent
|
|
51
|
+
* @returns {Promise<string>}
|
|
52
|
+
*/
|
|
53
|
+
export async function computeSignature(secret, timestamp, body) {
|
|
54
|
+
assertServerSide();
|
|
55
|
+
assertSecret(secret);
|
|
56
|
+
assertTimestamp(timestamp);
|
|
57
|
+
const raw = toBytes(body);
|
|
58
|
+
const prefix = enc.encode(`${timestamp}.`);
|
|
59
|
+
const msg = new Uint8Array(prefix.length + raw.length);
|
|
60
|
+
msg.set(prefix, 0);
|
|
61
|
+
msg.set(raw, prefix.length);
|
|
62
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
63
|
+
'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
|
|
64
|
+
);
|
|
65
|
+
return toHex(await globalThis.crypto.subtle.sign('HMAC', key, msg));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Value for the X-ST-Connector header: the shop's own name and version plus this package.
|
|
70
|
+
* @param {string} [name] e.g. "my-shop/2.3.0"
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function connectorHeader(name) {
|
|
74
|
+
const own = `st-connector/${VERSION}`;
|
|
75
|
+
const value = name ? `${name} ${own}` : own;
|
|
76
|
+
if (value.length > 120 || !CONNECTOR_RE.test(value)) {
|
|
77
|
+
throw new TypeError('connector name must be printable ASCII, at most 120 characters in total');
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Headers for a signed request.
|
|
84
|
+
* @param {{keyId: string, secret: string, body: string|Uint8Array|ArrayBuffer, timestamp?: number, connector?: string}} opts
|
|
85
|
+
* @returns {Promise<Record<string, string>>}
|
|
86
|
+
*/
|
|
87
|
+
export async function signRequest({ keyId, secret, body, timestamp = Math.floor(Date.now() / 1000), connector }) {
|
|
88
|
+
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);
|
|
90
|
+
return {
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
'X-ST-Key-Id': keyId,
|
|
93
|
+
'X-ST-Timestamp': String(timestamp),
|
|
94
|
+
'X-ST-Signature': `v1=${signature}`,
|
|
95
|
+
'X-ST-Connector': connectorHeader(connector),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hexEqual(a, b) {
|
|
100
|
+
if (a.length !== b.length) return false;
|
|
101
|
+
let diff = 0;
|
|
102
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
103
|
+
return diff === 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Checks a signature the way ScaleTrail does: format, time window, constant-time comparison.
|
|
108
|
+
* 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
|
|
110
|
+
* @returns {Promise<boolean>}
|
|
111
|
+
*/
|
|
112
|
+
export async function verifySignature({ secret, timestamp, body, signature, nowMs = Date.now() }) {
|
|
113
|
+
try {
|
|
114
|
+
const ts = typeof timestamp === 'string' && /^\d{10}$/.test(timestamp) ? Number(timestamp) : timestamp;
|
|
115
|
+
if (!Number.isInteger(ts)) return false;
|
|
116
|
+
if (Math.abs(Math.floor(nowMs / 1000) - ts) > TIMESTAMP_TOLERANCE_SECONDS) return false;
|
|
117
|
+
const m = typeof signature === 'string' ? SIGNATURE_RE.exec(signature) : null;
|
|
118
|
+
if (!m) return false;
|
|
119
|
+
return hexEqual(await computeSignature(secret, ts, body), m[1]);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Generated from the ScaleTrail Connector API v1 JSON Schema. Do not edit by hand.
|
|
2
|
+
// Schema version 1.0.0-draft.6.
|
|
3
|
+
|
|
4
|
+
export type Cents = number;
|
|
5
|
+
|
|
6
|
+
export type CentsOrNull = number | null;
|
|
7
|
+
|
|
8
|
+
export type Datetime = string;
|
|
9
|
+
|
|
10
|
+
export type DatetimeMs = string;
|
|
11
|
+
|
|
12
|
+
export type DatetimeOrNull = string | null;
|
|
13
|
+
|
|
14
|
+
export type Id = string;
|
|
15
|
+
|
|
16
|
+
export type IdOrNull = string | null;
|
|
17
|
+
|
|
18
|
+
export type OrderId = string;
|
|
19
|
+
|
|
20
|
+
export type RefundId = string;
|
|
21
|
+
|
|
22
|
+
export type Trailiq = string | null;
|
|
23
|
+
|
|
24
|
+
export type Text = string | null;
|
|
25
|
+
|
|
26
|
+
export type Uuid = string | null;
|
|
27
|
+
|
|
28
|
+
export type ClickId = string | null;
|
|
29
|
+
|
|
30
|
+
export type Currency = string;
|
|
31
|
+
|
|
32
|
+
export type Country = string | null;
|
|
33
|
+
|
|
34
|
+
export type Percent = number | null;
|
|
35
|
+
|
|
36
|
+
export interface OrderMessage {
|
|
37
|
+
order: Order;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EventMessage {
|
|
41
|
+
event: EventAddToCart | EventLoginIdentity;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RedactMessage {
|
|
45
|
+
redact: Redact;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Order {
|
|
49
|
+
schema_version: "1";
|
|
50
|
+
order_id: OrderId;
|
|
51
|
+
order_number?: IdOrNull;
|
|
52
|
+
origin?: "native" | "migrated";
|
|
53
|
+
created_at: Datetime;
|
|
54
|
+
updated_at: DatetimeMs;
|
|
55
|
+
paid_at?: DatetimeOrNull;
|
|
56
|
+
status: "pending" | "processing" | "on-hold" | "completed" | "cancelled" | "refunded" | "failed";
|
|
57
|
+
sales_channel: string;
|
|
58
|
+
is_test: boolean;
|
|
59
|
+
currency: Currency;
|
|
60
|
+
language?: string | null;
|
|
61
|
+
totals: Totals;
|
|
62
|
+
items: Item[];
|
|
63
|
+
discounts?: Discount[];
|
|
64
|
+
payments?: Payment[];
|
|
65
|
+
shipments?: Shipment[];
|
|
66
|
+
refunds?: Refund[];
|
|
67
|
+
subscription?: null | Subscription;
|
|
68
|
+
customer: Customer;
|
|
69
|
+
billing_address?: null | Address;
|
|
70
|
+
shipping_address?: null | Address;
|
|
71
|
+
attribution?: null | Attribution;
|
|
72
|
+
client?: null | Client;
|
|
73
|
+
consent?: null | Consent;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Totals {
|
|
77
|
+
total_cents: Cents;
|
|
78
|
+
subtotal_cents?: CentsOrNull;
|
|
79
|
+
shipping_cents?: CentsOrNull;
|
|
80
|
+
discount_cents?: CentsOrNull;
|
|
81
|
+
tax_cents?: CentsOrNull;
|
|
82
|
+
taxes_included?: boolean | null;
|
|
83
|
+
tax_lines?: {
|
|
84
|
+
rate: number;
|
|
85
|
+
base_cents?: CentsOrNull;
|
|
86
|
+
tax_cents: Cents;
|
|
87
|
+
}[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Item {
|
|
91
|
+
line_id: Id;
|
|
92
|
+
product_id: Id;
|
|
93
|
+
variant_id?: IdOrNull;
|
|
94
|
+
sku?: string | null;
|
|
95
|
+
name: string;
|
|
96
|
+
quantity: number;
|
|
97
|
+
unit_price_cents: Cents;
|
|
98
|
+
line_total_cents: Cents;
|
|
99
|
+
line_discount_cents?: CentsOrNull;
|
|
100
|
+
tax_rate?: Percent;
|
|
101
|
+
unit_cost_cents?: CentsOrNull;
|
|
102
|
+
category?: Text;
|
|
103
|
+
is_subscription?: boolean | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface Discount {
|
|
107
|
+
source: "coupon" | "rule" | "loyalty_points" | "tier" | "gift_card" | "manual";
|
|
108
|
+
amount_cents: Cents;
|
|
109
|
+
code?: string | null;
|
|
110
|
+
rule_id?: IdOrNull;
|
|
111
|
+
label?: Text;
|
|
112
|
+
line_ids?: Id[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface Payment {
|
|
116
|
+
gateway_id: string;
|
|
117
|
+
method?: Text;
|
|
118
|
+
amount_cents: Cents;
|
|
119
|
+
fee_cents?: CentsOrNull;
|
|
120
|
+
fee_source?: "provider" | "estimated" | null;
|
|
121
|
+
status?: "authorized" | "captured" | "failed" | "refunded" | null;
|
|
122
|
+
captured_at?: DatetimeOrNull;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface Shipment {
|
|
126
|
+
shipment_id: Id;
|
|
127
|
+
method?: string | null;
|
|
128
|
+
postage_cents?: CentsOrNull;
|
|
129
|
+
weight_grams?: number | null;
|
|
130
|
+
created_at?: DatetimeOrNull;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface Refund {
|
|
134
|
+
refund_id: RefundId;
|
|
135
|
+
created_at: Datetime;
|
|
136
|
+
amount_cents: Cents;
|
|
137
|
+
tax_cents?: CentsOrNull;
|
|
138
|
+
shipping_cents?: CentsOrNull;
|
|
139
|
+
reason?: Text;
|
|
140
|
+
items?: {
|
|
141
|
+
line_id: Id;
|
|
142
|
+
quantity: number;
|
|
143
|
+
amount_cents?: CentsOrNull;
|
|
144
|
+
}[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface Subscription {
|
|
148
|
+
subscription_id: Id;
|
|
149
|
+
is_renewal: boolean;
|
|
150
|
+
renewal_number?: number | null;
|
|
151
|
+
status?: "active" | "paused" | "cancelled" | "payment_failed" | null;
|
|
152
|
+
interval?: string | null;
|
|
153
|
+
started_at?: DatetimeOrNull;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface Customer {
|
|
157
|
+
customer_id: Id;
|
|
158
|
+
is_guest?: boolean | null;
|
|
159
|
+
email?: string | null;
|
|
160
|
+
phone?: string | null;
|
|
161
|
+
first_name?: Text;
|
|
162
|
+
last_name?: Text;
|
|
163
|
+
order_count?: number | null;
|
|
164
|
+
first_order_at?: DatetimeOrNull;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface Address {
|
|
168
|
+
street?: Text;
|
|
169
|
+
postcode?: string | null;
|
|
170
|
+
city?: Text;
|
|
171
|
+
region?: Text;
|
|
172
|
+
country?: Country;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface Attribution {
|
|
176
|
+
sa_uid?: Uuid;
|
|
177
|
+
click_ids?: {
|
|
178
|
+
gclid?: ClickId;
|
|
179
|
+
gbraid?: ClickId;
|
|
180
|
+
wbraid?: ClickId;
|
|
181
|
+
fbclid?: ClickId;
|
|
182
|
+
ttclid?: ClickId;
|
|
183
|
+
msclkid?: ClickId;
|
|
184
|
+
oppref?: ClickId;
|
|
185
|
+
trailiq?: Trailiq;
|
|
186
|
+
epik?: ClickId;
|
|
187
|
+
srsltid?: ClickId;
|
|
188
|
+
};
|
|
189
|
+
utm?: {
|
|
190
|
+
source?: string | null;
|
|
191
|
+
medium?: string | null;
|
|
192
|
+
campaign?: string | null;
|
|
193
|
+
content?: string | null;
|
|
194
|
+
term?: string | null;
|
|
195
|
+
id?: string | null;
|
|
196
|
+
};
|
|
197
|
+
landing_url?: string | null;
|
|
198
|
+
referrer?: string | null;
|
|
199
|
+
browser_ids?: {
|
|
200
|
+
ga?: string | null;
|
|
201
|
+
fbp?: string | null;
|
|
202
|
+
fbc?: string | null;
|
|
203
|
+
ttp?: string | null;
|
|
204
|
+
};
|
|
205
|
+
captured_at?: DatetimeOrNull;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface Client {
|
|
209
|
+
ip?: string | null;
|
|
210
|
+
user_agent?: string | null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface Consent {
|
|
214
|
+
source: "cookiebot" | "tcf" | "custom" | "none";
|
|
215
|
+
statistics?: boolean | null;
|
|
216
|
+
marketing?: boolean | null;
|
|
217
|
+
captured_at?: DatetimeOrNull;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface EventAddToCart {
|
|
221
|
+
type: "add_to_cart";
|
|
222
|
+
event_id: string;
|
|
223
|
+
occurred_at: Datetime;
|
|
224
|
+
sa_uid?: Uuid;
|
|
225
|
+
product_id: Id;
|
|
226
|
+
variant_id?: IdOrNull;
|
|
227
|
+
sku?: string | null;
|
|
228
|
+
name?: Text;
|
|
229
|
+
quantity: number;
|
|
230
|
+
unit_price_cents: Cents;
|
|
231
|
+
currency: Currency;
|
|
232
|
+
page_url?: string | null;
|
|
233
|
+
client?: null | Client;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface EventLoginIdentity {
|
|
237
|
+
type: "login_identity";
|
|
238
|
+
event_id: Id;
|
|
239
|
+
occurred_at: Datetime;
|
|
240
|
+
sa_uid?: Uuid;
|
|
241
|
+
customer_id: Id;
|
|
242
|
+
email?: string | null;
|
|
243
|
+
phone?: string | null;
|
|
244
|
+
first_name?: Text;
|
|
245
|
+
last_name?: Text;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface Redact {
|
|
249
|
+
customer_id: Id;
|
|
250
|
+
email?: string | null;
|
|
251
|
+
phone?: string | null;
|
|
252
|
+
order_ids?: Id[];
|
|
253
|
+
requested_at: Datetime;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export type ConnectorMessage = OrderMessage | EventMessage | RedactMessage;
|
package/src/types.js
ADDED
package/src/uuid.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Visitor IDs: UUID v7 (RFC 9562) for new IDs, v4 and v7 accepted when reading.
|
|
2
|
+
//
|
|
3
|
+
// A UUID v7 starts with a 48-bit millisecond timestamp, so it is predictable in part. Use it for
|
|
4
|
+
// visitor IDs only, never as a secret or token.
|
|
5
|
+
|
|
6
|
+
const VISITOR_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* New UUID v7.
|
|
10
|
+
* @param {number} [nowMs] Unix time in milliseconds (default: now)
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function uuidV7(nowMs = Date.now()) {
|
|
14
|
+
const tsHex = BigInt(Math.floor(nowMs)).toString(16).padStart(12, '0').slice(-12);
|
|
15
|
+
const rand = globalThis.crypto.getRandomValues(new Uint8Array(10));
|
|
16
|
+
rand[0] = (rand[0] & 0x0f) | 0x70; // version 7
|
|
17
|
+
rand[2] = (rand[2] & 0x3f) | 0x80; // variant 10xx
|
|
18
|
+
const r = Array.from(rand, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
19
|
+
return `${tsHex.slice(0, 8)}-${tsHex.slice(8, 12)}-${r.slice(0, 4)}-${r.slice(4, 8)}-${r.slice(8, 20)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True for a UUID v4 or v7 in canonical form (any case).
|
|
24
|
+
* @param {unknown} value
|
|
25
|
+
* @returns {value is string}
|
|
26
|
+
*/
|
|
27
|
+
export function isVisitorId(value) {
|
|
28
|
+
return typeof value === 'string' && value.length === 36 && VISITOR_ID_RE.test(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Visitor ID in the form the API expects (lower case), or null if invalid.
|
|
33
|
+
* @param {unknown} value
|
|
34
|
+
* @returns {string|null}
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeVisitorId(value) {
|
|
37
|
+
return isVisitorId(value) ? value.toLowerCase() : null;
|
|
38
|
+
}
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Validates messages against the Connector API v1 JSON Schema, with the same rules the API applies.
|
|
2
|
+
// Supports exactly the JSON Schema draft-07 keywords the schema uses; loading fails on any other.
|
|
3
|
+
// Results are field paths only, e.g. "order.items[0].quantity", never values.
|
|
4
|
+
//
|
|
5
|
+
// strict: unknown fields are errors (the API does this for ?dry_run=1). Without strict, unknown
|
|
6
|
+
// fields are ignored, as the API does for live requests.
|
|
7
|
+
|
|
8
|
+
import { SCHEMA } from './schema.js';
|
|
9
|
+
|
|
10
|
+
const MAX_PATHS = 50;
|
|
11
|
+
|
|
12
|
+
const MESSAGE_DEFS = Object.freeze({
|
|
13
|
+
orders: 'order_message',
|
|
14
|
+
events: 'event_message',
|
|
15
|
+
redact: 'redact_message',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const SUPPORTED = new Set([
|
|
19
|
+
'$schema', '$id', '$ref', 'title', 'description', 'version', 'definitions',
|
|
20
|
+
'type', 'enum', 'pattern', 'minLength', 'maxLength', 'minimum', 'maximum', 'required',
|
|
21
|
+
'properties', 'additionalProperties', 'items', 'minItems', 'maxItems', 'oneOf',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function assertSupportedSchema(schema) {
|
|
25
|
+
(function walk(node, path, inProps) {
|
|
26
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
|
|
27
|
+
for (const [k, v] of Object.entries(node)) {
|
|
28
|
+
if (!inProps && !SUPPORTED.has(k)) throw new Error(`schema keyword not supported: '${k}' at ${path}`);
|
|
29
|
+
if (k === 'properties' || k === 'definitions') {
|
|
30
|
+
for (const [name, sub] of Object.entries(v)) walk(sub, `${path}/${k}/${name}`, false);
|
|
31
|
+
} else if (k === 'oneOf' || (k === 'items' && Array.isArray(v))) {
|
|
32
|
+
v.forEach((sub, i) => walk(sub, `${path}/${k}/${i}`, false));
|
|
33
|
+
} else if (k === 'items' || k === 'additionalProperties') {
|
|
34
|
+
walk(v, `${path}/${k}`, false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
})(schema, '#', false);
|
|
38
|
+
}
|
|
39
|
+
assertSupportedSchema(SCHEMA);
|
|
40
|
+
|
|
41
|
+
const regexCache = new Map();
|
|
42
|
+
function regexFor(pattern) {
|
|
43
|
+
let re = regexCache.get(pattern);
|
|
44
|
+
if (!re) {
|
|
45
|
+
re = new RegExp(pattern);
|
|
46
|
+
regexCache.set(pattern, re);
|
|
47
|
+
}
|
|
48
|
+
return re;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolve(ref) {
|
|
52
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) throw new Error('only local $ref is supported');
|
|
53
|
+
return ref.slice(2).split('/').reduce((node, key) => node?.[key], SCHEMA);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function typeOf(v) {
|
|
57
|
+
if (v === null) return 'null';
|
|
58
|
+
if (Array.isArray(v)) return 'array';
|
|
59
|
+
if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';
|
|
60
|
+
return typeof v;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function typeMatches(actual, wanted) {
|
|
64
|
+
return wanted === actual || (wanted === 'number' && actual === 'integer');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fieldPath(base, key) {
|
|
68
|
+
return base ? `${base}.${key}` : key;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function check(node, value, path, errors, strict) {
|
|
72
|
+
if (node.$ref) return check(resolve(node.$ref), value, path, errors, strict);
|
|
73
|
+
if (node.oneOf) {
|
|
74
|
+
const results = node.oneOf.map((sub) => {
|
|
75
|
+
const e = [];
|
|
76
|
+
check(sub, value, path, e, strict);
|
|
77
|
+
return e;
|
|
78
|
+
});
|
|
79
|
+
const hits = results.filter((e) => e.length === 0).length;
|
|
80
|
+
if (hits === 1) return;
|
|
81
|
+
if (hits === 0) {
|
|
82
|
+
// Report the closest branch so the path points at the field, not at its parent. A branch
|
|
83
|
+
// that already fails on its own type counts as further away.
|
|
84
|
+
const own = path || '$';
|
|
85
|
+
const score = (e) => e.length + (e.length === 1 && e[0] === own ? 0.5 : 0);
|
|
86
|
+
errors.push(...results.reduce((a, b) => (score(b) < score(a) ? b : a)));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
errors.push(path || '$');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const actual = typeOf(value);
|
|
93
|
+
if (node.type) {
|
|
94
|
+
const wanted = Array.isArray(node.type) ? node.type : [node.type];
|
|
95
|
+
if (!wanted.some((w) => typeMatches(actual, w))) {
|
|
96
|
+
errors.push(path || '$');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (node.enum && !node.enum.includes(value)) {
|
|
101
|
+
errors.push(path || '$');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (actual === 'string') {
|
|
105
|
+
// Length before pattern: an oversized value never reaches the regex.
|
|
106
|
+
if (node.maxLength !== undefined && value.length > node.maxLength) { errors.push(path || '$'); return; }
|
|
107
|
+
if (node.minLength !== undefined && value.length < node.minLength) { errors.push(path || '$'); return; }
|
|
108
|
+
if (node.pattern && !regexFor(node.pattern).test(value)) { errors.push(path || '$'); return; }
|
|
109
|
+
}
|
|
110
|
+
if (actual === 'integer' || actual === 'number') {
|
|
111
|
+
if (node.minimum !== undefined && value < node.minimum) { errors.push(path || '$'); return; }
|
|
112
|
+
if (node.maximum !== undefined && value > node.maximum) { errors.push(path || '$'); return; }
|
|
113
|
+
}
|
|
114
|
+
if (actual === 'object') {
|
|
115
|
+
for (const key of node.required || []) {
|
|
116
|
+
if (!Object.hasOwn(value, key)) errors.push(fieldPath(path, key));
|
|
117
|
+
}
|
|
118
|
+
const props = node.properties || {};
|
|
119
|
+
for (const [key, v] of Object.entries(value)) {
|
|
120
|
+
if (Object.hasOwn(props, key)) check(props[key], v, fieldPath(path, key), errors, strict);
|
|
121
|
+
else if (strict && node.additionalProperties === false) errors.push(fieldPath(path, key));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (actual === 'array') {
|
|
125
|
+
if (node.minItems !== undefined && value.length < node.minItems) { errors.push(path || '$'); return; }
|
|
126
|
+
if (node.maxItems !== undefined && value.length > node.maxItems) { errors.push(path || '$'); return; }
|
|
127
|
+
if (node.items) value.forEach((v, i) => check(node.items, v, `${path}[${i}]`, errors, strict));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Field paths that are invalid for a route. Empty array means valid. At most 50, no duplicates.
|
|
133
|
+
* @param {'orders'|'events'|'redact'} route
|
|
134
|
+
* @param {unknown} message parsed JSON body, e.g. {order: {...}}
|
|
135
|
+
* @param {{strict?: boolean}} [opts]
|
|
136
|
+
* @returns {string[]}
|
|
137
|
+
*/
|
|
138
|
+
export function validateMessage(route, message, { strict = true } = {}) {
|
|
139
|
+
const def = MESSAGE_DEFS[route];
|
|
140
|
+
if (!def) throw new TypeError("route must be 'orders', 'events' or 'redact'");
|
|
141
|
+
const errors = [];
|
|
142
|
+
check({ $ref: `#/definitions/${def}` }, message, '', errors, strict);
|
|
143
|
+
return [...new Set(errors)].slice(0, MAX_PATHS);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @param {unknown} message @param {{strict?: boolean}} [opts] */
|
|
147
|
+
export const validateOrderMessage = (message, opts) => validateMessage('orders', message, opts);
|
|
148
|
+
/** @param {unknown} message @param {{strict?: boolean}} [opts] */
|
|
149
|
+
export const validateEventMessage = (message, opts) => validateMessage('events', message, opts);
|
|
150
|
+
/** @param {unknown} message @param {{strict?: boolean}} [opts] */
|
|
151
|
+
export const validateRedactMessage = (message, opts) => validateMessage('redact', message, opts);
|
|
152
|
+
|
|
153
|
+
/** Schema version this package validates against. */
|
|
154
|
+
export const SCHEMA_VERSION = SCHEMA.version;
|
package/src/version.js
ADDED