@obelism/content 0.5.0 → 0.6.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 CHANGED
@@ -155,6 +155,51 @@ export const POST = createPromptHandler({
155
155
  })
156
156
  ```
157
157
 
158
+ ## Webhooks
159
+
160
+ A webhook tells your site that what the delivery API answers has changed
161
+ (Space settings → Webhooks). Its requests are signed (Standard Webhooks);
162
+ `createWebhookHandler` verifies them and hands you the typed event:
163
+
164
+ ```ts
165
+ // app/api/revalidate/route.ts
166
+ import { revalidatePath, revalidateTag } from 'next/cache'
167
+ import { createWebhookHandler } from '@obelism/content/webhooks'
168
+
169
+ export const POST = createWebhookHandler({
170
+ secret: process.env.CONTENT_WEBHOOK_SECRET!, // whsec_…, shown once
171
+ onEvent: (event, { id }) => {
172
+ if (event.type === 'entry.published' && event.data.entry.path) {
173
+ revalidatePath(`/${event.data.entry.path}`)
174
+ } else {
175
+ revalidateTag('content')
176
+ }
177
+ },
178
+ })
179
+ ```
180
+
181
+ It answers 401 to anything not signed with the secret or with a
182
+ `webhook-timestamp` more than 5 minutes off (`tolerance`, in seconds), 204
183
+ once `onEvent` is done, and 500 when it throws, so the CMS tries again. `id`
184
+ is the delivery's `webhook-id`, the same on every retry.
185
+
186
+ Elsewhere, `verifyWebhook(request, secret, { tolerance? })` returns the event
187
+ or throws `WebhookVerificationError`. It takes a `Request`, or the raw body
188
+ and headers (`{ body, headers }`; Node's `req.headers` work as they are), the
189
+ body exactly as it was sent:
190
+
191
+ ```ts
192
+ import { verifyWebhook } from '@obelism/content/webhooks'
193
+
194
+ const event = await verifyWebhook(request, process.env.CONTENT_WEBHOOK_SECRET!)
195
+ ```
196
+
197
+ `WebhookEvent` is a union on `type`: `entry.published`, `entry.unpublished`,
198
+ `entry.deleted` and `entry.moved` (`data.entry`: `{ id, contentType, name,
199
+ path }`, plus `previousPath` when moved), `asset.updated` (`data.asset`,
200
+ `data.entries`), `model.changed` (`data.contentTypes`: `{ apiId, action }`),
201
+ `locales.changed` (`data.defaultLocale`, `data.locales`) and `webhook.test`.
202
+
158
203
  ## Helpers
159
204
 
160
205
  - `richTextToHtml(doc, { renderEntry?, renderAsset? })`, `richTextToPlainText(doc)`
@@ -211,3 +256,40 @@ npx @obelism/content model apply # apply it, all or nothing
211
256
 
212
257
  Options: `--file`, `--url` (or `$CONTENT_URL`), `--prune`. In GitHub Actions
213
258
  the plan is also written to the job summary.
259
+
260
+ ### Types
261
+
262
+ Write a TypeScript type per content type to `content/content-types.ts`:
263
+
264
+ ```bash
265
+ npx @obelism/content types # from the environment
266
+ npx @obelism/content types --file content/content-model.json # from the model file, no token
267
+ ```
268
+
269
+ Pages and data become `Entry<{ …fields }, 'apiId'>`, blocks
270
+ `Block<{ …fields }, 'apiId'>`, named after the apiId (`blogPost` →
271
+ `BlogPost`), with `ContentEntry` and `ContentBlock` as the unions of them.
272
+ Each field is typed by what the delivery API answers with its defaults (one
273
+ locale, rich text as a document), and `| null` when empty:
274
+
275
+ | Field | Type |
276
+ | ------------------------ | -------------------------------------------------------------------- |
277
+ | text, textarea, datetime | `string` |
278
+ | number / boolean | `number` / `boolean` |
279
+ | select | the options' values, `'a' \| 'b'` (a list when multiple) |
280
+ | asset | `Asset` (a list when multiple) |
281
+ | reference | the allowed entry types or their `EntryStub`s (a list when multiple) |
282
+ | blocks | a list of the allowed block types and `ImproveBlock` |
283
+ | richtext | `RichTextDocument` |
284
+ | json | `unknown` |
285
+
286
+ A localized field is the one locale's value. Pass the types to the client:
287
+
288
+ ```ts
289
+ import type { BlogPost } from './content/content-types'
290
+
291
+ const post = await client.getPage<BlogPost>('blog/hello')
292
+ ```
293
+
294
+ Run it again after the model changes, e.g. after `model pull`. Options:
295
+ `--dir` (default `content`), `--file`, `--url`.
@@ -256,9 +256,8 @@ class CliError extends Error {
256
256
  super(message), this.exitCode = exitCode;
257
257
  }
258
258
  }
259
- // Exit code: 0 done, 1 problems in the plan.
260
- const runModelCommand = async ({ command, file, url, token, prune, log = console.log, summaryFile, fetch: fetchImpl = fetch })=>{
261
- const call = async (method, path, body)=>{
259
+ // Calls the management API; anything but JSON back is a CliError.
260
+ const managementApi = (url, token, fetchImpl = fetch)=>async (method, path, body)=>{
262
261
  const response = await fetchImpl(`${url}${config.MANAGEMENT_PATH}${path}`, {
263
262
  method,
264
263
  headers: {
@@ -284,6 +283,9 @@ const runModelCommand = async ({ command, file, url, token, prune, log = console
284
283
  throw new CliError(`${response.status} from ${url}: ${text.slice(0, 200)}`);
285
284
  }
286
285
  };
286
+ // Exit code: 0 done, 1 problems in the plan.
287
+ const runModelCommand = async ({ command, file, url, token, prune, log = console.log, summaryFile, fetch: fetchImpl = fetch })=>{
288
+ const call = managementApi(url, token, fetchImpl);
287
289
  if (command === 'pull') {
288
290
  const { ok, json } = await call('GET', '/model');
289
291
  if (!ok) throw new CliError(String(json.error ?? 'Pull failed'));
@@ -511,8 +513,173 @@ const runInitCommand = async ({ url, token, dir, install: shouldInstall, cwd = p
511
513
  return 0;
512
514
  };
513
515
 
516
+ // What `content types` writes to content/content-types.ts: a TypeScript type
517
+ // per content type, for what the delivery API answers with its defaults (one
518
+ // locale, rich text as a document). Pages and data are `Entry<…>`, blocks
519
+ // `Block<…>`, every field `| null` (delivery answers null for empty ones).
520
+ // Pure.
521
+ //
522
+ // const post = await client.getPage<BlogPost>('blog/hello')
523
+ // The SDK's types the file imports; a content type whose name would clash
524
+ // gets its kind as a suffix (`asset` → `AssetEntry`).
525
+ const SDK_TYPES = [
526
+ 'Asset',
527
+ 'Block',
528
+ 'Entry',
529
+ 'EntryStub',
530
+ 'ImproveBlock',
531
+ 'RichTextDocument'
532
+ ];
533
+ // The unions of every entry and block type the file ends with.
534
+ const UNION_NAMES = [
535
+ 'ContentEntry',
536
+ 'ContentBlock'
537
+ ];
538
+ const quote = (value)=>`'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
539
+ const union = (members)=>[
540
+ ...new Set(members)
541
+ ].join(' | ');
542
+ const list = (type, multiple)=>multiple ? type.includes(' | ') ? `(${type})[]` : `${type}[]` : type;
543
+ // `unknown` (a json field) holds null already.
544
+ const nullable = (type)=>type === 'unknown' ? type : `${type} | null`;
545
+ const comment = (text, indent)=>`${indent}// ${text.replace(/\s+/g, ' ').trim()}\n`;
546
+ const generateTypes = (model)=>{
547
+ const types = model.contentTypes;
548
+ const blockTypes = types.filter(({ kind })=>kind === 'block');
549
+ const entryTypes = types.filter(({ kind })=>kind !== 'block');
550
+ const used = new Set();
551
+ const use = (name)=>{
552
+ used.add(name);
553
+ return name;
554
+ };
555
+ // apiIds start with a lowercase letter and are unique, so capitalizing
556
+ // keeps them unique.
557
+ const typeName = new Map(types.map(({ apiId, kind })=>{
558
+ const name = `${apiId[0].toUpperCase()}${apiId.slice(1)}`;
559
+ const reserved = SDK_TYPES.includes(name) || UNION_NAMES.includes(name);
560
+ return [
561
+ apiId,
562
+ reserved ? `${name}${kind === 'block' ? 'Block' : 'Entry'}` : name
563
+ ];
564
+ }));
565
+ const known = (allowed, candidates)=>allowed ? candidates.filter(({ apiId })=>allowed.includes(apiId)) : candidates;
566
+ const value = (field)=>{
567
+ switch(field.type){
568
+ case 'text':
569
+ case 'textarea':
570
+ case 'datetime':
571
+ return 'string';
572
+ case 'number':
573
+ return 'number';
574
+ case 'boolean':
575
+ return 'boolean';
576
+ case 'richtext':
577
+ return use('RichTextDocument');
578
+ case 'select':
579
+ return list(union(field.options.map((option)=>quote(option.value))), field.multiple);
580
+ case 'asset':
581
+ return list(use('Asset'), field.multiple);
582
+ case 'reference':
583
+ {
584
+ // The entry itself, or a stub past the requested depth.
585
+ const targets = known(field.contentTypes, entryTypes);
586
+ const type = targets.length === 0 ? `${use('Entry')} | ${use('EntryStub')}` : union([
587
+ ...targets.map(({ apiId })=>typeName.get(apiId)),
588
+ `${use('EntryStub')}<${union(targets.map(({ apiId })=>quote(apiId)))}>`
589
+ ]);
590
+ return list(type, field.multiple);
591
+ }
592
+ case 'blocks':
593
+ {
594
+ const allowed = known(field.allowedBlocks, blockTypes);
595
+ const blocks = allowed.length === 0 ? use('Block') : union(allowed.map(({ apiId })=>typeName.get(apiId)));
596
+ const improve = `${use('ImproveBlock')}${allowed.length === 0 ? '' : `<${blocks}>`}`;
597
+ return list(union([
598
+ blocks,
599
+ improve
600
+ ]), true);
601
+ }
602
+ case 'json':
603
+ return 'unknown';
604
+ }
605
+ };
606
+ const declaration = (type)=>{
607
+ const wrapper = type.kind === 'block' ? use('Block') : use('Entry');
608
+ const fields = type.fields.map((field)=>`${field.description ? comment(field.description, '\t\t') : ''}\t\t${field.apiId}: ${nullable(value(field))}\n`).join('');
609
+ const body = fields ? `{\n${fields}\t}` : 'Record<never, never>';
610
+ return [
611
+ comment(`${type.name} (${type.kind})${type.description ? `: ${type.description}` : ''}`, ''),
612
+ `export type ${typeName.get(type.apiId)} = ${wrapper}<\n\t${body},\n\t${quote(type.apiId)}\n>\n`
613
+ ].join('');
614
+ };
615
+ const declarations = types.map(declaration);
616
+ const unions = [
617
+ [
618
+ 'ContentEntry',
619
+ entryTypes
620
+ ],
621
+ [
622
+ 'ContentBlock',
623
+ blockTypes
624
+ ]
625
+ ];
626
+ const unionLines = unions.filter(([, members])=>members.length > 0).map(([name, members])=>`export type ${name} = ${union(members.map(({ apiId })=>typeName.get(apiId)))}\n`);
627
+ const imports = SDK_TYPES.filter((name)=>used.has(name));
628
+ return [
629
+ '// Generated by `npx @obelism/content types` from the content model.\n',
630
+ "// Don't edit: run it again after the model changes.\n",
631
+ ...imports.length > 0 ? [
632
+ `\nimport type { ${imports.join(', ')} } from '@obelism/content'\n`
633
+ ] : [],
634
+ ...declarations.map((text)=>`\n${text}`),
635
+ ...unionLines.length > 0 ? [
636
+ '\n',
637
+ ...unionLines
638
+ ] : []
639
+ ].join('');
640
+ };
641
+
642
+ const readModel = async (file)=>{
643
+ let text;
644
+ try {
645
+ text = await promises.readFile(file, 'utf8');
646
+ } catch {
647
+ throw new CliError(`Can't read ${file}. Run \`content model pull\` first?`);
648
+ }
649
+ try {
650
+ return JSON.parse(text);
651
+ } catch (error) {
652
+ throw new CliError(`${file} is not valid JSON: ${error.message}`);
653
+ }
654
+ };
655
+ const runTypesCommand = async ({ dir, file, url, token, cwd = process.cwd(), log = console.log, fetch: fetchImpl = fetch })=>{
656
+ let model;
657
+ if (file) {
658
+ model = await readModel(node_path.resolve(cwd, file));
659
+ } else {
660
+ if (!token) {
661
+ throw new CliError('CONTENT_MANAGEMENT_TOKEN is not set (or pass --file to read a model file).', 2);
662
+ }
663
+ const { ok, json } = await managementApi(url, token, fetchImpl)('GET', '/model');
664
+ if (!ok) throw new CliError(String(json.error ?? 'Pull failed'));
665
+ model = json;
666
+ }
667
+ if (!Array.isArray(model.contentTypes)) {
668
+ throw new CliError('The model has no contentTypes');
669
+ }
670
+ const out = node_path.join(dir, 'content-types.ts');
671
+ await promises.mkdir(node_path.resolve(cwd, dir), {
672
+ recursive: true
673
+ });
674
+ await promises.writeFile(node_path.resolve(cwd, out), generateTypes(model));
675
+ const count = model.contentTypes.length;
676
+ log(`Wrote ${count} ${count === 1 ? 'type' : 'types'} to ${out}.`);
677
+ return 0;
678
+ };
679
+
514
680
  const USAGE = `Usage: content init [--dir content] [--url origin] [--skip-install]
515
681
  content model <pull|plan|apply> [--file path] [--url origin] [--prune]
682
+ content types [--dir content] [--file path] [--url origin]
516
683
 
517
684
  init start a site's repository on the token's environment: the
518
685
  model file, an example per content type, the SDK installed
@@ -520,14 +687,19 @@ const USAGE = `Usage: content init [--dir content] [--url origin] [--skip-instal
520
687
  model pull write the environment's model to the file
521
688
  model plan show what applying the file would change, and what stands in the way
522
689
  model apply apply the file in one transaction, or change nothing
690
+ types write content-types.ts: a TypeScript type per content type,
691
+ from the environment's model (or the model file with --file)
523
692
 
524
- --dir where init writes content-model.json and examples/ (default content)
693
+ --dir where init writes content-model.json and examples/, and types
694
+ content-types.ts (default content)
525
695
  --skip-install init leaves dependencies alone
526
- --file the model file (default content/content-model.json)
696
+ --file the model file (default content/content-model.json); types
697
+ reads it instead of pulling the model
527
698
  --url the CMS (default $CONTENT_URL, else ${config.DEFAULT_URL})
528
699
  --prune plan and apply also delete content types the file leaves out
529
700
 
530
- Needs CONTENT_MANAGEMENT_TOKEN: a management token from Space settings.`;
701
+ Needs CONTENT_MANAGEMENT_TOKEN: a management token from Space settings
702
+ (not for types --file).`;
531
703
  const main = async ()=>{
532
704
  let parsed;
533
705
  try {
@@ -535,8 +707,7 @@ const main = async ()=>{
535
707
  allowPositionals: true,
536
708
  options: {
537
709
  file: {
538
- type: 'string',
539
- default: 'content/content-model.json'
710
+ type: 'string'
540
711
  },
541
712
  url: {
542
713
  type: 'string'
@@ -570,13 +741,23 @@ const main = async ()=>{
570
741
  return 0;
571
742
  }
572
743
  const isInit = group === 'init' && command === undefined;
744
+ const isTypes = group === 'types' && command === undefined;
573
745
  const modelCommand = group === 'model' && (command === 'pull' || command === 'plan' || command === 'apply') ? command : null;
574
- if (!isInit && !modelCommand) throw new CliError(USAGE, 2);
746
+ if (!isInit && !isTypes && !modelCommand) throw new CliError(USAGE, 2);
575
747
  const token = process.env.CONTENT_MANAGEMENT_TOKEN;
748
+ const url = config.normalizeUrl(values.url ?? process.env.CONTENT_URL ?? config.DEFAULT_URL);
749
+ // With --file, types needs no token.
750
+ if (isTypes) {
751
+ return runTypesCommand({
752
+ dir: values.dir,
753
+ file: values.file,
754
+ url,
755
+ token
756
+ });
757
+ }
576
758
  if (!token) {
577
759
  throw new CliError(`CONTENT_MANAGEMENT_TOKEN is not set.\n\n${USAGE}`, 2);
578
760
  }
579
- const url = config.normalizeUrl(values.url ?? process.env.CONTENT_URL ?? config.DEFAULT_URL);
580
761
  if (!modelCommand) {
581
762
  return runInitCommand({
582
763
  url,
@@ -587,7 +768,7 @@ const main = async ()=>{
587
768
  }
588
769
  return runModelCommand({
589
770
  command: modelCommand,
590
- file: values.file,
771
+ file: values.file ?? 'content/content-model.json',
591
772
  url,
592
773
  token,
593
774
  prune: values.prune,
package/dist/prompt.js CHANGED
@@ -1,5 +1,7 @@
1
1
  Object.defineProperty(exports, '__esModule', { value: true });
2
2
 
3
+ var standardWebhooks = require('./standardWebhooks-kh5dc0ff.js');
4
+
3
5
  // Your own model for prompt editing in the visual editor. Set the endpoint
4
6
  // under Space settings → Prompt editing; the CMS then sends each prompt there
5
7
  // instead of to Workers AI, as a signed POST (Standard Webhooks, the same
@@ -23,48 +25,25 @@ class PromptVerificationError extends Error {
23
25
  this.name = 'PromptVerificationError';
24
26
  }
25
27
  }
26
- const SECRET_PREFIX = 'whsec_';
27
- // Seconds a request's timestamp may be off, against replays.
28
- const DEFAULT_TOLERANCE = 5 * 60;
29
- const base64ToBytes = (value)=>Uint8Array.from(atob(value), (char)=>char.charCodeAt(0));
30
28
  // Checks a request's Standard Webhooks signature and returns its body.
31
29
  // Throws PromptVerificationError when it isn't from the CMS.
32
- const verifyPromptRequest = async (request, secret, { tolerance = DEFAULT_TOLERANCE, now = Date.now() } = {})=>{
33
- if (!secret?.startsWith(SECRET_PREFIX)) {
30
+ const verifyPromptRequest = async (request, secret, { tolerance = standardWebhooks.DEFAULT_TOLERANCE, now = Date.now() } = {})=>{
31
+ if (!secret?.startsWith(standardWebhooks.SECRET_PREFIX)) {
34
32
  throw new Error('The prompt secret must be the whsec_… from Space settings');
35
33
  }
36
- const id = request.headers.get('webhook-id');
37
- const timestamp = request.headers.get('webhook-timestamp');
38
- const signatures = request.headers.get('webhook-signature');
39
- if (!id || !timestamp || !signatures) {
40
- throw new PromptVerificationError('Missing signature headers');
41
- }
42
- const seconds = Number(timestamp);
43
- if (!Number.isFinite(seconds) || Math.abs(now / 1000 - seconds) > tolerance) {
44
- throw new PromptVerificationError('Timestamp outside the tolerance');
45
- }
46
34
  const body = await request.text();
47
- const key = await crypto.subtle.importKey('raw', base64ToBytes(secret.slice(SECRET_PREFIX.length)), {
48
- name: 'HMAC',
49
- hash: 'SHA-256'
50
- }, false, [
51
- 'verify'
52
- ]);
53
- const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
54
- let valid = false;
55
- for (const signature of signatures.split(' ')){
56
- const [version, value] = signature.split(',');
57
- if (version !== 'v1' || !value) continue;
58
- let bytes;
59
- try {
60
- bytes = base64ToBytes(value);
61
- } catch {
62
- continue;
35
+ await standardWebhooks.verifySignature({
36
+ body,
37
+ id: request.headers.get('webhook-id'),
38
+ timestamp: request.headers.get('webhook-timestamp'),
39
+ signatures: request.headers.get('webhook-signature')
40
+ }, secret, {
41
+ tolerance,
42
+ now,
43
+ fail: (message)=>{
44
+ throw new PromptVerificationError(message);
63
45
  }
64
- // crypto.subtle.verify compares in constant time.
65
- if (await crypto.subtle.verify('HMAC', key, bytes, signed)) valid = true;
66
- }
67
- if (!valid) throw new PromptVerificationError('Invalid signature');
46
+ });
68
47
  let payload;
69
48
  try {
70
49
  payload = JSON.parse(body);
package/dist/prompt.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { S as SECRET_PREFIX, v as verifySignature, D as DEFAULT_TOLERANCE } from './standardWebhooks-ew5mhpac.mjs';
2
+
1
3
  // Your own model for prompt editing in the visual editor. Set the endpoint
2
4
  // under Space settings → Prompt editing; the CMS then sends each prompt there
3
5
  // instead of to Workers AI, as a signed POST (Standard Webhooks, the same
@@ -21,48 +23,25 @@ class PromptVerificationError extends Error {
21
23
  this.name = 'PromptVerificationError';
22
24
  }
23
25
  }
24
- const SECRET_PREFIX = 'whsec_';
25
- // Seconds a request's timestamp may be off, against replays.
26
- const DEFAULT_TOLERANCE = 5 * 60;
27
- const base64ToBytes = (value)=>Uint8Array.from(atob(value), (char)=>char.charCodeAt(0));
28
26
  // Checks a request's Standard Webhooks signature and returns its body.
29
27
  // Throws PromptVerificationError when it isn't from the CMS.
30
28
  const verifyPromptRequest = async (request, secret, { tolerance = DEFAULT_TOLERANCE, now = Date.now() } = {})=>{
31
29
  if (!secret?.startsWith(SECRET_PREFIX)) {
32
30
  throw new Error('The prompt secret must be the whsec_… from Space settings');
33
31
  }
34
- const id = request.headers.get('webhook-id');
35
- const timestamp = request.headers.get('webhook-timestamp');
36
- const signatures = request.headers.get('webhook-signature');
37
- if (!id || !timestamp || !signatures) {
38
- throw new PromptVerificationError('Missing signature headers');
39
- }
40
- const seconds = Number(timestamp);
41
- if (!Number.isFinite(seconds) || Math.abs(now / 1000 - seconds) > tolerance) {
42
- throw new PromptVerificationError('Timestamp outside the tolerance');
43
- }
44
32
  const body = await request.text();
45
- const key = await crypto.subtle.importKey('raw', base64ToBytes(secret.slice(SECRET_PREFIX.length)), {
46
- name: 'HMAC',
47
- hash: 'SHA-256'
48
- }, false, [
49
- 'verify'
50
- ]);
51
- const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
52
- let valid = false;
53
- for (const signature of signatures.split(' ')){
54
- const [version, value] = signature.split(',');
55
- if (version !== 'v1' || !value) continue;
56
- let bytes;
57
- try {
58
- bytes = base64ToBytes(value);
59
- } catch {
60
- continue;
33
+ await verifySignature({
34
+ body,
35
+ id: request.headers.get('webhook-id'),
36
+ timestamp: request.headers.get('webhook-timestamp'),
37
+ signatures: request.headers.get('webhook-signature')
38
+ }, secret, {
39
+ tolerance,
40
+ now,
41
+ fail: (message)=>{
42
+ throw new PromptVerificationError(message);
61
43
  }
62
- // crypto.subtle.verify compares in constant time.
63
- if (await crypto.subtle.verify('HMAC', key, bytes, signed)) valid = true;
64
- }
65
- if (!valid) throw new PromptVerificationError('Invalid signature');
44
+ });
66
45
  let payload;
67
46
  try {
68
47
  payload = JSON.parse(body);
@@ -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":[]}
@@ -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.5.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.6.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": {