@voyantjs/distribution 0.19.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.
Files changed (54) hide show
  1. package/dist/channel-push/admin-routes.d.ts +31 -0
  2. package/dist/channel-push/admin-routes.d.ts.map +1 -0
  3. package/dist/channel-push/admin-routes.js +165 -0
  4. package/dist/channel-push/availability-push.d.ts +76 -0
  5. package/dist/channel-push/availability-push.d.ts.map +1 -0
  6. package/dist/channel-push/availability-push.js +238 -0
  7. package/dist/channel-push/booking-push.d.ts +114 -0
  8. package/dist/channel-push/booking-push.d.ts.map +1 -0
  9. package/dist/channel-push/booking-push.js +503 -0
  10. package/dist/channel-push/content-push.d.ts +60 -0
  11. package/dist/channel-push/content-push.d.ts.map +1 -0
  12. package/dist/channel-push/content-push.js +256 -0
  13. package/dist/channel-push/index.d.ts +15 -0
  14. package/dist/channel-push/index.d.ts.map +1 -0
  15. package/dist/channel-push/index.js +18 -0
  16. package/dist/channel-push/plugin.d.ts +18 -0
  17. package/dist/channel-push/plugin.d.ts.map +1 -0
  18. package/dist/channel-push/plugin.js +21 -0
  19. package/dist/channel-push/reconciler.d.ts +85 -0
  20. package/dist/channel-push/reconciler.d.ts.map +1 -0
  21. package/dist/channel-push/reconciler.js +175 -0
  22. package/dist/channel-push/subscriber.d.ts +40 -0
  23. package/dist/channel-push/subscriber.d.ts.map +1 -0
  24. package/dist/channel-push/subscriber.js +174 -0
  25. package/dist/channel-push/types.d.ts +43 -0
  26. package/dist/channel-push/types.d.ts.map +1 -0
  27. package/dist/channel-push/types.js +32 -0
  28. package/dist/channel-push/workflows.d.ts +56 -0
  29. package/dist/channel-push/workflows.d.ts.map +1 -0
  30. package/dist/channel-push/workflows.js +100 -0
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +3 -0
  34. package/dist/rate-limit.d.ts +69 -0
  35. package/dist/rate-limit.d.ts.map +1 -0
  36. package/dist/rate-limit.js +135 -0
  37. package/dist/routes.d.ts +170 -10
  38. package/dist/routes.d.ts.map +1 -1
  39. package/dist/schema-core.d.ts +417 -1
  40. package/dist/schema-core.d.ts.map +1 -1
  41. package/dist/schema-core.js +98 -1
  42. package/dist/schema-push-intents.d.ts +387 -0
  43. package/dist/schema-push-intents.d.ts.map +1 -0
  44. package/dist/schema-push-intents.js +77 -0
  45. package/dist/schema.d.ts +1 -0
  46. package/dist/schema.d.ts.map +1 -1
  47. package/dist/schema.js +1 -0
  48. package/dist/service.d.ts +103 -7
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/validation.d.ts +5 -5
  51. package/dist/webhook-deliveries.d.ts +86 -0
  52. package/dist/webhook-deliveries.d.ts.map +1 -0
  53. package/dist/webhook-deliveries.js +293 -0
  54. 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.19.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.19.0",
38
- "@voyantjs/core": "0.19.0",
39
- "@voyantjs/db": "0.19.0",
40
- "@voyantjs/hono": "0.19.0",
41
- "@voyantjs/identity": "0.19.0",
42
- "@voyantjs/products": "0.19.0",
43
- "@voyantjs/suppliers": "0.19.0"
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",