@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.
- package/README.md +92 -4
- package/dist/commonjs/index.d.ts +1 -0
- package/dist/commonjs/index.d.ts.map +1 -1
- package/dist/commonjs/index.js +1 -0
- package/dist/commonjs/index.js.map +1 -1
- package/dist/commonjs/lib/config.d.ts +13 -2
- package/dist/commonjs/lib/config.d.ts.map +1 -1
- package/dist/commonjs/lib/config.js +2 -2
- package/dist/commonjs/lib/config.js.map +1 -1
- package/dist/commonjs/lib/env.d.ts +1 -0
- package/dist/commonjs/lib/env.d.ts.map +1 -1
- package/dist/commonjs/lib/env.js +1 -0
- package/dist/commonjs/lib/env.js.map +1 -1
- package/dist/commonjs/lib/sdks.d.ts.map +1 -1
- package/dist/commonjs/lib/sdks.js +9 -1
- package/dist/commonjs/lib/sdks.js.map +1 -1
- package/dist/commonjs/sdk/sdk.d.ts +3 -0
- package/dist/commonjs/sdk/sdk.d.ts.map +1 -1
- package/dist/commonjs/sdk/sdk.js +11 -0
- package/dist/commonjs/sdk/sdk.js.map +1 -1
- package/dist/commonjs/sdk/webhooks.d.ts +151 -0
- package/dist/commonjs/sdk/webhooks.d.ts.map +1 -0
- package/dist/commonjs/sdk/webhooks.js +139 -0
- package/dist/commonjs/sdk/webhooks.js.map +1 -0
- package/dist/commonjs/types/primitives.d.ts +1 -1
- package/dist/commonjs/types/primitives.d.ts.map +1 -1
- package/dist/commonjs/types/primitives.js +7 -3
- package/dist/commonjs/types/primitives.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/lib/config.d.ts +13 -2
- package/dist/esm/lib/config.d.ts.map +1 -1
- package/dist/esm/lib/config.js +2 -2
- package/dist/esm/lib/config.js.map +1 -1
- package/dist/esm/lib/env.d.ts +1 -0
- package/dist/esm/lib/env.d.ts.map +1 -1
- package/dist/esm/lib/env.js +1 -0
- package/dist/esm/lib/env.js.map +1 -1
- package/dist/esm/lib/sdks.d.ts.map +1 -1
- package/dist/esm/lib/sdks.js +9 -1
- package/dist/esm/lib/sdks.js.map +1 -1
- package/dist/esm/sdk/sdk.d.ts +3 -0
- package/dist/esm/sdk/sdk.d.ts.map +1 -1
- package/dist/esm/sdk/sdk.js +11 -0
- package/dist/esm/sdk/sdk.js.map +1 -1
- package/dist/esm/sdk/webhooks.d.ts +151 -0
- package/dist/esm/sdk/webhooks.d.ts.map +1 -0
- package/dist/esm/sdk/webhooks.js +134 -0
- package/dist/esm/sdk/webhooks.js.map +1 -0
- package/dist/esm/types/primitives.d.ts +1 -1
- package/dist/esm/types/primitives.d.ts.map +1 -1
- package/dist/esm/types/primitives.js +7 -3
- package/dist/esm/types/primitives.js.map +1 -1
- package/examples/webhooksServer.example.ts +93 -0
- package/package.json +3 -4
- package/src/index.ts +1 -0
- package/src/lib/config.ts +14 -2
- package/src/lib/env.ts +2 -2
- package/src/lib/sdks.ts +9 -1
- package/src/sdk/sdk.ts +8 -0
- package/src/sdk/webhooks.ts +329 -0
- 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
|
+
|
package/src/types/primitives.ts
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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) {
|