@obelism/content 0.5.0 → 0.7.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/README.md +104 -0
- package/dist/bin/content.js +192 -11
- package/dist/bridge.d.mts +26 -2
- package/dist/bridge.d.mts.map +1 -1
- package/dist/bridge.d.ts +26 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.global.js +26 -2
- package/dist/bridge.js +202 -2
- package/dist/bridge.mjs +201 -3
- package/dist/prompt.js +15 -36
- package/dist/prompt.mjs +13 -34
- package/dist/standardWebhooks-ew5mhpac.mjs +41 -0
- package/dist/standardWebhooks-kh5dc0ff.js +43 -0
- package/dist/webhooks.d.mts +72 -0
- package/dist/webhooks.d.mts.map +1 -0
- package/dist/webhooks.d.ts +72 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +110 -0
- package/dist/webhooks.mjs +106 -0
- package/package.json +12 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Standard Webhooks (https://www.standardwebhooks.com/), the scheme the CMS
|
|
2
|
+
// signs both webhooks and prompt requests with: HMAC-SHA256 over
|
|
3
|
+
// `{id}.{timestamp}.{body}`, keyed with the base64 part of the `whsec_…`
|
|
4
|
+
// secret, in `webhook-signature` as `v1,<base64>` (several, space-separated,
|
|
5
|
+
// while a secret is rotated).
|
|
6
|
+
const SECRET_PREFIX = 'whsec_';
|
|
7
|
+
// Seconds a request's timestamp may be off, against replays.
|
|
8
|
+
const DEFAULT_TOLERANCE = 5 * 60;
|
|
9
|
+
const base64ToBytes = (value)=>Uint8Array.from(atob(value), (char)=>char.charCodeAt(0));
|
|
10
|
+
// Checks a message's signature and timestamp; calls `fail` (which throws the
|
|
11
|
+
// caller's own error) when it isn't from the CMS.
|
|
12
|
+
const verifySignature = async ({ body, id, timestamp, signatures }, secret, { tolerance = DEFAULT_TOLERANCE, now = Date.now(), fail })=>{
|
|
13
|
+
if (!id || !timestamp || !signatures) fail('Missing signature headers');
|
|
14
|
+
const seconds = Number(timestamp);
|
|
15
|
+
if (!Number.isFinite(seconds) || Math.abs(now / 1000 - seconds) > tolerance) {
|
|
16
|
+
fail('Timestamp outside the tolerance');
|
|
17
|
+
}
|
|
18
|
+
const key = await crypto.subtle.importKey('raw', base64ToBytes(secret.slice(SECRET_PREFIX.length)), {
|
|
19
|
+
name: 'HMAC',
|
|
20
|
+
hash: 'SHA-256'
|
|
21
|
+
}, false, [
|
|
22
|
+
'verify'
|
|
23
|
+
]);
|
|
24
|
+
const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
|
|
25
|
+
let valid = false;
|
|
26
|
+
for (const signature of signatures.split(' ')){
|
|
27
|
+
const [version, value] = signature.split(',');
|
|
28
|
+
if (version !== 'v1' || !value) continue;
|
|
29
|
+
let bytes;
|
|
30
|
+
try {
|
|
31
|
+
bytes = base64ToBytes(value);
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// crypto.subtle.verify compares in constant time.
|
|
36
|
+
if (await crypto.subtle.verify('HMAC', key, bytes, signed)) valid = true;
|
|
37
|
+
}
|
|
38
|
+
if (!valid) fail('Invalid signature');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export { DEFAULT_TOLERANCE as D, SECRET_PREFIX as S, verifySignature as v };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Standard Webhooks (https://www.standardwebhooks.com/), the scheme the CMS
|
|
2
|
+
// signs both webhooks and prompt requests with: HMAC-SHA256 over
|
|
3
|
+
// `{id}.{timestamp}.{body}`, keyed with the base64 part of the `whsec_…`
|
|
4
|
+
// secret, in `webhook-signature` as `v1,<base64>` (several, space-separated,
|
|
5
|
+
// while a secret is rotated).
|
|
6
|
+
const SECRET_PREFIX = 'whsec_';
|
|
7
|
+
// Seconds a request's timestamp may be off, against replays.
|
|
8
|
+
const DEFAULT_TOLERANCE = 5 * 60;
|
|
9
|
+
const base64ToBytes = (value)=>Uint8Array.from(atob(value), (char)=>char.charCodeAt(0));
|
|
10
|
+
// Checks a message's signature and timestamp; calls `fail` (which throws the
|
|
11
|
+
// caller's own error) when it isn't from the CMS.
|
|
12
|
+
const verifySignature = async ({ body, id, timestamp, signatures }, secret, { tolerance = DEFAULT_TOLERANCE, now = Date.now(), fail })=>{
|
|
13
|
+
if (!id || !timestamp || !signatures) fail('Missing signature headers');
|
|
14
|
+
const seconds = Number(timestamp);
|
|
15
|
+
if (!Number.isFinite(seconds) || Math.abs(now / 1000 - seconds) > tolerance) {
|
|
16
|
+
fail('Timestamp outside the tolerance');
|
|
17
|
+
}
|
|
18
|
+
const key = await crypto.subtle.importKey('raw', base64ToBytes(secret.slice(SECRET_PREFIX.length)), {
|
|
19
|
+
name: 'HMAC',
|
|
20
|
+
hash: 'SHA-256'
|
|
21
|
+
}, false, [
|
|
22
|
+
'verify'
|
|
23
|
+
]);
|
|
24
|
+
const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
|
|
25
|
+
let valid = false;
|
|
26
|
+
for (const signature of signatures.split(' ')){
|
|
27
|
+
const [version, value] = signature.split(',');
|
|
28
|
+
if (version !== 'v1' || !value) continue;
|
|
29
|
+
let bytes;
|
|
30
|
+
try {
|
|
31
|
+
bytes = base64ToBytes(value);
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// crypto.subtle.verify compares in constant time.
|
|
36
|
+
if (await crypto.subtle.verify('HMAC', key, bytes, signed)) valid = true;
|
|
37
|
+
}
|
|
38
|
+
if (!valid) fail('Invalid signature');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
exports.DEFAULT_TOLERANCE = DEFAULT_TOLERANCE;
|
|
42
|
+
exports.SECRET_PREFIX = SECRET_PREFIX;
|
|
43
|
+
exports.verifySignature = verifySignature;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Environment } from './index.mjs';
|
|
2
|
+
|
|
3
|
+
type WebhookEntry = {
|
|
4
|
+
id: string;
|
|
5
|
+
contentType: string;
|
|
6
|
+
name: string;
|
|
7
|
+
path: string | null;
|
|
8
|
+
};
|
|
9
|
+
type WebhookEventOf<TType extends string, TData> = {
|
|
10
|
+
type: TType;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
space: string;
|
|
13
|
+
environment: Environment;
|
|
14
|
+
data: TData;
|
|
15
|
+
};
|
|
16
|
+
type EntryPublishedEvent = WebhookEventOf<'entry.published', {
|
|
17
|
+
entry: WebhookEntry;
|
|
18
|
+
}>;
|
|
19
|
+
type EntryUnpublishedEvent = WebhookEventOf<'entry.unpublished', {
|
|
20
|
+
entry: WebhookEntry;
|
|
21
|
+
}>;
|
|
22
|
+
type EntryDeletedEvent = WebhookEventOf<'entry.deleted', {
|
|
23
|
+
entry: WebhookEntry;
|
|
24
|
+
}>;
|
|
25
|
+
type EntryMovedEvent = WebhookEventOf<'entry.moved', {
|
|
26
|
+
entry: WebhookEntry;
|
|
27
|
+
previousPath: string | null;
|
|
28
|
+
}>;
|
|
29
|
+
type AssetUpdatedEvent = WebhookEventOf<'asset.updated', {
|
|
30
|
+
asset: {
|
|
31
|
+
id: string;
|
|
32
|
+
filename: string;
|
|
33
|
+
};
|
|
34
|
+
entries: WebhookEntry[];
|
|
35
|
+
}>;
|
|
36
|
+
type ModelChangedEvent = WebhookEventOf<'model.changed', {
|
|
37
|
+
contentTypes: {
|
|
38
|
+
apiId: string;
|
|
39
|
+
action: 'create' | 'update' | 'delete';
|
|
40
|
+
}[];
|
|
41
|
+
}>;
|
|
42
|
+
type LocalesChangedEvent = WebhookEventOf<'locales.changed', {
|
|
43
|
+
defaultLocale: string;
|
|
44
|
+
locales: string[];
|
|
45
|
+
}>;
|
|
46
|
+
type WebhookTestEvent = WebhookEventOf<'webhook.test', Record<string, never>>;
|
|
47
|
+
type WebhookEvent = EntryPublishedEvent | EntryUnpublishedEvent | EntryDeletedEvent | EntryMovedEvent | AssetUpdatedEvent | ModelChangedEvent | LocalesChangedEvent | WebhookTestEvent;
|
|
48
|
+
type WebhookEventType = WebhookEvent['type'];
|
|
49
|
+
declare class WebhookVerificationError extends Error {
|
|
50
|
+
constructor(message: string);
|
|
51
|
+
}
|
|
52
|
+
type WebhookMessage = {
|
|
53
|
+
body: string;
|
|
54
|
+
headers: Headers | Record<string, string | string[] | undefined> | Iterable<[string, string]>;
|
|
55
|
+
};
|
|
56
|
+
type VerifyWebhookOptions = {
|
|
57
|
+
tolerance?: number | undefined;
|
|
58
|
+
now?: number;
|
|
59
|
+
};
|
|
60
|
+
declare const verifyWebhook: (request: Request | WebhookMessage, secret: string, { tolerance, now, }?: VerifyWebhookOptions) => Promise<WebhookEvent>;
|
|
61
|
+
type WebhookHandlerOptions = {
|
|
62
|
+
secret: string;
|
|
63
|
+
onEvent: (event: WebhookEvent, meta: {
|
|
64
|
+
id: string;
|
|
65
|
+
}) => unknown;
|
|
66
|
+
tolerance?: number | undefined;
|
|
67
|
+
};
|
|
68
|
+
declare const createWebhookHandler: ({ secret, onEvent, tolerance }: WebhookHandlerOptions) => (request: Request) => Promise<Response>;
|
|
69
|
+
|
|
70
|
+
export { WebhookVerificationError, createWebhookHandler, verifyWebhook };
|
|
71
|
+
export type { AssetUpdatedEvent, EntryDeletedEvent, EntryMovedEvent, EntryPublishedEvent, EntryUnpublishedEvent, LocalesChangedEvent, ModelChangedEvent, VerifyWebhookOptions, WebhookEntry, WebhookEvent, WebhookEventType, WebhookHandlerOptions, WebhookMessage, WebhookTestEvent };
|
|
72
|
+
//# sourceMappingURL=webhooks.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.d.mts","sources":["../src/webhooks.ts"],"mappings":";;AACA,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;AACnB;AACA;AACA;AACA,iBAAiB,WAAW;AAC5B;AACA;AACA,KAAK,mBAAmB,GAAG,cAAc;AACzC,WAAW,YAAY;AACvB;AACA,KAAK,qBAAqB,GAAG,cAAc;AAC3C,WAAW,YAAY;AACvB;AACA,KAAK,iBAAiB,GAAG,cAAc;AACvC,WAAW,YAAY;AACvB;AACA,KAAK,eAAe,GAAG,cAAc;AACrC,WAAW,YAAY;AACvB;AACA;AACA,KAAK,iBAAiB,GAAG,cAAc;AACvC;AACA;AACA;AACA;AACA,aAAa,YAAY;AACzB;AACA,KAAK,iBAAiB,GAAG,cAAc;AACvC;AACA;AACA;AACA;AACA;AACA,KAAK,mBAAmB,GAAG,cAAc;AACzC;AACA;AACA;AACA,KAAK,gBAAgB,GAAG,cAAc,iBAAiB,MAAM;AAC7D,KAAK,YAAY,GAAG,mBAAmB,GAAG,qBAAqB,GAAG,iBAAiB,GAAG,eAAe,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,mBAAmB,GAAG,gBAAgB;AACtL,KAAK,gBAAgB,GAAG,YAAY;AACpC,cAAc,wBAAwB,SAAS,KAAK;AACpD;AACA;AACA,KAAK,cAAc;AACnB;AACA,aAAa,OAAO,GAAG,MAAM,0CAA0C,QAAQ;AAC/E;AACA,KAAK,oBAAoB;AACzB;AACA;AACA;AACA,cAAc,aAAa,YAAY,OAAO,GAAG,cAAc,wCAAwC,oBAAoB,KAAK,OAAO,CAAC,YAAY;AACpJ,KAAK,qBAAqB;AAC1B;AACA,qBAAqB,YAAY;AACjC;AACA;AACA;AACA;AACA,cAAc,oBAAoB,mCAAmC,qBAAqB,eAAe,OAAO,KAAK,OAAO,CAAC,QAAQ","names":[]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Environment } from './index.js';
|
|
2
|
+
|
|
3
|
+
type WebhookEntry = {
|
|
4
|
+
id: string;
|
|
5
|
+
contentType: string;
|
|
6
|
+
name: string;
|
|
7
|
+
path: string | null;
|
|
8
|
+
};
|
|
9
|
+
type WebhookEventOf<TType extends string, TData> = {
|
|
10
|
+
type: TType;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
space: string;
|
|
13
|
+
environment: Environment;
|
|
14
|
+
data: TData;
|
|
15
|
+
};
|
|
16
|
+
type EntryPublishedEvent = WebhookEventOf<'entry.published', {
|
|
17
|
+
entry: WebhookEntry;
|
|
18
|
+
}>;
|
|
19
|
+
type EntryUnpublishedEvent = WebhookEventOf<'entry.unpublished', {
|
|
20
|
+
entry: WebhookEntry;
|
|
21
|
+
}>;
|
|
22
|
+
type EntryDeletedEvent = WebhookEventOf<'entry.deleted', {
|
|
23
|
+
entry: WebhookEntry;
|
|
24
|
+
}>;
|
|
25
|
+
type EntryMovedEvent = WebhookEventOf<'entry.moved', {
|
|
26
|
+
entry: WebhookEntry;
|
|
27
|
+
previousPath: string | null;
|
|
28
|
+
}>;
|
|
29
|
+
type AssetUpdatedEvent = WebhookEventOf<'asset.updated', {
|
|
30
|
+
asset: {
|
|
31
|
+
id: string;
|
|
32
|
+
filename: string;
|
|
33
|
+
};
|
|
34
|
+
entries: WebhookEntry[];
|
|
35
|
+
}>;
|
|
36
|
+
type ModelChangedEvent = WebhookEventOf<'model.changed', {
|
|
37
|
+
contentTypes: {
|
|
38
|
+
apiId: string;
|
|
39
|
+
action: 'create' | 'update' | 'delete';
|
|
40
|
+
}[];
|
|
41
|
+
}>;
|
|
42
|
+
type LocalesChangedEvent = WebhookEventOf<'locales.changed', {
|
|
43
|
+
defaultLocale: string;
|
|
44
|
+
locales: string[];
|
|
45
|
+
}>;
|
|
46
|
+
type WebhookTestEvent = WebhookEventOf<'webhook.test', Record<string, never>>;
|
|
47
|
+
type WebhookEvent = EntryPublishedEvent | EntryUnpublishedEvent | EntryDeletedEvent | EntryMovedEvent | AssetUpdatedEvent | ModelChangedEvent | LocalesChangedEvent | WebhookTestEvent;
|
|
48
|
+
type WebhookEventType = WebhookEvent['type'];
|
|
49
|
+
declare class WebhookVerificationError extends Error {
|
|
50
|
+
constructor(message: string);
|
|
51
|
+
}
|
|
52
|
+
type WebhookMessage = {
|
|
53
|
+
body: string;
|
|
54
|
+
headers: Headers | Record<string, string | string[] | undefined> | Iterable<[string, string]>;
|
|
55
|
+
};
|
|
56
|
+
type VerifyWebhookOptions = {
|
|
57
|
+
tolerance?: number | undefined;
|
|
58
|
+
now?: number;
|
|
59
|
+
};
|
|
60
|
+
declare const verifyWebhook: (request: Request | WebhookMessage, secret: string, { tolerance, now, }?: VerifyWebhookOptions) => Promise<WebhookEvent>;
|
|
61
|
+
type WebhookHandlerOptions = {
|
|
62
|
+
secret: string;
|
|
63
|
+
onEvent: (event: WebhookEvent, meta: {
|
|
64
|
+
id: string;
|
|
65
|
+
}) => unknown;
|
|
66
|
+
tolerance?: number | undefined;
|
|
67
|
+
};
|
|
68
|
+
declare const createWebhookHandler: ({ secret, onEvent, tolerance }: WebhookHandlerOptions) => (request: Request) => Promise<Response>;
|
|
69
|
+
|
|
70
|
+
export { WebhookVerificationError, createWebhookHandler, verifyWebhook };
|
|
71
|
+
export type { AssetUpdatedEvent, EntryDeletedEvent, EntryMovedEvent, EntryPublishedEvent, EntryUnpublishedEvent, LocalesChangedEvent, ModelChangedEvent, VerifyWebhookOptions, WebhookEntry, WebhookEvent, WebhookEventType, WebhookHandlerOptions, WebhookMessage, WebhookTestEvent };
|
|
72
|
+
//# sourceMappingURL=webhooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.d.ts","sources":["../src/webhooks.ts"],"mappings":";;AACA,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;AACnB;AACA;AACA;AACA,iBAAiB,WAAW;AAC5B;AACA;AACA,KAAK,mBAAmB,GAAG,cAAc;AACzC,WAAW,YAAY;AACvB;AACA,KAAK,qBAAqB,GAAG,cAAc;AAC3C,WAAW,YAAY;AACvB;AACA,KAAK,iBAAiB,GAAG,cAAc;AACvC,WAAW,YAAY;AACvB;AACA,KAAK,eAAe,GAAG,cAAc;AACrC,WAAW,YAAY;AACvB;AACA;AACA,KAAK,iBAAiB,GAAG,cAAc;AACvC;AACA;AACA;AACA;AACA,aAAa,YAAY;AACzB;AACA,KAAK,iBAAiB,GAAG,cAAc;AACvC;AACA;AACA;AACA;AACA;AACA,KAAK,mBAAmB,GAAG,cAAc;AACzC;AACA;AACA;AACA,KAAK,gBAAgB,GAAG,cAAc,iBAAiB,MAAM;AAC7D,KAAK,YAAY,GAAG,mBAAmB,GAAG,qBAAqB,GAAG,iBAAiB,GAAG,eAAe,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,mBAAmB,GAAG,gBAAgB;AACtL,KAAK,gBAAgB,GAAG,YAAY;AACpC,cAAc,wBAAwB,SAAS,KAAK;AACpD;AACA;AACA,KAAK,cAAc;AACnB;AACA,aAAa,OAAO,GAAG,MAAM,0CAA0C,QAAQ;AAC/E;AACA,KAAK,oBAAoB;AACzB;AACA;AACA;AACA,cAAc,aAAa,YAAY,OAAO,GAAG,cAAc,wCAAwC,oBAAoB,KAAK,OAAO,CAAC,YAAY;AACpJ,KAAK,qBAAqB;AAC1B;AACA,qBAAqB,YAAY;AACjC;AACA;AACA;AACA;AACA,cAAc,oBAAoB,mCAAmC,qBAAqB,eAAe,OAAO,KAAK,OAAO,CAAC,QAAQ","names":[]}
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
var standardWebhooks = require('./standardWebhooks-kh5dc0ff.js');
|
|
4
|
+
|
|
5
|
+
// Webhooks from Obelism Content (the CMS's docs/guides/webhooks): one signed
|
|
6
|
+
// POST per event (Standard Webhooks), telling your site what the delivery
|
|
7
|
+
// API answers has changed. Verify it and revalidate:
|
|
8
|
+
//
|
|
9
|
+
// // app/api/revalidate/route.ts
|
|
10
|
+
// import { revalidatePath } from 'next/cache'
|
|
11
|
+
// import { createWebhookHandler } from '@obelism/content/webhooks'
|
|
12
|
+
//
|
|
13
|
+
// export const POST = createWebhookHandler({
|
|
14
|
+
// secret: process.env.CONTENT_WEBHOOK_SECRET!,
|
|
15
|
+
// onEvent: (event) => {
|
|
16
|
+
// if (event.type === 'entry.published' && event.data.entry.path) {
|
|
17
|
+
// revalidatePath(`/${event.data.entry.path}`)
|
|
18
|
+
// }
|
|
19
|
+
// },
|
|
20
|
+
// })
|
|
21
|
+
class WebhookVerificationError extends Error {
|
|
22
|
+
constructor(message){
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'WebhookVerificationError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const headerReader = (headers)=>{
|
|
28
|
+
if (typeof headers.get === 'function') {
|
|
29
|
+
return (name)=>headers.get(name);
|
|
30
|
+
}
|
|
31
|
+
const lower = new Map();
|
|
32
|
+
const entries = Symbol.iterator in headers ? headers : Object.entries(headers);
|
|
33
|
+
for (const [name, value] of entries){
|
|
34
|
+
if (value === undefined) continue;
|
|
35
|
+
lower.set(name.toLowerCase(), Array.isArray(value) ? value.join(' ') : String(value));
|
|
36
|
+
}
|
|
37
|
+
return (name)=>lower.get(name) ?? null;
|
|
38
|
+
};
|
|
39
|
+
// Checks a webhook's signature and timestamp and returns its event. Throws
|
|
40
|
+
// WebhookVerificationError when it isn't from the CMS, or too old.
|
|
41
|
+
const verifyWebhook = async (request, secret, { tolerance = standardWebhooks.DEFAULT_TOLERANCE, now = Date.now() } = {})=>{
|
|
42
|
+
if (!secret?.startsWith(standardWebhooks.SECRET_PREFIX)) {
|
|
43
|
+
throw new Error('The webhook secret must be the whsec_… shown when the webhook was added');
|
|
44
|
+
}
|
|
45
|
+
// Not `instanceof Request`: frameworks may bring their own Request class.
|
|
46
|
+
const body = typeof request.body === 'string' ? request.body : await request.text();
|
|
47
|
+
const header = headerReader(request.headers);
|
|
48
|
+
await standardWebhooks.verifySignature({
|
|
49
|
+
body,
|
|
50
|
+
id: header('webhook-id'),
|
|
51
|
+
timestamp: header('webhook-timestamp'),
|
|
52
|
+
signatures: header('webhook-signature')
|
|
53
|
+
}, secret, {
|
|
54
|
+
tolerance,
|
|
55
|
+
now,
|
|
56
|
+
fail: (message)=>{
|
|
57
|
+
throw new WebhookVerificationError(message);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
let event;
|
|
61
|
+
try {
|
|
62
|
+
event = JSON.parse(body);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new WebhookVerificationError('The body is not JSON');
|
|
65
|
+
}
|
|
66
|
+
if (typeof event?.type !== 'string') {
|
|
67
|
+
throw new WebhookVerificationError('The body is not a webhook event');
|
|
68
|
+
}
|
|
69
|
+
return event;
|
|
70
|
+
};
|
|
71
|
+
// A fetch-style handler (Request → Response) for the webhook's URL: Next.js
|
|
72
|
+
// route handlers, Hono, Workers, Bun, Deno. Answers 401 to anything not
|
|
73
|
+
// signed by the CMS, 204 once `onEvent` is done and 500 when it throws, so
|
|
74
|
+
// the delivery is retried.
|
|
75
|
+
const createWebhookHandler = ({ secret, onEvent, tolerance })=>async (request)=>{
|
|
76
|
+
let event;
|
|
77
|
+
try {
|
|
78
|
+
event = await verifyWebhook(request, secret, {
|
|
79
|
+
tolerance
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof WebhookVerificationError) {
|
|
83
|
+
return Response.json({
|
|
84
|
+
error: error.message
|
|
85
|
+
}, {
|
|
86
|
+
status: 401
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await onEvent(event, {
|
|
93
|
+
id: request.headers.get('webhook-id')
|
|
94
|
+
});
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('[@obelism/content webhooks]', error);
|
|
97
|
+
return Response.json({
|
|
98
|
+
error: 'Handling the event failed'
|
|
99
|
+
}, {
|
|
100
|
+
status: 500
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return new Response(null, {
|
|
104
|
+
status: 204
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
exports.WebhookVerificationError = WebhookVerificationError;
|
|
109
|
+
exports.createWebhookHandler = createWebhookHandler;
|
|
110
|
+
exports.verifyWebhook = verifyWebhook;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { S as SECRET_PREFIX, v as verifySignature, D as DEFAULT_TOLERANCE } from './standardWebhooks-ew5mhpac.mjs';
|
|
2
|
+
|
|
3
|
+
// Webhooks from Obelism Content (the CMS's docs/guides/webhooks): one signed
|
|
4
|
+
// POST per event (Standard Webhooks), telling your site what the delivery
|
|
5
|
+
// API answers has changed. Verify it and revalidate:
|
|
6
|
+
//
|
|
7
|
+
// // app/api/revalidate/route.ts
|
|
8
|
+
// import { revalidatePath } from 'next/cache'
|
|
9
|
+
// import { createWebhookHandler } from '@obelism/content/webhooks'
|
|
10
|
+
//
|
|
11
|
+
// export const POST = createWebhookHandler({
|
|
12
|
+
// secret: process.env.CONTENT_WEBHOOK_SECRET!,
|
|
13
|
+
// onEvent: (event) => {
|
|
14
|
+
// if (event.type === 'entry.published' && event.data.entry.path) {
|
|
15
|
+
// revalidatePath(`/${event.data.entry.path}`)
|
|
16
|
+
// }
|
|
17
|
+
// },
|
|
18
|
+
// })
|
|
19
|
+
class WebhookVerificationError extends Error {
|
|
20
|
+
constructor(message){
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'WebhookVerificationError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const headerReader = (headers)=>{
|
|
26
|
+
if (typeof headers.get === 'function') {
|
|
27
|
+
return (name)=>headers.get(name);
|
|
28
|
+
}
|
|
29
|
+
const lower = new Map();
|
|
30
|
+
const entries = Symbol.iterator in headers ? headers : Object.entries(headers);
|
|
31
|
+
for (const [name, value] of entries){
|
|
32
|
+
if (value === undefined) continue;
|
|
33
|
+
lower.set(name.toLowerCase(), Array.isArray(value) ? value.join(' ') : String(value));
|
|
34
|
+
}
|
|
35
|
+
return (name)=>lower.get(name) ?? null;
|
|
36
|
+
};
|
|
37
|
+
// Checks a webhook's signature and timestamp and returns its event. Throws
|
|
38
|
+
// WebhookVerificationError when it isn't from the CMS, or too old.
|
|
39
|
+
const verifyWebhook = async (request, secret, { tolerance = DEFAULT_TOLERANCE, now = Date.now() } = {})=>{
|
|
40
|
+
if (!secret?.startsWith(SECRET_PREFIX)) {
|
|
41
|
+
throw new Error('The webhook secret must be the whsec_… shown when the webhook was added');
|
|
42
|
+
}
|
|
43
|
+
// Not `instanceof Request`: frameworks may bring their own Request class.
|
|
44
|
+
const body = typeof request.body === 'string' ? request.body : await request.text();
|
|
45
|
+
const header = headerReader(request.headers);
|
|
46
|
+
await verifySignature({
|
|
47
|
+
body,
|
|
48
|
+
id: header('webhook-id'),
|
|
49
|
+
timestamp: header('webhook-timestamp'),
|
|
50
|
+
signatures: header('webhook-signature')
|
|
51
|
+
}, secret, {
|
|
52
|
+
tolerance,
|
|
53
|
+
now,
|
|
54
|
+
fail: (message)=>{
|
|
55
|
+
throw new WebhookVerificationError(message);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
let event;
|
|
59
|
+
try {
|
|
60
|
+
event = JSON.parse(body);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new WebhookVerificationError('The body is not JSON');
|
|
63
|
+
}
|
|
64
|
+
if (typeof event?.type !== 'string') {
|
|
65
|
+
throw new WebhookVerificationError('The body is not a webhook event');
|
|
66
|
+
}
|
|
67
|
+
return event;
|
|
68
|
+
};
|
|
69
|
+
// A fetch-style handler (Request → Response) for the webhook's URL: Next.js
|
|
70
|
+
// route handlers, Hono, Workers, Bun, Deno. Answers 401 to anything not
|
|
71
|
+
// signed by the CMS, 204 once `onEvent` is done and 500 when it throws, so
|
|
72
|
+
// the delivery is retried.
|
|
73
|
+
const createWebhookHandler = ({ secret, onEvent, tolerance })=>async (request)=>{
|
|
74
|
+
let event;
|
|
75
|
+
try {
|
|
76
|
+
event = await verifyWebhook(request, secret, {
|
|
77
|
+
tolerance
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error instanceof WebhookVerificationError) {
|
|
81
|
+
return Response.json({
|
|
82
|
+
error: error.message
|
|
83
|
+
}, {
|
|
84
|
+
status: 401
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await onEvent(event, {
|
|
91
|
+
id: request.headers.get('webhook-id')
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('[@obelism/content webhooks]', error);
|
|
95
|
+
return Response.json({
|
|
96
|
+
error: 'Handling the event failed'
|
|
97
|
+
}, {
|
|
98
|
+
status: 500
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return new Response(null, {
|
|
102
|
+
status: 204
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export { WebhookVerificationError, createWebhookHandler, verifyWebhook };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obelism/content",
|
|
3
|
-
"description": "Obelism Content SDK: the delivery API client, the visual editor bridge with prompt editing, editing on your own site, and the content model CLI",
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Obelism Content SDK: the delivery API client, the visual editor bridge with prompt editing, editing on your own site, webhook verification, and the content model CLI",
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cms",
|
|
7
7
|
"headless-cms",
|
|
@@ -62,6 +62,16 @@
|
|
|
62
62
|
"types": "./dist/prompt.d.ts",
|
|
63
63
|
"default": "./dist/prompt.js"
|
|
64
64
|
}
|
|
65
|
+
},
|
|
66
|
+
"./webhooks": {
|
|
67
|
+
"import": {
|
|
68
|
+
"types": "./dist/webhooks.d.mts",
|
|
69
|
+
"default": "./dist/webhooks.mjs"
|
|
70
|
+
},
|
|
71
|
+
"require": {
|
|
72
|
+
"types": "./dist/webhooks.d.ts",
|
|
73
|
+
"default": "./dist/webhooks.js"
|
|
74
|
+
}
|
|
65
75
|
}
|
|
66
76
|
},
|
|
67
77
|
"scripts": {
|