@fastpix/fastpix-node 2.0.7 → 2.0.8

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 (64) hide show
  1. package/README.md +92 -4
  2. package/dist/commonjs/index.d.ts +1 -0
  3. package/dist/commonjs/index.d.ts.map +1 -1
  4. package/dist/commonjs/index.js +1 -0
  5. package/dist/commonjs/index.js.map +1 -1
  6. package/dist/commonjs/lib/config.d.ts +13 -2
  7. package/dist/commonjs/lib/config.d.ts.map +1 -1
  8. package/dist/commonjs/lib/config.js +2 -2
  9. package/dist/commonjs/lib/config.js.map +1 -1
  10. package/dist/commonjs/lib/env.d.ts +1 -0
  11. package/dist/commonjs/lib/env.d.ts.map +1 -1
  12. package/dist/commonjs/lib/env.js +1 -0
  13. package/dist/commonjs/lib/env.js.map +1 -1
  14. package/dist/commonjs/lib/sdks.d.ts.map +1 -1
  15. package/dist/commonjs/lib/sdks.js +9 -1
  16. package/dist/commonjs/lib/sdks.js.map +1 -1
  17. package/dist/commonjs/sdk/sdk.d.ts +3 -0
  18. package/dist/commonjs/sdk/sdk.d.ts.map +1 -1
  19. package/dist/commonjs/sdk/sdk.js +11 -0
  20. package/dist/commonjs/sdk/sdk.js.map +1 -1
  21. package/dist/commonjs/sdk/webhooks.d.ts +151 -0
  22. package/dist/commonjs/sdk/webhooks.d.ts.map +1 -0
  23. package/dist/commonjs/sdk/webhooks.js +139 -0
  24. package/dist/commonjs/sdk/webhooks.js.map +1 -0
  25. package/dist/commonjs/types/primitives.d.ts +1 -1
  26. package/dist/commonjs/types/primitives.d.ts.map +1 -1
  27. package/dist/commonjs/types/primitives.js +7 -3
  28. package/dist/commonjs/types/primitives.js.map +1 -1
  29. package/dist/esm/index.d.ts +1 -0
  30. package/dist/esm/index.d.ts.map +1 -1
  31. package/dist/esm/index.js +1 -0
  32. package/dist/esm/index.js.map +1 -1
  33. package/dist/esm/lib/config.d.ts +13 -2
  34. package/dist/esm/lib/config.d.ts.map +1 -1
  35. package/dist/esm/lib/config.js +2 -2
  36. package/dist/esm/lib/config.js.map +1 -1
  37. package/dist/esm/lib/env.d.ts +1 -0
  38. package/dist/esm/lib/env.d.ts.map +1 -1
  39. package/dist/esm/lib/env.js +1 -0
  40. package/dist/esm/lib/env.js.map +1 -1
  41. package/dist/esm/lib/sdks.d.ts.map +1 -1
  42. package/dist/esm/lib/sdks.js +9 -1
  43. package/dist/esm/lib/sdks.js.map +1 -1
  44. package/dist/esm/sdk/sdk.d.ts +3 -0
  45. package/dist/esm/sdk/sdk.d.ts.map +1 -1
  46. package/dist/esm/sdk/sdk.js +11 -0
  47. package/dist/esm/sdk/sdk.js.map +1 -1
  48. package/dist/esm/sdk/webhooks.d.ts +151 -0
  49. package/dist/esm/sdk/webhooks.d.ts.map +1 -0
  50. package/dist/esm/sdk/webhooks.js +134 -0
  51. package/dist/esm/sdk/webhooks.js.map +1 -0
  52. package/dist/esm/types/primitives.d.ts +1 -1
  53. package/dist/esm/types/primitives.d.ts.map +1 -1
  54. package/dist/esm/types/primitives.js +7 -3
  55. package/dist/esm/types/primitives.js.map +1 -1
  56. package/examples/webhooksServer.example.ts +93 -0
  57. package/package.json +3 -4
  58. package/src/index.ts +1 -0
  59. package/src/lib/config.ts +14 -2
  60. package/src/lib/env.ts +2 -2
  61. package/src/lib/sdks.ts +9 -1
  62. package/src/sdk/sdk.ts +8 -0
  63. package/src/sdk/webhooks.ts +329 -0
  64. package/src/types/primitives.ts +12 -7
@@ -0,0 +1,329 @@
1
+ /*
2
+ * FastPix webhook verification and unwrapping.
3
+ *
4
+ * Unlike the other files under `src/sdk/`, this resource is hand-written rather
5
+ * than code-generated, but it follows the exact same conventions: it extends the
6
+ * shared `ClientSDK` base class and reads its configuration from `this._options`
7
+ * (here, the webhook signing secret resolved by the base constructor).
8
+ *
9
+ * Signature scheme (verified against a live FastPix delivery):
10
+ * - The signature travels in the `FastPix-Signature` header (read lowercase as
11
+ * `fastpix-signature`). It is a SINGLE base64 value — there is no `t=`/`v1=`
12
+ * structure to parse. A real digest looks like
13
+ * "oeDnZHgmhQ3UJ7qUw7uJAzo0O3Dbulfr0w89eoy0lVA=" (44 base64 chars => 32-byte
14
+ * HMAC-SHA256 output).
15
+ * - The signing secret is itself base64-encoded; decode it with
16
+ * `Buffer.from(secret, "base64")` and use those raw bytes as the HMAC key.
17
+ * - The HMAC-SHA256 is computed over the RAW REQUEST BODY ONLY (no timestamp
18
+ * prefix) and the digest is encoded as base64 (not hex).
19
+ * - No timestamp is signed, so there is no replay/tolerance window to enforce.
20
+ * Callers MUST instead dedupe on the top-level event `id` for idempotency.
21
+ */
22
+
23
+ /// <reference types="node" />
24
+
25
+ import { createHmac, timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
26
+ import { ClientSDK } from "../lib/sdks.js";
27
+ import type { CreateLiveStreamResponseDTO } from "../models/createlivestreamresponsedto.js";
28
+ import type { Media } from "../models/media.js";
29
+ import { Buffer } from "node:buffer";
30
+
31
+ /**
32
+ * The raw, unparsed webhook request body. Always pass the bytes exactly as they
33
+ * arrived on the wire — verification fails if the body was re-serialized.
34
+ */
35
+ export type WebhookRawBody = string | Buffer | Uint8Array;
36
+
37
+ /**
38
+ * Inbound request headers. Accepts a WHATWG `Headers` instance or a plain object
39
+ * such as Node's `IncomingHttpHeaders` (values may be `string` or `string[]`).
40
+ */
41
+ export type WebhookHeaders =
42
+ | Headers
43
+ | Record<string, string | string[] | undefined>;
44
+
45
+ /** The `object` envelope field: the resource this event is about. */
46
+ export interface WebhookEventObject {
47
+ /** The resource type, e.g. "media" or "live-stream". */
48
+ type: string;
49
+ /** The affected resource id (equal to `data.id`). */
50
+ id: string;
51
+ }
52
+
53
+ /** The `workspace` envelope field. */
54
+ export interface WebhookWorkspace {
55
+ id: string;
56
+ name: string;
57
+ }
58
+
59
+ /** Every webhook event type whose `data` payload is a {@link Media}. */
60
+ export type MediaWebhookEventType =
61
+ | "video.media.created"
62
+ | "video.media.updated"
63
+ | "video.media.ready"
64
+ | "video.media.failed"
65
+ | "video.media.deleted"
66
+ | "video.media.track.created"
67
+ | "video.media.track.ready"
68
+ | "video.media.track.updated"
69
+ | "video.media.track.deleted"
70
+ | "video.media.upload.cancelled"
71
+ | "video.media.subtitle.generated.ready"
72
+ | "video.media.source.ready"
73
+ | "video.media.source.deleted"
74
+ | "video.media.mp4Support.ready";
75
+
76
+ /**
77
+ * Every webhook event type whose `data` payload is a
78
+ * {@link CreateLiveStreamResponseDTO}.
79
+ */
80
+ export type LiveStreamWebhookEventType =
81
+ | "video.live_stream.created"
82
+ | "video.live_stream.updated"
83
+ | "video.live_stream.deleted"
84
+ | "video.live_stream.simulcast_target.updated"
85
+ | "video.live_stream.simulcast_target.deleted";
86
+
87
+ /** Union of all known FastPix webhook event `type` strings. */
88
+ export type WebhookEventType =
89
+ | MediaWebhookEventType
90
+ | LiveStreamWebhookEventType;
91
+
92
+ /**
93
+ * A verified FastPix webhook event.
94
+ *
95
+ * Route on `type`, dedupe on the top-level `id` (the idempotency key — NOT
96
+ * `object.id`), and read the affected resource id from `object.id`
97
+ * (== `data.id`). `data` carries the full entity payload.
98
+ *
99
+ * Two type parameters, both with sensible defaults:
100
+ * - `TData` — the shape of `data` (defaults to a loose record).
101
+ * - `TType` — the literal `type` (defaults to any `string`).
102
+ *
103
+ * Most callers don't use these directly: {@link Webhooks.unwrap} returns the
104
+ * discriminated {@link FastpixWebhookEvent} union, which narrows both for you.
105
+ */
106
+ export interface WebhookEvent<
107
+ TData = Record<string, unknown>,
108
+ TType extends string = string,
109
+ > {
110
+ /** Routing key, e.g. "video.media.updated". */
111
+ type: TType;
112
+ /** The resource this event concerns. */
113
+ object: WebhookEventObject;
114
+ /** Event id — the idempotency key. Dedupe on this. */
115
+ id: string;
116
+ /** The workspace that produced the event. */
117
+ workspace: WebhookWorkspace;
118
+ /** Coarse status string, e.g. "media_created". */
119
+ status: string;
120
+ /** Full entity payload (has its own id/status/playbackIds/tracks/...). */
121
+ data: TData;
122
+ /** ISO-8601 timestamp string. */
123
+ createdAt: string;
124
+ /** Delivery attempt metadata. */
125
+ attempts: unknown[];
126
+ }
127
+
128
+ /** A `video.media.*` event. `data` is a {@link Media}. */
129
+ export type MediaWebhookEvent = WebhookEvent<Media, MediaWebhookEventType>;
130
+
131
+ /**
132
+ * A `video.live_stream.*` event. `data` is a
133
+ * {@link CreateLiveStreamResponseDTO}.
134
+ */
135
+ export type LiveStreamWebhookEvent = WebhookEvent<
136
+ CreateLiveStreamResponseDTO,
137
+ LiveStreamWebhookEventType
138
+ >;
139
+
140
+ /**
141
+ * The discriminated union of every known FastPix webhook event. This is what
142
+ * {@link Webhooks.unwrap} returns: `switch (event.type)` narrows `event.data`
143
+ * to the right payload type automatically.
144
+ *
145
+ * ```ts
146
+ * const event = fastpix.webhooks.unwrap(body, headers);
147
+ * switch (event.type) {
148
+ * case "video.media.ready":
149
+ * event.data.playbackIds; // ✅ typed as Media
150
+ * break;
151
+ * case "video.live_stream.created":
152
+ * event.data.streamKey; // ✅ typed as CreateLiveStreamResponseDTO
153
+ * break;
154
+ * }
155
+ * ```
156
+ */
157
+ export type FastpixWebhookEvent = MediaWebhookEvent | LiveStreamWebhookEvent;
158
+
159
+ /**
160
+ * Thrown when a webhook cannot be verified. Catch this to return `400` (bad
161
+ * signature / malformed input) versus `500` (unexpected server error).
162
+ */
163
+ export class WebhookVerificationError extends Error {
164
+ constructor(message: string, options?: { cause?: unknown }) {
165
+ super(message, options);
166
+ this.name = "WebhookVerificationError";
167
+ }
168
+ }
169
+
170
+ /** Header name we read, always lower-cased per the HTTP spec. */
171
+ const SIGNATURE_HEADER = "fastpix-signature";
172
+
173
+ /**
174
+ * Webhooks resource — verifies and unwraps inbound FastPix webhook deliveries.
175
+ *
176
+ * Accessed as `fastpix.webhooks`. The signing secret defaults to the client's
177
+ * `webhookSecret` option (which itself falls back to
178
+ * `process.env.FASTPIX_WEBHOOK_SECRET`), and can be overridden per call.
179
+ */
180
+ export class Webhooks extends ClientSDK {
181
+ /**
182
+ * Verify the signature and return the parsed event.
183
+ *
184
+ * This is the single function most integrations need: hand it the raw body and
185
+ * request headers and it returns a typed {@link WebhookEvent}, throwing
186
+ * {@link WebhookVerificationError} if anything is wrong.
187
+ *
188
+ * @param body The raw, unparsed request body (string or Buffer/Uint8Array).
189
+ * @param headers The inbound request headers.
190
+ * @param secret Optional override for the webhook signing secret. Defaults to
191
+ * the client's `webhookSecret` option.
192
+ * @returns The verified, parsed webhook event.
193
+ */
194
+ unwrap(
195
+ body: WebhookRawBody,
196
+ headers: WebhookHeaders,
197
+ secret?: string | null,
198
+ ): FastpixWebhookEvent;
199
+ /**
200
+ * Escape hatch: supply your own `data` shape (e.g. for an event type the SDK
201
+ * doesn't model yet) by passing an explicit type argument.
202
+ */
203
+ unwrap<TData>(
204
+ body: WebhookRawBody,
205
+ headers: WebhookHeaders,
206
+ secret?: string | null,
207
+ ): WebhookEvent<TData>;
208
+ unwrap(
209
+ body: WebhookRawBody,
210
+ headers: WebhookHeaders,
211
+ secret?: string | null,
212
+ ): unknown {
213
+ this.verifySignature(body, headers, secret);
214
+
215
+ const raw = typeof body === "string" ? body : Buffer.from(body).toString("utf8");
216
+ try {
217
+ return JSON.parse(raw);
218
+ } catch (cause) {
219
+ throw new WebhookVerificationError(
220
+ "Webhook signature verified but the body is not valid JSON.",
221
+ { cause },
222
+ );
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Verify the signature without parsing. Throws {@link WebhookVerificationError}
228
+ * on any failure (missing secret, parsed-instead-of-raw body, missing header,
229
+ * or signature mismatch). Returns `void` on success.
230
+ */
231
+ verifySignature(
232
+ body: WebhookRawBody,
233
+ headers: WebhookHeaders,
234
+ secret?: string | null,
235
+ ): void {
236
+ // 1. Resolve the signing secret. Explicit arg wins, then the client option.
237
+ const signingSecret = secret ?? this._options.webhookSecret;
238
+ if (!signingSecret) {
239
+ throw new WebhookVerificationError(
240
+ "Missing webhook secret. Pass one to unwrap()/verifySignature(), set the "
241
+ + "`webhookSecret` client option, or set the FASTPIX_WEBHOOK_SECRET "
242
+ + "environment variable.",
243
+ );
244
+ }
245
+
246
+ // 2. The body MUST be the raw bytes. A plain object means the caller already
247
+ // JSON.parsed it, which destroys the exact bytes the signature covers.
248
+ if (
249
+ typeof body !== "string"
250
+ && !Buffer.isBuffer(body)
251
+ && !(body instanceof Uint8Array)
252
+ ) {
253
+ throw new WebhookVerificationError(
254
+ "Webhook body must be the raw request payload as a string or Buffer. It "
255
+ + "looks like the body was already parsed into an object — configure your "
256
+ + "framework to expose the raw body (e.g. express.raw({ type: "
257
+ + "'application/json' })).",
258
+ );
259
+ }
260
+
261
+ // 3. Pull the signature header (single base64 value, no `t=`/`v1=` parts).
262
+ const provided = this.extractSignature(headers);
263
+ if (!provided) {
264
+ throw new WebhookVerificationError(
265
+ `Missing "FastPix-Signature" header on the webhook request.`,
266
+ );
267
+ }
268
+
269
+ // 4. Compute the expected signature over the raw body and compare in
270
+ // constant time.
271
+ const expected = this.computeSignature(body, signingSecret);
272
+ if (!this.timingSafeEqual(provided, expected)) {
273
+ throw new WebhookVerificationError(
274
+ "Webhook signature mismatch. The payload may have been tampered with, or "
275
+ + "a different signing secret was used.",
276
+ );
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Compute the base64 HMAC-SHA256 of the raw payload using the base64-decoded
282
+ * secret as the key. Matches FastPix's signing scheme exactly.
283
+ */
284
+ private computeSignature(
285
+ payload: WebhookRawBody,
286
+ secret: string,
287
+ ): string {
288
+ const key = Buffer.from(secret, "base64");
289
+ return createHmac("sha256", key).update(payload).digest("base64");
290
+ }
291
+
292
+ /**
293
+ * Constant-time string comparison. Performs a length check first (lengths are
294
+ * not secret), then delegates to `crypto.timingSafeEqual`.
295
+ */
296
+ private timingSafeEqual(a: string, b: string): boolean {
297
+ const ab = Buffer.from(a);
298
+ const bb = Buffer.from(b);
299
+ if (ab.length !== bb.length) {
300
+ return false;
301
+ }
302
+ return nodeTimingSafeEqual(ab, bb);
303
+ }
304
+
305
+ /**
306
+ * Read the `FastPix-Signature` header case-insensitively. If a framework hands
307
+ * back an array of values, the first one is used.
308
+ */
309
+ private extractSignature(headers: WebhookHeaders): string | undefined {
310
+ if (typeof Headers !== "undefined" && headers instanceof Headers) {
311
+ return headers.get(SIGNATURE_HEADER) ?? undefined;
312
+ }
313
+
314
+ const record = headers as Record<string, string | string[] | undefined>;
315
+ // Fast path: HTTP servers (e.g. Node) already lower-case header names.
316
+ let value = record[SIGNATURE_HEADER];
317
+ if (value === undefined) {
318
+ for (const key of Object.keys(record)) {
319
+ if (key.toLowerCase() === SIGNATURE_HEADER) {
320
+ value = record[key];
321
+ break;
322
+ }
323
+ }
324
+ }
325
+
326
+ return Array.isArray(value) ? value[0] : value;
327
+ }
328
+ }
329
+
@@ -141,13 +141,18 @@ export function literalBigInt<T extends bigint>(value: T): z.ZodMiniType<T> {
141
141
  }
142
142
 
143
143
  export function optional<T extends z.ZodMiniType>(t: T) {
144
- return z.union([
145
- z.undefined(),
146
-
147
- // Null -> undefined
148
- z.pipe(z.null(), z.transform(() => unrecognized(undefined))),
149
- t,
150
- ]);
144
+ // Wrap in `z.optional` so the object key is treated as optional regardless of
145
+ // zod version. zod >=4.4.0 changed object parsing so that a missing key whose
146
+ // value schema is merely a `union` containing `z.undefined()` is treated as
147
+ // required ("nonoptional"); `z.optional(...)` marks the key optional in every
148
+ // 4.x. The inner null->undefined pipe is preserved.
149
+ return z.optional(
150
+ z.union([
151
+ // Null -> undefined
152
+ z.pipe(z.null(), z.transform(() => unrecognized(undefined))),
153
+ t,
154
+ ]),
155
+ );
151
156
  }
152
157
 
153
158
  export function nullable<T extends z.ZodMiniType>(t: T) {