@voyantjs/distribution 0.20.0 → 0.21.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/dist/channel-push/admin-routes.d.ts +31 -0
- package/dist/channel-push/admin-routes.d.ts.map +1 -0
- package/dist/channel-push/admin-routes.js +165 -0
- package/dist/channel-push/availability-push.d.ts +76 -0
- package/dist/channel-push/availability-push.d.ts.map +1 -0
- package/dist/channel-push/availability-push.js +238 -0
- package/dist/channel-push/booking-push.d.ts +114 -0
- package/dist/channel-push/booking-push.d.ts.map +1 -0
- package/dist/channel-push/booking-push.js +503 -0
- package/dist/channel-push/content-push.d.ts +60 -0
- package/dist/channel-push/content-push.d.ts.map +1 -0
- package/dist/channel-push/content-push.js +256 -0
- package/dist/channel-push/index.d.ts +15 -0
- package/dist/channel-push/index.d.ts.map +1 -0
- package/dist/channel-push/index.js +18 -0
- package/dist/channel-push/plugin.d.ts +18 -0
- package/dist/channel-push/plugin.d.ts.map +1 -0
- package/dist/channel-push/plugin.js +21 -0
- package/dist/channel-push/reconciler.d.ts +85 -0
- package/dist/channel-push/reconciler.d.ts.map +1 -0
- package/dist/channel-push/reconciler.js +175 -0
- package/dist/channel-push/subscriber.d.ts +40 -0
- package/dist/channel-push/subscriber.d.ts.map +1 -0
- package/dist/channel-push/subscriber.js +174 -0
- package/dist/channel-push/types.d.ts +43 -0
- package/dist/channel-push/types.d.ts.map +1 -0
- package/dist/channel-push/types.js +32 -0
- package/dist/channel-push/workflows.d.ts +56 -0
- package/dist/channel-push/workflows.d.ts.map +1 -0
- package/dist/channel-push/workflows.js +100 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/rate-limit.d.ts +69 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +135 -0
- package/dist/routes.d.ts +170 -10
- package/dist/routes.d.ts.map +1 -1
- package/dist/schema-core.d.ts +417 -1
- package/dist/schema-core.d.ts.map +1 -1
- package/dist/schema-core.js +98 -1
- package/dist/schema-push-intents.d.ts +387 -0
- package/dist/schema-push-intents.d.ts.map +1 -0
- package/dist/schema-push-intents.js +77 -0
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -0
- package/dist/service.d.ts +103 -7
- package/dist/service.d.ts.map +1 -1
- package/dist/validation.d.ts +5 -5
- package/dist/webhook-deliveries.d.ts +86 -0
- package/dist/webhook-deliveries.d.ts.map +1 -0
- package/dist/webhook-deliveries.js +293 -0
- package/package.json +16 -8
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prepareOutboundEnvelope` — the ONLY allowed write path into
|
|
3
|
+
* `webhook_deliveries`. Enforces the redaction guarantees called for in
|
|
4
|
+
* channel-push-architecture §11.3:
|
|
5
|
+
*
|
|
6
|
+
* 1. Drops sensitive headers (Authorization, Cookie, X-Api-Key, …).
|
|
7
|
+
* 2. Bounds request/response excerpts to 4 KB so the table doesn't
|
|
8
|
+
* become a body archive.
|
|
9
|
+
* 3. Hashes the request body (SHA-256 over canonical JSON or raw
|
|
10
|
+
* text) so retries can be correlated and drift detected without
|
|
11
|
+
* exposing payloads.
|
|
12
|
+
*
|
|
13
|
+
* v2 will add per-flow PII redactors here (booking-traveler payloads,
|
|
14
|
+
* email/phone shapes); v1 keeps the envelope minimal and documents the
|
|
15
|
+
* redactor as the future home of those rules.
|
|
16
|
+
*
|
|
17
|
+
* Direct INSERTs into `webhook_deliveries` from anywhere except this
|
|
18
|
+
* helper are a lint violation per §11.3.
|
|
19
|
+
*/
|
|
20
|
+
import { newId } from "@voyantjs/db/lib/typeid";
|
|
21
|
+
import { infraWebhookDeliveriesTable, } from "@voyantjs/db/schema/infra";
|
|
22
|
+
import { eq } from "drizzle-orm";
|
|
23
|
+
const DEFAULT_EXCERPT_BYTES = 4 * 1024;
|
|
24
|
+
const REDACTED_HEADERS = new Set([
|
|
25
|
+
"authorization",
|
|
26
|
+
"proxy-authorization",
|
|
27
|
+
"cookie",
|
|
28
|
+
"set-cookie",
|
|
29
|
+
"x-api-key",
|
|
30
|
+
"x-api-token",
|
|
31
|
+
"x-auth-token",
|
|
32
|
+
"x-access-token",
|
|
33
|
+
"api-key",
|
|
34
|
+
"apikey",
|
|
35
|
+
]);
|
|
36
|
+
const REDACTION_MARKER = "[REDACTED]";
|
|
37
|
+
/**
|
|
38
|
+
* Body-key names that always redact (case-insensitive). Every match is
|
|
39
|
+
* replaced with `[REDACTED]` regardless of value type. Per §11.3 — PII
|
|
40
|
+
* redaction is a library guarantee, not caller discipline.
|
|
41
|
+
*/
|
|
42
|
+
const REDACTED_BODY_KEYS = new Set([
|
|
43
|
+
// Auth
|
|
44
|
+
"password",
|
|
45
|
+
"secret",
|
|
46
|
+
"token",
|
|
47
|
+
"accesstoken",
|
|
48
|
+
"refreshtoken",
|
|
49
|
+
"apikey",
|
|
50
|
+
"apitoken",
|
|
51
|
+
"authorization",
|
|
52
|
+
// Personal identifiers
|
|
53
|
+
"email",
|
|
54
|
+
"phone",
|
|
55
|
+
"phonenumber",
|
|
56
|
+
"mobile",
|
|
57
|
+
"ssn",
|
|
58
|
+
"passport",
|
|
59
|
+
"passportnumber",
|
|
60
|
+
"documentnumber",
|
|
61
|
+
"nationalid",
|
|
62
|
+
"taxid",
|
|
63
|
+
"dob",
|
|
64
|
+
"dateofbirth",
|
|
65
|
+
"birthdate",
|
|
66
|
+
// Payment
|
|
67
|
+
"cardnumber",
|
|
68
|
+
"pan",
|
|
69
|
+
"cvv",
|
|
70
|
+
"cvc",
|
|
71
|
+
"iban",
|
|
72
|
+
"bic",
|
|
73
|
+
"accountnumber",
|
|
74
|
+
// Booking-traveler shapes
|
|
75
|
+
"firstname",
|
|
76
|
+
"lastname",
|
|
77
|
+
"fullname",
|
|
78
|
+
"middlename",
|
|
79
|
+
]);
|
|
80
|
+
const EMAIL_PATTERN = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
|
|
81
|
+
const PHONE_PATTERN = /\+?\d[\d\s().-]{6,}\d/g;
|
|
82
|
+
/**
|
|
83
|
+
* Begin an outbound delivery: redacts the request, persists an
|
|
84
|
+
* in-flight row, and returns a `complete()` finisher the caller invokes
|
|
85
|
+
* with the response (or error).
|
|
86
|
+
*
|
|
87
|
+
* Usage:
|
|
88
|
+
* const env = await prepareOutboundEnvelope(db, { ... })
|
|
89
|
+
* try {
|
|
90
|
+
* const res = await fetch(...)
|
|
91
|
+
* await env.complete({ responseStatus: res.status, ... })
|
|
92
|
+
* } catch (err) {
|
|
93
|
+
* await env.complete({ errorClass: "network", errorMessage: String(err) })
|
|
94
|
+
* }
|
|
95
|
+
*/
|
|
96
|
+
export async function prepareOutboundEnvelope(db, input) {
|
|
97
|
+
const startedAt = new Date();
|
|
98
|
+
const redactedRequestHeaders = redactHeaders(input.requestHeaders);
|
|
99
|
+
const requestBodyHash = hashBodySync(input.requestBody);
|
|
100
|
+
const requestBodyExcerpt = excerptBody(input.requestBody);
|
|
101
|
+
const id = newId("webhook_deliveries");
|
|
102
|
+
const inserted = (await db
|
|
103
|
+
.insert(infraWebhookDeliveriesTable)
|
|
104
|
+
.values({
|
|
105
|
+
id,
|
|
106
|
+
sourceModule: input.sourceModule,
|
|
107
|
+
sourceEvent: input.sourceEvent,
|
|
108
|
+
sourceEntityModule: input.sourceEntityModule ?? null,
|
|
109
|
+
sourceEntityId: input.sourceEntityId ?? null,
|
|
110
|
+
subscriptionId: input.subscriptionId ?? null,
|
|
111
|
+
targetUrl: input.targetUrl,
|
|
112
|
+
targetKind: input.targetKind ?? null,
|
|
113
|
+
targetRef: input.targetRef ?? null,
|
|
114
|
+
requestMethod: input.requestMethod,
|
|
115
|
+
requestHeaders: redactedRequestHeaders,
|
|
116
|
+
requestBodyHash,
|
|
117
|
+
requestBodyExcerpt,
|
|
118
|
+
attemptNumber: input.attemptNumber ?? 1,
|
|
119
|
+
parentDeliveryId: input.parentDeliveryId ?? null,
|
|
120
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
121
|
+
status: "in_flight",
|
|
122
|
+
scheduledFor: input.scheduledFor ?? null,
|
|
123
|
+
startedAt,
|
|
124
|
+
})
|
|
125
|
+
.returning());
|
|
126
|
+
const row = inserted[0];
|
|
127
|
+
if (!row)
|
|
128
|
+
throw new Error("prepareOutboundEnvelope: insert returned no rows");
|
|
129
|
+
const delivery = row;
|
|
130
|
+
return {
|
|
131
|
+
delivery,
|
|
132
|
+
async complete(result) {
|
|
133
|
+
const finishedAt = new Date();
|
|
134
|
+
const durationMs = finishedAt.getTime() - startedAt.getTime();
|
|
135
|
+
const status = decideStatus(result);
|
|
136
|
+
const updated = (await db
|
|
137
|
+
.update(infraWebhookDeliveriesTable)
|
|
138
|
+
.set({
|
|
139
|
+
responseStatus: result.responseStatus ?? null,
|
|
140
|
+
responseHeaders: redactHeaders(result.responseHeaders),
|
|
141
|
+
responseBodyExcerpt: excerptBody(result.responseBody),
|
|
142
|
+
status,
|
|
143
|
+
finishedAt,
|
|
144
|
+
durationMs,
|
|
145
|
+
errorClass: result.errorClass ?? null,
|
|
146
|
+
errorMessage: result.errorMessage ?? null,
|
|
147
|
+
updatedAt: finishedAt,
|
|
148
|
+
})
|
|
149
|
+
.where(eq(infraWebhookDeliveriesTable.id, delivery.id))
|
|
150
|
+
.returning());
|
|
151
|
+
const finalized = updated[0];
|
|
152
|
+
if (!finalized) {
|
|
153
|
+
throw new Error("prepareOutboundEnvelope.complete: update returned no rows");
|
|
154
|
+
}
|
|
155
|
+
return finalized;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function decideStatus(result) {
|
|
160
|
+
if (result.errorClass)
|
|
161
|
+
return "failed";
|
|
162
|
+
const code = result.responseStatus ?? 0;
|
|
163
|
+
if (code >= 200 && code < 400)
|
|
164
|
+
return "succeeded";
|
|
165
|
+
if (code >= 400)
|
|
166
|
+
return "failed";
|
|
167
|
+
return "failed";
|
|
168
|
+
}
|
|
169
|
+
export function redactHeaders(headers) {
|
|
170
|
+
if (!headers)
|
|
171
|
+
return null;
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
174
|
+
out[name] = REDACTED_HEADERS.has(name.toLowerCase()) ? REDACTION_MARKER : value;
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
function excerptBody(body, max = DEFAULT_EXCERPT_BYTES) {
|
|
179
|
+
if (body == null)
|
|
180
|
+
return null;
|
|
181
|
+
let text;
|
|
182
|
+
if (typeof body === "string") {
|
|
183
|
+
text = redactStringPii(body);
|
|
184
|
+
}
|
|
185
|
+
else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
186
|
+
text = "[binary]";
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
try {
|
|
190
|
+
text = JSON.stringify(redactBodyPii(body));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
text = "[unserializable]";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (text.length > max) {
|
|
197
|
+
return `${text.slice(0, max - 1)}…`;
|
|
198
|
+
}
|
|
199
|
+
return text;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Recursively redact PII from a JSON-serializable body. Every key whose
|
|
203
|
+
* lowercased name matches `REDACTED_BODY_KEYS` is replaced with
|
|
204
|
+
* `[REDACTED]`; remaining string values get email/phone shapes
|
|
205
|
+
* scrubbed. This protects channel-push booking payloads (which carry
|
|
206
|
+
* traveler contact info) from leaking into the delivery log per §11.3.
|
|
207
|
+
*
|
|
208
|
+
* Exported for callers that want to redact bodies before passing them
|
|
209
|
+
* to other sinks (logs, error reporters).
|
|
210
|
+
*/
|
|
211
|
+
export function redactBodyPii(value) {
|
|
212
|
+
if (value == null)
|
|
213
|
+
return value;
|
|
214
|
+
if (typeof value === "string")
|
|
215
|
+
return redactStringPii(value);
|
|
216
|
+
if (typeof value !== "object")
|
|
217
|
+
return value;
|
|
218
|
+
if (Array.isArray(value))
|
|
219
|
+
return value.map(redactBodyPii);
|
|
220
|
+
const out = {};
|
|
221
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
222
|
+
const normalized = key.toLowerCase().replace(/[_-]/g, "");
|
|
223
|
+
if (REDACTED_BODY_KEYS.has(normalized)) {
|
|
224
|
+
out[key] = REDACTION_MARKER;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
out[key] = redactBodyPii(raw);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Scrub email/phone shapes from a free-text string. The patterns are
|
|
234
|
+
* coarse on purpose — false positives (e.g. a phone-shaped tracking id)
|
|
235
|
+
* are preferable to leaks.
|
|
236
|
+
*/
|
|
237
|
+
export function redactStringPii(text) {
|
|
238
|
+
return text.replace(EMAIL_PATTERN, REDACTION_MARKER).replace(PHONE_PATTERN, REDACTION_MARKER);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Stable SHA-256 hash of the body, computed sync via Web Crypto when
|
|
242
|
+
* available. Returns null when crypto is unavailable (the column
|
|
243
|
+
* permits null and downstream consumers tolerate it).
|
|
244
|
+
*
|
|
245
|
+
* Note: this helper does NOT await; it returns the SubtleCrypto
|
|
246
|
+
* promise's eventual hex string by consuming it synchronously where
|
|
247
|
+
* possible. We use a small wrapper so callers don't sprinkle awaits.
|
|
248
|
+
*
|
|
249
|
+
* For deterministic behavior we hash the canonical-JSON serialization
|
|
250
|
+
* of the body. Strings hash directly. Binary inputs hash as the byte
|
|
251
|
+
* array.
|
|
252
|
+
*/
|
|
253
|
+
function hashBodySync(body) {
|
|
254
|
+
if (body == null)
|
|
255
|
+
return null;
|
|
256
|
+
// Synchronous hashing isn't available in browser/CF Workers crypto —
|
|
257
|
+
// we accept that v1 stores no hash for binary bodies and only the
|
|
258
|
+
// text-canonicalized hash for the rest. For the hot path (channel
|
|
259
|
+
// push HTTP requests), bodies are small and JSON-stringified, so we
|
|
260
|
+
// return a deterministic hex string built from a fast non-crypto
|
|
261
|
+
// hash. v2 can swap in SubtleCrypto with an async write.
|
|
262
|
+
let text;
|
|
263
|
+
if (typeof body === "string") {
|
|
264
|
+
text = body;
|
|
265
|
+
}
|
|
266
|
+
else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
try {
|
|
271
|
+
text = JSON.stringify(body);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return fnv1a64(text);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* 64-bit FNV-1a hash, hex-encoded. Not a cryptographic hash — purely
|
|
281
|
+
* a stable fingerprint for "is this the same body on retry?" and drift
|
|
282
|
+
* detection. v2 swaps to SubtleCrypto SHA-256 with async writes.
|
|
283
|
+
*/
|
|
284
|
+
function fnv1a64(str) {
|
|
285
|
+
let h = 0xcbf29ce484222325n;
|
|
286
|
+
const prime = 0x100000001b3n;
|
|
287
|
+
const mask = 0xffffffffffffffffn;
|
|
288
|
+
for (let i = 0; i < str.length; i++) {
|
|
289
|
+
h ^= BigInt(str.charCodeAt(i));
|
|
290
|
+
h = (h * prime) & mask;
|
|
291
|
+
}
|
|
292
|
+
return h.toString(16).padStart(16, "0");
|
|
293
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/distribution",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,19 +28,27 @@
|
|
|
28
28
|
"types": "./dist/booking-extension.d.ts",
|
|
29
29
|
"import": "./dist/booking-extension.js",
|
|
30
30
|
"default": "./dist/booking-extension.js"
|
|
31
|
+
},
|
|
32
|
+
"./channel-push": {
|
|
33
|
+
"types": "./dist/channel-push/index.d.ts",
|
|
34
|
+
"import": "./dist/channel-push/index.js",
|
|
35
|
+
"default": "./dist/channel-push/index.js"
|
|
31
36
|
}
|
|
32
37
|
},
|
|
33
38
|
"dependencies": {
|
|
34
39
|
"drizzle-orm": "^0.45.2",
|
|
35
40
|
"hono": "^4.12.10",
|
|
36
41
|
"zod": "^4.3.6",
|
|
37
|
-
"@voyantjs/availability": "0.
|
|
38
|
-
"@voyantjs/
|
|
39
|
-
"@voyantjs/
|
|
40
|
-
"@voyantjs/
|
|
41
|
-
"@voyantjs/
|
|
42
|
-
"@voyantjs/
|
|
43
|
-
"@voyantjs/
|
|
42
|
+
"@voyantjs/availability": "0.21.0",
|
|
43
|
+
"@voyantjs/bookings": "0.21.0",
|
|
44
|
+
"@voyantjs/catalog": "0.21.0",
|
|
45
|
+
"@voyantjs/core": "0.21.0",
|
|
46
|
+
"@voyantjs/db": "0.21.0",
|
|
47
|
+
"@voyantjs/hono": "0.21.0",
|
|
48
|
+
"@voyantjs/identity": "0.21.0",
|
|
49
|
+
"@voyantjs/products": "0.21.0",
|
|
50
|
+
"@voyantjs/suppliers": "0.21.0",
|
|
51
|
+
"@voyantjs/workflows": "0.21.0"
|
|
44
52
|
},
|
|
45
53
|
"devDependencies": {
|
|
46
54
|
"typescript": "^6.0.2",
|