@obelism/content 0.0.0-stage → 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,284 @@
1
+ import { n as normalizeUrl, a as DEFAULT_TIMEOUT, b as DELIVERY_PATH, D as DEFAULT_URL } from './config-c6qktlz6.mjs';
2
+ export { E as ENTRY_ATTRIBUTE, P as PREVIEW_PARAMS, U as UID_ATTRIBUTE, e as editable, r as readPreviewParams } from './preview-kvotryzw.mjs';
3
+
4
+ // A response from the API that wasn't a success. `message` is the API's own
5
+ // `error` text when it sent one.
6
+ class ContentApiError extends Error {
7
+ constructor(status, url, body){
8
+ const apiMessage = typeof body === 'object' && body !== null && typeof body.error === 'string' ? body.error : null;
9
+ super(apiMessage ?? `Obelism Content answered ${status} for ${url}`);
10
+ this.name = 'ContentApiError';
11
+ this.status = status;
12
+ this.url = url;
13
+ this.body = body;
14
+ }
15
+ }
16
+
17
+ const trimSlashes = (path)=>path.replace(/^\/+|\/+$/g, '');
18
+ // Reads one space environment's content through the delivery API. The token
19
+ // decides the space, the environment and whether drafts are visible.
20
+ //
21
+ // const content = new ContentClient({ token: process.env.CONTENT_TOKEN })
22
+ // const page = await content.getPage('blog/hello', { locale: 'nl' })
23
+ class ContentClient {
24
+ #token;
25
+ #defaults;
26
+ #timeout;
27
+ #init;
28
+ #fetch;
29
+ constructor({ token, url, locale, depth, richText, timeout, init, fetch: fetchImpl }){
30
+ // The page at a path (`blog/hello`), or null when there is none. A site's
31
+ // catch-all route asks for this.
32
+ this.getPage = async (path, options = {})=>{
33
+ const trimmed = trimSlashes(path);
34
+ if (!trimmed) throw new Error('getPage needs a path');
35
+ const result = await this.#request('/pages', {
36
+ path: trimmed
37
+ }, options, {
38
+ notFound: true
39
+ });
40
+ return result?.entry ?? null;
41
+ };
42
+ // An entry by id, or null when there is none (or it isn't published, for a
43
+ // delivery token).
44
+ this.getEntry = async (id, options = {})=>{
45
+ const result = await this.#request(`/entries/${encodeURIComponent(id)}`, {}, options, {
46
+ notFound: true
47
+ });
48
+ return result?.entry ?? null;
49
+ };
50
+ // One page of entries, ordered as in the space's tree.
51
+ this.getEntries = ({ contentType, parentPath, limit, skip, ...options } = {})=>this.#request('/entries', {
52
+ contentType,
53
+ parentPath: parentPath === undefined ? undefined : trimSlashes(parentPath),
54
+ limit,
55
+ skip
56
+ }, options);
57
+ // The environment's content model, e.g. to generate types in a build.
58
+ this.getContentModel = (options = {})=>this.#request('/content-model', {}, options, {
59
+ defaults: false
60
+ });
61
+ this.url = normalizeUrl(url || DEFAULT_URL);
62
+ this.#token = token;
63
+ this.#defaults = {
64
+ locale,
65
+ depth,
66
+ richText
67
+ };
68
+ this.#timeout = timeout ?? DEFAULT_TIMEOUT;
69
+ this.#init = init;
70
+ this.#fetch = fetchImpl ?? ((...args)=>fetch(...args));
71
+ }
72
+ // Every entry matching the query, fetching pages of `limit` (default 100)
73
+ // as it goes.
74
+ //
75
+ // for await (const post of content.getAllEntries({ contentType: 'post' }))
76
+ async *getAllEntries(query = {}) {
77
+ const limit = query.limit ?? 100;
78
+ let skip = 0;
79
+ while(true){
80
+ const page = await this.getEntries({
81
+ ...query,
82
+ limit,
83
+ skip
84
+ });
85
+ yield* page.items;
86
+ skip += page.items.length;
87
+ if (page.items.length === 0 || skip >= page.total) return;
88
+ }
89
+ }
90
+ // null for a 404 when `notFound` is set; throws ContentApiError otherwise.
91
+ async #request(path, query, { init, ...options }, { notFound = false, defaults = true } = {}) {
92
+ // Checked here rather than in the constructor, so a module creating a
93
+ // client still loads (and a build still runs) without the variable.
94
+ if (!this.#token) throw new Error('ContentClient needs a token');
95
+ const { locale, depth, richText } = {
96
+ ...defaults && this.#defaults,
97
+ ...withoutUndefined(options)
98
+ };
99
+ const params = new URLSearchParams();
100
+ const values = {
101
+ ...query,
102
+ locale,
103
+ depth,
104
+ richtext: richText
105
+ };
106
+ for (const [key, value] of Object.entries(values)){
107
+ if (value !== undefined) params.set(key, String(value));
108
+ }
109
+ const search = params.size > 0 ? `?${params}` : '';
110
+ const url = `${this.url}${DELIVERY_PATH}${path}${search}`;
111
+ const merged = {
112
+ ...this.#init,
113
+ ...init
114
+ };
115
+ const timeout = AbortSignal.timeout(this.#timeout);
116
+ const response = await this.#fetch(url, {
117
+ ...merged,
118
+ headers: {
119
+ ...headersToObject(merged.headers),
120
+ Accept: 'application/json',
121
+ Authorization: `Bearer ${this.#token}`
122
+ },
123
+ signal: merged.signal ? AbortSignal.any([
124
+ merged.signal,
125
+ timeout
126
+ ]) : timeout
127
+ });
128
+ if (notFound && response.status === 404) return null;
129
+ const text = await response.text();
130
+ let body = text;
131
+ try {
132
+ body = JSON.parse(text);
133
+ } catch {
134
+ // Not JSON: keep the text for the error.
135
+ }
136
+ if (!response.ok) throw new ContentApiError(response.status, url, body);
137
+ return body;
138
+ }
139
+ }
140
+ const withoutUndefined = (value)=>Object.fromEntries(Object.entries(value).filter(([, entry])=>entry !== undefined));
141
+ const headersToObject = (headers)=>headers ? Object.fromEntries(new Headers(headers)) : {};
142
+
143
+ const KEYS = [
144
+ 'w',
145
+ 'h',
146
+ 'fit',
147
+ 'format',
148
+ 'q'
149
+ ];
150
+ // An asset's URL with a resize / re-encode query. PNG, JPEG, GIF, WebP and
151
+ // AVIF are transformed; other files ignore the query.
152
+ //
153
+ // imageUrl(asset, { w: 800 })
154
+ const imageUrl = (asset, transform = {})=>{
155
+ const url = new URL(typeof asset === 'string' ? asset : asset.url);
156
+ for (const key of KEYS){
157
+ const value = transform[key];
158
+ if (value !== undefined) url.searchParams.set(key, String(value));
159
+ }
160
+ return url.toString();
161
+ };
162
+ // A `srcset` with one candidate per width.
163
+ //
164
+ // <img src={imageUrl(asset, { w: 960 })}
165
+ // srcSet={imageSrcSet(asset, [480, 960, 1440])}
166
+ // sizes="(min-width: 60rem) 50vw, 100vw" />
167
+ const imageSrcSet = (asset, widths, transform = {})=>widths.map((w)=>`${imageUrl(asset, {
168
+ ...transform,
169
+ w
170
+ })} ${w}w`).join(', ');
171
+
172
+ // Links are only rendered for these; anything else (`javascript:` …) keeps its
173
+ // text without the link. The same rule the CMS validates with.
174
+ const SAFE_HREF = /^(https?:\/\/|mailto:|tel:|#|\/(?!\/))/i;
175
+ const escapeHtml = (value)=>value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
176
+ const MARK_TAGS = {
177
+ bold: 'strong',
178
+ italic: 'em',
179
+ underline: 'u',
180
+ strike: 's',
181
+ code: 'code'
182
+ };
183
+ const defaultRenderAsset = (asset)=>{
184
+ const url = escapeHtml(asset.url);
185
+ if (asset.mimeType.startsWith('image/')) {
186
+ const alt = typeof asset.alt === 'string' ? asset.alt : '';
187
+ const size = asset.width && asset.height ? ` width="${asset.width}" height="${asset.height}"` : '';
188
+ return `<figure><img src="${url}" alt="${escapeHtml(alt)}"${size}></figure>`;
189
+ }
190
+ return `<p><a href="${url}">${escapeHtml(asset.filename)}</a></p>`;
191
+ };
192
+ // Rich text JSON (what the delivery API answers by default) to plain, safe
193
+ // HTML — the same HTML the API renders with `richText: 'html'`, except that
194
+ // embeds are up to you.
195
+ const richTextToHtml = (doc, { renderEntry, renderAsset = defaultRenderAsset } = {})=>{
196
+ if (!doc) return '';
197
+ const marks = (node, html)=>(node.marks ?? []).reduceRight((inner, mark)=>{
198
+ if (mark.type === 'link') {
199
+ const href = String(mark.attrs?.href ?? '');
200
+ if (!SAFE_HREF.test(href)) return inner;
201
+ const target = mark.attrs?.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
202
+ const title = mark.attrs?.title ? ` title="${escapeHtml(String(mark.attrs.title))}"` : '';
203
+ return `<a href="${escapeHtml(href)}"${target}${title}>${inner}</a>`;
204
+ }
205
+ const tag = MARK_TAGS[mark.type];
206
+ return tag ? `<${tag}>${inner}</${tag}>` : inner;
207
+ }, html);
208
+ const render = (node)=>{
209
+ const children = ()=>(node.content ?? []).map(render).join('');
210
+ switch(node.type){
211
+ case 'doc':
212
+ return children();
213
+ case 'text':
214
+ return marks(node, escapeHtml(node.text ?? ''));
215
+ case 'hardBreak':
216
+ return '<br>';
217
+ case 'paragraph':
218
+ return `<p>${children()}</p>`;
219
+ case 'heading':
220
+ {
221
+ const tag = `h${headingLevel(node)}`;
222
+ return `<${tag}>${children()}</${tag}>`;
223
+ }
224
+ case 'blockquote':
225
+ return `<blockquote>${children()}</blockquote>`;
226
+ case 'bulletList':
227
+ return `<ul>${children()}</ul>`;
228
+ case 'orderedList':
229
+ {
230
+ const start = Number(node.attrs?.start ?? 1);
231
+ return `<ol${start !== 1 ? ` start="${start}"` : ''}>${children()}</ol>`;
232
+ }
233
+ case 'listItem':
234
+ return `<li>${children()}</li>`;
235
+ case 'codeBlock':
236
+ {
237
+ const language = node.attrs?.language;
238
+ const className = language ? ` class="language-${escapeHtml(String(language))}"` : '';
239
+ return `<pre><code${className}>${children()}</code></pre>`;
240
+ }
241
+ case 'horizontalRule':
242
+ return '<hr>';
243
+ case 'embeddedEntry':
244
+ {
245
+ const entry = node.attrs?.entry;
246
+ return entry && renderEntry ? renderEntry(entry, node) : '';
247
+ }
248
+ case 'embeddedAsset':
249
+ {
250
+ const asset = node.attrs?.asset;
251
+ return asset ? renderAsset(asset, node) : '';
252
+ }
253
+ default:
254
+ return '';
255
+ }
256
+ };
257
+ return render(doc);
258
+ };
259
+ const headingLevel = (node)=>{
260
+ const level = Number(node.attrs?.level);
261
+ return level >= 1 && level <= 6 ? level : 2;
262
+ };
263
+ // The text of a document without any markup, e.g. for a meta description.
264
+ const richTextToPlainText = (doc)=>{
265
+ if (!doc) return '';
266
+ const BLOCKS = new Set([
267
+ 'paragraph',
268
+ 'heading',
269
+ 'blockquote',
270
+ 'listItem',
271
+ 'codeBlock'
272
+ ]);
273
+ const parts = [];
274
+ const walk = (node)=>{
275
+ if (node.type === 'text') parts.push(node.text ?? '');
276
+ else if (node.type === 'hardBreak') parts.push('\n');
277
+ node.content?.forEach(walk);
278
+ if (BLOCKS.has(node.type)) parts.push('\n');
279
+ };
280
+ walk(doc);
281
+ return parts.join('').replace(/\n+/g, '\n').trim();
282
+ };
283
+
284
+ export { ContentApiError, ContentClient, DEFAULT_URL, SAFE_HREF, escapeHtml, headingLevel, imageSrcSet, imageUrl, richTextToHtml, richTextToPlainText };
@@ -0,0 +1,68 @@
1
+ const UID_ATTRIBUTE = 'data-cms-uid';
2
+ const ENTRY_ATTRIBUTE = 'data-cms-entry';
3
+ // The attribute that lets editors select a block on the page, in the visual
4
+ // editor and on the site. Spread it on the block's outermost element:
5
+ //
6
+ // <section {...editable(block)}>…</section>
7
+ //
8
+ // Given an entry, it marks the element the entry renders into, which editing
9
+ // on the site (outside the visual editor) needs to know what a prompt, Save
10
+ // and Publish act on:
11
+ //
12
+ // <main {...editable(entry)}>…</main>
13
+ const editable = (target)=>{
14
+ if (!target) return {};
15
+ if ('_uid' in target && target._uid) return {
16
+ 'data-cms-uid': target._uid
17
+ };
18
+ if ('id' in target && 'fields' in target && target.id) {
19
+ return {
20
+ 'data-cms-entry': target.id
21
+ };
22
+ }
23
+ return {};
24
+ };
25
+
26
+ // The visual editor loads the site in an iframe at the environment's preview
27
+ // URL plus the entry's path, with these parameters added. A request carrying
28
+ // `cms_preview` should be answered with drafts: fetch with a preview token.
29
+ const PREVIEW_PARAMS = {
30
+ preview: 'cms_preview',
31
+ entry: 'cms_entry',
32
+ environment: 'cms_environment'
33
+ };
34
+ const toSearchParams = (input)=>{
35
+ if (input instanceof URL) return input.searchParams;
36
+ if (input instanceof URLSearchParams) return input;
37
+ if (typeof input === 'string') {
38
+ return input.includes('://') ? new URL(input).searchParams : new URLSearchParams(input);
39
+ }
40
+ const params = new URLSearchParams();
41
+ for (const [key, value] of Object.entries(input)){
42
+ if (value === undefined) continue;
43
+ for (const item of Array.isArray(value) ? value : [
44
+ value
45
+ ]){
46
+ params.append(key, item);
47
+ }
48
+ }
49
+ return params;
50
+ };
51
+ // The editor's parameters when this is a preview request, else null.
52
+ //
53
+ // const preview = readPreviewParams(request.url)
54
+ // const content = preview ? previewClient : deliveryClient
55
+ const readPreviewParams = (input)=>{
56
+ const params = toSearchParams(input);
57
+ if (!params.has(PREVIEW_PARAMS.preview)) return null;
58
+ return {
59
+ entryId: params.get(PREVIEW_PARAMS.entry),
60
+ environment: params.get(PREVIEW_PARAMS.environment)
61
+ };
62
+ };
63
+
64
+ exports.ENTRY_ATTRIBUTE = ENTRY_ATTRIBUTE;
65
+ exports.PREVIEW_PARAMS = PREVIEW_PARAMS;
66
+ exports.UID_ATTRIBUTE = UID_ATTRIBUTE;
67
+ exports.editable = editable;
68
+ exports.readPreviewParams = readPreviewParams;
@@ -0,0 +1,64 @@
1
+ const UID_ATTRIBUTE = 'data-cms-uid';
2
+ const ENTRY_ATTRIBUTE = 'data-cms-entry';
3
+ // The attribute that lets editors select a block on the page, in the visual
4
+ // editor and on the site. Spread it on the block's outermost element:
5
+ //
6
+ // <section {...editable(block)}>…</section>
7
+ //
8
+ // Given an entry, it marks the element the entry renders into, which editing
9
+ // on the site (outside the visual editor) needs to know what a prompt, Save
10
+ // and Publish act on:
11
+ //
12
+ // <main {...editable(entry)}>…</main>
13
+ const editable = (target)=>{
14
+ if (!target) return {};
15
+ if ('_uid' in target && target._uid) return {
16
+ 'data-cms-uid': target._uid
17
+ };
18
+ if ('id' in target && 'fields' in target && target.id) {
19
+ return {
20
+ 'data-cms-entry': target.id
21
+ };
22
+ }
23
+ return {};
24
+ };
25
+
26
+ // The visual editor loads the site in an iframe at the environment's preview
27
+ // URL plus the entry's path, with these parameters added. A request carrying
28
+ // `cms_preview` should be answered with drafts: fetch with a preview token.
29
+ const PREVIEW_PARAMS = {
30
+ preview: 'cms_preview',
31
+ entry: 'cms_entry',
32
+ environment: 'cms_environment'
33
+ };
34
+ const toSearchParams = (input)=>{
35
+ if (input instanceof URL) return input.searchParams;
36
+ if (input instanceof URLSearchParams) return input;
37
+ if (typeof input === 'string') {
38
+ return input.includes('://') ? new URL(input).searchParams : new URLSearchParams(input);
39
+ }
40
+ const params = new URLSearchParams();
41
+ for (const [key, value] of Object.entries(input)){
42
+ if (value === undefined) continue;
43
+ for (const item of Array.isArray(value) ? value : [
44
+ value
45
+ ]){
46
+ params.append(key, item);
47
+ }
48
+ }
49
+ return params;
50
+ };
51
+ // The editor's parameters when this is a preview request, else null.
52
+ //
53
+ // const preview = readPreviewParams(request.url)
54
+ // const content = preview ? previewClient : deliveryClient
55
+ const readPreviewParams = (input)=>{
56
+ const params = toSearchParams(input);
57
+ if (!params.has(PREVIEW_PARAMS.preview)) return null;
58
+ return {
59
+ entryId: params.get(PREVIEW_PARAMS.entry),
60
+ environment: params.get(PREVIEW_PARAMS.environment)
61
+ };
62
+ };
63
+
64
+ export { ENTRY_ATTRIBUTE as E, PREVIEW_PARAMS as P, UID_ATTRIBUTE as U, editable as e, readPreviewParams as r };
@@ -0,0 +1,26 @@
1
+ type PromptMessage = {
2
+ role: 'user' | 'assistant';
3
+ content: string;
4
+ };
5
+ type PromptRequest = {
6
+ type: 'content.prompt';
7
+ system: string;
8
+ messages: PromptMessage[];
9
+ schema: Record<string, unknown>;
10
+ };
11
+ declare class PromptVerificationError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ declare const verifyPromptRequest: (request: Request, secret: string, { tolerance, now }?: {
15
+ tolerance?: number | undefined;
16
+ now?: number | undefined;
17
+ }) => Promise<PromptRequest>;
18
+ type PromptHandlerOptions = {
19
+ secret: string;
20
+ generate: (request: PromptRequest) => Promise<unknown>;
21
+ };
22
+ declare const createPromptHandler: ({ secret, generate }: PromptHandlerOptions) => (request: Request) => Promise<Response>;
23
+
24
+ export { PromptVerificationError, createPromptHandler, verifyPromptRequest };
25
+ export type { PromptHandlerOptions, PromptMessage, PromptRequest };
26
+ //# sourceMappingURL=prompt.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prompt.d.mts","sources":["../src/prompt.ts"],"mappings":"AAAA,KAAK,aAAa;AAClB;AACA;AACA;AACA,KAAK,aAAa;AAClB;AACA;AACA,cAAc,aAAa;AAC3B,YAAY,MAAM;AAClB;AACA,cAAc,uBAAuB,SAAS,KAAK;AACnD;AACA;AACA,cAAc,mBAAmB,YAAY,OAAO;AACpD;AACA;AACA,MAAM,OAAO,CAAC,aAAa;AAC3B,KAAK,oBAAoB;AACzB;AACA,wBAAwB,aAAa,KAAK,OAAO;AACjD;AACA,cAAc,mBAAmB,yBAAyB,oBAAoB,eAAe,OAAO,KAAK,OAAO,CAAC,QAAQ;;","names":[]}
@@ -0,0 +1,26 @@
1
+ type PromptMessage = {
2
+ role: 'user' | 'assistant';
3
+ content: string;
4
+ };
5
+ type PromptRequest = {
6
+ type: 'content.prompt';
7
+ system: string;
8
+ messages: PromptMessage[];
9
+ schema: Record<string, unknown>;
10
+ };
11
+ declare class PromptVerificationError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ declare const verifyPromptRequest: (request: Request, secret: string, { tolerance, now }?: {
15
+ tolerance?: number | undefined;
16
+ now?: number | undefined;
17
+ }) => Promise<PromptRequest>;
18
+ type PromptHandlerOptions = {
19
+ secret: string;
20
+ generate: (request: PromptRequest) => Promise<unknown>;
21
+ };
22
+ declare const createPromptHandler: ({ secret, generate }: PromptHandlerOptions) => (request: Request) => Promise<Response>;
23
+
24
+ export { PromptVerificationError, createPromptHandler, verifyPromptRequest };
25
+ export type { PromptHandlerOptions, PromptMessage, PromptRequest };
26
+ //# sourceMappingURL=prompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prompt.d.ts","sources":["../src/prompt.ts"],"mappings":"AAAA,KAAK,aAAa;AAClB;AACA;AACA;AACA,KAAK,aAAa;AAClB;AACA;AACA,cAAc,aAAa;AAC3B,YAAY,MAAM;AAClB;AACA,cAAc,uBAAuB,SAAS,KAAK;AACnD;AACA;AACA,cAAc,mBAAmB,YAAY,OAAO;AACpD;AACA;AACA,MAAM,OAAO,CAAC,aAAa;AAC3B,KAAK,oBAAoB;AACzB;AACA,wBAAwB,aAAa,KAAK,OAAO;AACjD;AACA,cAAc,mBAAmB,yBAAyB,oBAAoB,eAAe,OAAO,KAAK,OAAO,CAAC,QAAQ;;","names":[]}
package/dist/prompt.js ADDED
@@ -0,0 +1,112 @@
1
+ Object.defineProperty(exports, '__esModule', { value: true });
2
+
3
+ // Your own model for prompt editing in the visual editor. Set the endpoint
4
+ // under Space settings → Prompt editing; the CMS then sends each prompt there
5
+ // instead of to Workers AI, as a signed POST (Standard Webhooks, the same
6
+ // scheme as webhooks), with the instructions, the conversation and the JSON
7
+ // Schema of the answer already written. Your endpoint only runs a model:
8
+ //
9
+ // // app/api/content-prompt/route.ts
10
+ // import { createPromptHandler } from '@obelism/content/prompt'
11
+ //
12
+ // export const POST = createPromptHandler({
13
+ // secret: process.env.CONTENT_PROMPT_SECRET,
14
+ // generate: async ({ system, messages, schema }) => {
15
+ // // … call any model; return the answer object (or its JSON text)
16
+ // },
17
+ // })
18
+ //
19
+ // The CMS checks the answer like any edit, so a bad one changes nothing.
20
+ class PromptVerificationError extends Error {
21
+ constructor(message){
22
+ super(message);
23
+ this.name = 'PromptVerificationError';
24
+ }
25
+ }
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
+ // Checks a request's Standard Webhooks signature and returns its body.
31
+ // 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)) {
34
+ throw new Error('The prompt secret must be the whsec_… from Space settings');
35
+ }
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
+ 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;
63
+ }
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');
68
+ let payload;
69
+ try {
70
+ payload = JSON.parse(body);
71
+ } catch {
72
+ throw new PromptVerificationError('The body is not JSON');
73
+ }
74
+ if (payload.type !== 'content.prompt') {
75
+ throw new PromptVerificationError(`Unexpected type "${payload.type}"`);
76
+ }
77
+ return payload;
78
+ };
79
+ // A fetch-style handler (Request → Response) for the prompt endpoint: Next.js
80
+ // route handlers, Hono, Workers, Bun, Deno. Answers 401 to anything not signed
81
+ // by the CMS and { output } otherwise.
82
+ const createPromptHandler = ({ secret, generate })=>async (request)=>{
83
+ let payload;
84
+ try {
85
+ payload = await verifyPromptRequest(request, secret);
86
+ } catch (error) {
87
+ if (error instanceof PromptVerificationError) {
88
+ return Response.json({
89
+ error: error.message
90
+ }, {
91
+ status: 401
92
+ });
93
+ }
94
+ throw error;
95
+ }
96
+ try {
97
+ return Response.json({
98
+ output: await generate(payload)
99
+ });
100
+ } catch (error) {
101
+ console.error('[@obelism/content prompt]', error);
102
+ return Response.json({
103
+ error: 'The model failed'
104
+ }, {
105
+ status: 502
106
+ });
107
+ }
108
+ };
109
+
110
+ exports.PromptVerificationError = PromptVerificationError;
111
+ exports.createPromptHandler = createPromptHandler;
112
+ exports.verifyPromptRequest = verifyPromptRequest;