@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 +82 -0
- package/dist/bin/content.js +192 -11
- 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
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`.
|
package/dist/bin/content.js
CHANGED
|
@@ -256,9 +256,8 @@ class CliError extends Error {
|
|
|
256
256
|
super(message), this.exitCode = exitCode;
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
|
-
//
|
|
260
|
-
const
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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":[]}
|
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.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": {
|