@obelism/content 0.28.3 → 0.29.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/bridge.d.mts +1 -1
- package/dist/bridge.d.mts.map +1 -1
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.global.js +4 -4
- package/dist/bridge.js +423 -374
- package/dist/bridge.mjs +424 -375
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +44 -4
- package/dist/index.mjs +45 -5
- package/dist/{preview-el7sfk3h.mjs → preview-9qff497d.mjs} +1 -1
- package/dist/site-j5kqgtj3.js +368 -0
- package/dist/site-m85jdjid.mjs +360 -0
- package/dist/site.js +10 -340
- package/dist/site.mjs +2 -336
- package/dist/{types-b6jbj3g6.d.ts → types-mqo1osjh.d.ts} +2 -1
- package/dist/{types-b6jbj3g6.d.ts.map → types-mqo1osjh.d.ts.map} +1 -1
- package/package.json +5 -5
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TokenSource } from './oidc.mjs';
|
|
2
|
-
import { R as RichTextFormat, E as Entry, a as RedirectList, b as Redirect, c as EntryList, M as ModelFile, A as Asset, B as Block, I as ImproveBlock, S as ShopifyItem, L as LinkTarget, d as Link, e as EntryStub, f as RichTextNode } from './types-
|
|
3
|
-
export { g as AssetProduct, C as ContentTypeAccent, h as ContentTypeDefinition, i as ContentTypeKind, j as Environment, F as FieldCondition, k as FieldDefinition, l as FieldGroup, m as FieldType, n as RichTextDocument, o as RichTextMark } from './types-
|
|
2
|
+
import { R as RichTextFormat, E as Entry, a as RedirectList, b as Redirect, c as EntryList, M as ModelFile, A as Asset, B as Block, I as ImproveBlock, S as ShopifyItem, L as LinkTarget, d as Link, e as EntryStub, f as RichTextNode } from './types-mqo1osjh.js';
|
|
3
|
+
export { g as AssetProduct, C as ContentTypeAccent, h as ContentTypeDefinition, i as ContentTypeKind, j as Environment, F as FieldCondition, k as FieldDefinition, l as FieldGroup, m as FieldType, n as RichTextDocument, o as RichTextMark } from './types-mqo1osjh.js';
|
|
4
4
|
|
|
5
5
|
type ContentClientOptions = {
|
|
6
6
|
token: TokenSource;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TokenSource } from './oidc.js';
|
|
2
|
-
import { R as RichTextFormat, E as Entry, a as RedirectList, b as Redirect, c as EntryList, M as ModelFile, A as Asset, B as Block, I as ImproveBlock, S as ShopifyItem, L as LinkTarget, d as Link, e as EntryStub, f as RichTextNode } from './types-
|
|
3
|
-
export { g as AssetProduct, C as ContentTypeAccent, h as ContentTypeDefinition, i as ContentTypeKind, j as Environment, F as FieldCondition, k as FieldDefinition, l as FieldGroup, m as FieldType, n as RichTextDocument, o as RichTextMark } from './types-
|
|
2
|
+
import { R as RichTextFormat, E as Entry, a as RedirectList, b as Redirect, c as EntryList, M as ModelFile, A as Asset, B as Block, I as ImproveBlock, S as ShopifyItem, L as LinkTarget, d as Link, e as EntryStub, f as RichTextNode } from './types-mqo1osjh.js';
|
|
3
|
+
export { g as AssetProduct, C as ContentTypeAccent, h as ContentTypeDefinition, i as ContentTypeKind, j as Environment, F as FieldCondition, k as FieldDefinition, l as FieldGroup, m as FieldType, n as RichTextDocument, o as RichTextMark } from './types-mqo1osjh.js';
|
|
4
4
|
|
|
5
5
|
type ContentClientOptions = {
|
|
6
6
|
token: TokenSource;
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,9 @@ const fieldParams = (fields)=>Object.fromEntries(Object.entries(fields ?? {}).ma
|
|
|
33
33
|
];
|
|
34
34
|
}));
|
|
35
35
|
const trimSlashes = (path)=>path.replace(/^\/+|\/+$/g, '');
|
|
36
|
+
// `/space_…/<environment>`: the part of a scoped delivery path between
|
|
37
|
+
// DELIVERY_PATH and the operation.
|
|
38
|
+
const SCOPE = /^\/space_[^/]+\/[^/]+$/;
|
|
36
39
|
// Reads one space environment's content through the delivery API. The token
|
|
37
40
|
// decides the space, the environment and whether drafts are visible (for a
|
|
38
41
|
// host token, with `drafts`).
|
|
@@ -42,11 +45,17 @@ const trimSlashes = (path)=>path.replace(/^\/+|\/+$/g, '');
|
|
|
42
45
|
class ContentClient {
|
|
43
46
|
#token;
|
|
44
47
|
#place;
|
|
48
|
+
// Where the API answers this client's token: it sends a token that reads
|
|
49
|
+
// published content to `/api/delivery/v1/{space}/{environment}/…` with a
|
|
50
|
+
// 308, so the client asks there directly after the first one. Never used
|
|
51
|
+
// for a request that reads drafts, which the scoped path refuses.
|
|
52
|
+
#scope;
|
|
45
53
|
#defaults;
|
|
46
54
|
#timeout;
|
|
47
55
|
#init;
|
|
48
56
|
#fetch;
|
|
49
57
|
constructor({ token, drafts, space, environment, url, locale, depth, richText, timeout, init, fetch: fetchImpl }){
|
|
58
|
+
this.#scope = null;
|
|
50
59
|
// The page at a path (`blog/hello`), or null when there is none. A site's
|
|
51
60
|
// catch-all route asks for this. The path is looked up in `locale`'s
|
|
52
61
|
// paths (the default locale's when left out or `*`): pages can have a
|
|
@@ -171,7 +180,7 @@ class ContentClient {
|
|
|
171
180
|
}
|
|
172
181
|
// For a 404, null when `notFound` is set, or the body as is for 'body';
|
|
173
182
|
// throws ContentApiError otherwise.
|
|
174
|
-
async #request(path, query, { init, ...options }, { notFound = false, defaults = true } = {}) {
|
|
183
|
+
async #request(path, query, { init, ...options }, { notFound = false, defaults = true, scoped = true } = {}) {
|
|
175
184
|
// Checked here rather than in the constructor, so a module creating a
|
|
176
185
|
// client still loads (and a build still runs) without the variable.
|
|
177
186
|
if (!this.#token) throw new Error('ContentClient needs a token');
|
|
@@ -190,7 +199,8 @@ class ContentClient {
|
|
|
190
199
|
if (value !== undefined) params.set(key, String(value));
|
|
191
200
|
}
|
|
192
201
|
const search = params.size > 0 ? `?${params}` : '';
|
|
193
|
-
const
|
|
202
|
+
const scope = drafts || !scoped ? null : this.#scope;
|
|
203
|
+
const url = `${this.url}${config.DELIVERY_PATH}${scope ?? ''}${path}${search}`;
|
|
194
204
|
const merged = {
|
|
195
205
|
...this.#init,
|
|
196
206
|
...init
|
|
@@ -215,6 +225,21 @@ class ContentClient {
|
|
|
215
225
|
timeout
|
|
216
226
|
]) : timeout
|
|
217
227
|
});
|
|
228
|
+
// A token that changed, or now reads drafts, is refused on the path
|
|
229
|
+
// learned for the one before it: ask unscoped once, and learn again.
|
|
230
|
+
if (scope && (response.status === 401 || response.status === 403)) {
|
|
231
|
+
if (this.#scope === scope) this.#scope = null;
|
|
232
|
+
await response.body?.cancel();
|
|
233
|
+
return this.#request(path, query, {
|
|
234
|
+
init,
|
|
235
|
+
...options
|
|
236
|
+
}, {
|
|
237
|
+
notFound,
|
|
238
|
+
defaults,
|
|
239
|
+
scoped: false
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (!scope && !drafts && response.redirected) this.#learnScope(response, path);
|
|
218
243
|
if (notFound === true && response.status === 404) return null;
|
|
219
244
|
const text = await response.text();
|
|
220
245
|
let body = text;
|
|
@@ -229,6 +254,18 @@ class ContentClient {
|
|
|
229
254
|
if (!response.ok) throw new ContentApiError(response.status, url, body);
|
|
230
255
|
return body;
|
|
231
256
|
}
|
|
257
|
+
#learnScope(response, path) {
|
|
258
|
+
let redirected;
|
|
259
|
+
try {
|
|
260
|
+
redirected = new URL(response.url);
|
|
261
|
+
} catch {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const { origin, pathname } = redirected;
|
|
265
|
+
if (origin !== new URL(this.url).origin || !pathname.startsWith(`${config.DELIVERY_PATH}/`) || !pathname.endsWith(path)) return;
|
|
266
|
+
const scope = pathname.slice(config.DELIVERY_PATH.length, pathname.length - path.length);
|
|
267
|
+
if (SCOPE.test(scope)) this.#scope = scope;
|
|
268
|
+
}
|
|
232
269
|
}
|
|
233
270
|
const withoutUndefined = (value)=>Object.fromEntries(Object.entries(value).filter(([, entry])=>entry !== undefined));
|
|
234
271
|
const headersToObject = (headers)=>headers ? Object.fromEntries(new Headers(headers)) : {};
|
|
@@ -277,11 +314,14 @@ const isImproveBlock = (block)=>block._type === IMPROVE_BLOCK;
|
|
|
277
314
|
// improve's SDK returned for the visitor (getTestValue / getFlagValue). It
|
|
278
315
|
// is null when the test or flag isn't active or the visitor isn't in it;
|
|
279
316
|
// then, or for an option the block has no variant for, the `fallback`
|
|
280
|
-
// option's variant, else the first one
|
|
317
|
+
// option's variant, else the first one.
|
|
281
318
|
const pickImproveVariant = (block, option, fallback)=>{
|
|
282
319
|
const variants = block.variants ?? {};
|
|
283
320
|
if (option && variants[option]) return variants[option];
|
|
284
|
-
|
|
321
|
+
// The arm Improve gives a visitor outside the test, when the caller names
|
|
322
|
+
// none: it is not always the first variant.
|
|
323
|
+
const behind = fallback ?? block.fallback;
|
|
324
|
+
if (behind && variants[behind]) return variants[behind];
|
|
285
325
|
return Object.values(variants)[0] ?? [];
|
|
286
326
|
};
|
|
287
327
|
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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,
|
|
2
|
+
export { E as ENTRY_ATTRIBUTE, P as PREVIEW_PARAMS, c as PRODUCT_ATTRIBUTE, b as PRODUCT_HANDLE_ATTRIBUTE, U as UID_ATTRIBUTE, e as editable, d as editableCollection, f as editableProduct, r as readPreviewParams } from './preview-9qff497d.mjs';
|
|
3
3
|
|
|
4
4
|
// A response from the API that wasn't a success. `message` is the API's own
|
|
5
5
|
// `error` text when it sent one.
|
|
@@ -31,6 +31,9 @@ const fieldParams = (fields)=>Object.fromEntries(Object.entries(fields ?? {}).ma
|
|
|
31
31
|
];
|
|
32
32
|
}));
|
|
33
33
|
const trimSlashes = (path)=>path.replace(/^\/+|\/+$/g, '');
|
|
34
|
+
// `/space_…/<environment>`: the part of a scoped delivery path between
|
|
35
|
+
// DELIVERY_PATH and the operation.
|
|
36
|
+
const SCOPE = /^\/space_[^/]+\/[^/]+$/;
|
|
34
37
|
// Reads one space environment's content through the delivery API. The token
|
|
35
38
|
// decides the space, the environment and whether drafts are visible (for a
|
|
36
39
|
// host token, with `drafts`).
|
|
@@ -40,11 +43,17 @@ const trimSlashes = (path)=>path.replace(/^\/+|\/+$/g, '');
|
|
|
40
43
|
class ContentClient {
|
|
41
44
|
#token;
|
|
42
45
|
#place;
|
|
46
|
+
// Where the API answers this client's token: it sends a token that reads
|
|
47
|
+
// published content to `/api/delivery/v1/{space}/{environment}/…` with a
|
|
48
|
+
// 308, so the client asks there directly after the first one. Never used
|
|
49
|
+
// for a request that reads drafts, which the scoped path refuses.
|
|
50
|
+
#scope;
|
|
43
51
|
#defaults;
|
|
44
52
|
#timeout;
|
|
45
53
|
#init;
|
|
46
54
|
#fetch;
|
|
47
55
|
constructor({ token, drafts, space, environment, url, locale, depth, richText, timeout, init, fetch: fetchImpl }){
|
|
56
|
+
this.#scope = null;
|
|
48
57
|
// The page at a path (`blog/hello`), or null when there is none. A site's
|
|
49
58
|
// catch-all route asks for this. The path is looked up in `locale`'s
|
|
50
59
|
// paths (the default locale's when left out or `*`): pages can have a
|
|
@@ -169,7 +178,7 @@ class ContentClient {
|
|
|
169
178
|
}
|
|
170
179
|
// For a 404, null when `notFound` is set, or the body as is for 'body';
|
|
171
180
|
// throws ContentApiError otherwise.
|
|
172
|
-
async #request(path, query, { init, ...options }, { notFound = false, defaults = true } = {}) {
|
|
181
|
+
async #request(path, query, { init, ...options }, { notFound = false, defaults = true, scoped = true } = {}) {
|
|
173
182
|
// Checked here rather than in the constructor, so a module creating a
|
|
174
183
|
// client still loads (and a build still runs) without the variable.
|
|
175
184
|
if (!this.#token) throw new Error('ContentClient needs a token');
|
|
@@ -188,7 +197,8 @@ class ContentClient {
|
|
|
188
197
|
if (value !== undefined) params.set(key, String(value));
|
|
189
198
|
}
|
|
190
199
|
const search = params.size > 0 ? `?${params}` : '';
|
|
191
|
-
const
|
|
200
|
+
const scope = drafts || !scoped ? null : this.#scope;
|
|
201
|
+
const url = `${this.url}${DELIVERY_PATH}${scope ?? ''}${path}${search}`;
|
|
192
202
|
const merged = {
|
|
193
203
|
...this.#init,
|
|
194
204
|
...init
|
|
@@ -213,6 +223,21 @@ class ContentClient {
|
|
|
213
223
|
timeout
|
|
214
224
|
]) : timeout
|
|
215
225
|
});
|
|
226
|
+
// A token that changed, or now reads drafts, is refused on the path
|
|
227
|
+
// learned for the one before it: ask unscoped once, and learn again.
|
|
228
|
+
if (scope && (response.status === 401 || response.status === 403)) {
|
|
229
|
+
if (this.#scope === scope) this.#scope = null;
|
|
230
|
+
await response.body?.cancel();
|
|
231
|
+
return this.#request(path, query, {
|
|
232
|
+
init,
|
|
233
|
+
...options
|
|
234
|
+
}, {
|
|
235
|
+
notFound,
|
|
236
|
+
defaults,
|
|
237
|
+
scoped: false
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (!scope && !drafts && response.redirected) this.#learnScope(response, path);
|
|
216
241
|
if (notFound === true && response.status === 404) return null;
|
|
217
242
|
const text = await response.text();
|
|
218
243
|
let body = text;
|
|
@@ -227,6 +252,18 @@ class ContentClient {
|
|
|
227
252
|
if (!response.ok) throw new ContentApiError(response.status, url, body);
|
|
228
253
|
return body;
|
|
229
254
|
}
|
|
255
|
+
#learnScope(response, path) {
|
|
256
|
+
let redirected;
|
|
257
|
+
try {
|
|
258
|
+
redirected = new URL(response.url);
|
|
259
|
+
} catch {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const { origin, pathname } = redirected;
|
|
263
|
+
if (origin !== new URL(this.url).origin || !pathname.startsWith(`${DELIVERY_PATH}/`) || !pathname.endsWith(path)) return;
|
|
264
|
+
const scope = pathname.slice(DELIVERY_PATH.length, pathname.length - path.length);
|
|
265
|
+
if (SCOPE.test(scope)) this.#scope = scope;
|
|
266
|
+
}
|
|
230
267
|
}
|
|
231
268
|
const withoutUndefined = (value)=>Object.fromEntries(Object.entries(value).filter(([, entry])=>entry !== undefined));
|
|
232
269
|
const headersToObject = (headers)=>headers ? Object.fromEntries(new Headers(headers)) : {};
|
|
@@ -275,11 +312,14 @@ const isImproveBlock = (block)=>block._type === IMPROVE_BLOCK;
|
|
|
275
312
|
// improve's SDK returned for the visitor (getTestValue / getFlagValue). It
|
|
276
313
|
// is null when the test or flag isn't active or the visitor isn't in it;
|
|
277
314
|
// then, or for an option the block has no variant for, the `fallback`
|
|
278
|
-
// option's variant, else the first one
|
|
315
|
+
// option's variant, else the first one.
|
|
279
316
|
const pickImproveVariant = (block, option, fallback)=>{
|
|
280
317
|
const variants = block.variants ?? {};
|
|
281
318
|
if (option && variants[option]) return variants[option];
|
|
282
|
-
|
|
319
|
+
// The arm Improve gives a visitor outside the test, when the caller names
|
|
320
|
+
// none: it is not always the first variant.
|
|
321
|
+
const behind = fallback ?? block.fallback;
|
|
322
|
+
if (behind && variants[behind]) return variants[behind];
|
|
283
323
|
return Object.values(variants)[0] ?? [];
|
|
284
324
|
};
|
|
285
325
|
|
|
@@ -100,4 +100,4 @@ const readPreviewParams = (input)=>{
|
|
|
100
100
|
};
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
-
export {
|
|
103
|
+
export { COLLECTION_HANDLE_ATTRIBUTE as C, ENTRY_ATTRIBUTE as E, PREVIEW_PARAMS as P, UID_ATTRIBUTE as U, COLLECTION_ATTRIBUTE as a, PRODUCT_HANDLE_ATTRIBUTE as b, PRODUCT_ATTRIBUTE as c, editableCollection as d, editable as e, editableProduct as f, readPreviewParams as r };
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
var config = require('./config-ci2hxmas.js');
|
|
2
|
+
|
|
3
|
+
// The cookies in a `Cookie` header or `document.cookie`, by name: the first
|
|
4
|
+
// of each name, percent-decoded when it decodes.
|
|
5
|
+
const parseCookies = (header)=>{
|
|
6
|
+
const cookies = new Map();
|
|
7
|
+
for (const part of header.split(';')){
|
|
8
|
+
const index = part.indexOf('=');
|
|
9
|
+
if (index < 0) continue;
|
|
10
|
+
const name = part.slice(0, index).trim();
|
|
11
|
+
if (!name || cookies.has(name)) continue;
|
|
12
|
+
try {
|
|
13
|
+
cookies.set(name, decodeURIComponent(part.slice(index + 1).trim()));
|
|
14
|
+
} catch {
|
|
15
|
+
cookies.set(name, part.slice(index + 1).trim());
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return cookies;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// What a site's shop pages come in: the bridge's SHOP table is keyed by the
|
|
22
|
+
// type, and the site's API routes are built from the list.
|
|
23
|
+
const SHOP_KINDS = [
|
|
24
|
+
'product',
|
|
25
|
+
'collection'
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Every call the bridge makes through the site's `/cms/api/…`, and what the
|
|
29
|
+
// site's handler (`createSiteEditing`, ./site) lets through: one list, so a
|
|
30
|
+
// call the bridge makes can't be one the proxy refuses.
|
|
31
|
+
//
|
|
32
|
+
// The bridge goes through the site because the CMS token stays in an httpOnly
|
|
33
|
+
// cookie on it (the CMS's docs/adr/0010).
|
|
34
|
+
const entryPath = (entryId, suffix = '')=>`entries/${encodeURIComponent(entryId)}${suffix}`;
|
|
35
|
+
const siteApi = {
|
|
36
|
+
session: ()=>'session',
|
|
37
|
+
pages: ()=>'pages',
|
|
38
|
+
stats: ()=>'stats',
|
|
39
|
+
form: ()=>'form',
|
|
40
|
+
createEntry: ()=>'entries',
|
|
41
|
+
shopPages: (kind)=>`${kind}-pages`,
|
|
42
|
+
entry: (entryId)=>entryPath(entryId),
|
|
43
|
+
preview: (entryId)=>entryPath(entryId, '/preview'),
|
|
44
|
+
prompt: (entryId)=>entryPath(entryId, '/prompt'),
|
|
45
|
+
view: (entryId)=>entryPath(entryId, '/view'),
|
|
46
|
+
apply: (entryId)=>entryPath(entryId, '/apply'),
|
|
47
|
+
save: (entryId)=>entryPath(entryId, '/save'),
|
|
48
|
+
publish: (entryId)=>entryPath(entryId, '/publish')
|
|
49
|
+
};
|
|
50
|
+
// Each call's route on the CMS's site API, as the site's proxy matches it: an
|
|
51
|
+
// entry id is one path segment of word characters or dashes.
|
|
52
|
+
const ROUTE = {
|
|
53
|
+
session: 'session',
|
|
54
|
+
pages: 'pages',
|
|
55
|
+
stats: 'stats',
|
|
56
|
+
form: 'form',
|
|
57
|
+
createEntry: 'entries',
|
|
58
|
+
shopPages: `(?:${SHOP_KINDS.join('|')})-pages`,
|
|
59
|
+
entry: 'entries/[\\w-]+',
|
|
60
|
+
preview: 'entries/[\\w-]+/preview',
|
|
61
|
+
prompt: 'entries/[\\w-]+/prompt',
|
|
62
|
+
view: 'entries/[\\w-]+/view',
|
|
63
|
+
apply: 'entries/[\\w-]+/apply',
|
|
64
|
+
save: 'entries/[\\w-]+/save',
|
|
65
|
+
publish: 'entries/[\\w-]+/publish'
|
|
66
|
+
};
|
|
67
|
+
// The method each call is made with. The proxy lets only that one through.
|
|
68
|
+
const SITE_METHOD = {
|
|
69
|
+
session: 'GET',
|
|
70
|
+
pages: 'GET',
|
|
71
|
+
stats: 'POST',
|
|
72
|
+
form: 'POST',
|
|
73
|
+
createEntry: 'POST',
|
|
74
|
+
shopPages: 'POST',
|
|
75
|
+
entry: 'GET',
|
|
76
|
+
preview: 'POST',
|
|
77
|
+
prompt: 'POST',
|
|
78
|
+
view: 'POST',
|
|
79
|
+
apply: 'POST',
|
|
80
|
+
save: 'POST',
|
|
81
|
+
publish: 'POST'
|
|
82
|
+
};
|
|
83
|
+
// Whether the site's proxy lets this method and route through.
|
|
84
|
+
const siteApiAllows = (method, route)=>Object.keys(siteApi).some((name)=>SITE_METHOD[name] === method && new RegExp(`^${ROUTE[name]}$`).test(route));
|
|
85
|
+
|
|
86
|
+
// Editing on your own site, outside the visual editor
|
|
87
|
+
// (https://content.obelism.studio/docs/guides/site-editing). One handler
|
|
88
|
+
// serves everything under `/cms` (the `basePath`) on your site:
|
|
89
|
+
//
|
|
90
|
+
// - `/cms/login?return_to=/pricing` — sends the browser to Obelism Content
|
|
91
|
+
// to sign in, with a PKCE challenge, and…
|
|
92
|
+
// - `/cms/callback` — …takes it back: trades the one-time code for a site
|
|
93
|
+
// token, keeps it in an httpOnly cookie, calls `onSignIn` (turn on your
|
|
94
|
+
// framework's draft mode there) and returns to `return_to`.
|
|
95
|
+
// - `/cms/logout` — ends the session in the CMS, clears the cookies, calls
|
|
96
|
+
// `onSignOut`.
|
|
97
|
+
// - `/cms/api/…` — what the bridge's prompt bar calls in the browser,
|
|
98
|
+
// forwarded to the CMS with the token. The token never reaches page
|
|
99
|
+
// scripts.
|
|
100
|
+
//
|
|
101
|
+
// While signed in, render drafts by reading the delivery API with the token
|
|
102
|
+
// (`readSiteToken`), and the bridge shows the prompt bar with Save and
|
|
103
|
+
// Publish.
|
|
104
|
+
const SITE_TOKEN_COOKIE = 'obelism_content_token';
|
|
105
|
+
// Readable by the page: tells the bridge the site is being edited, and where
|
|
106
|
+
// this handler lives. Holds no secret.
|
|
107
|
+
const SITE_EDITING_COOKIE = 'obelism_content_editing';
|
|
108
|
+
const FLOW_COOKIE = 'obelism_content_flow';
|
|
109
|
+
// The bridge sends it with every write, which a cross-site form can't.
|
|
110
|
+
const SITE_REQUEST_HEADER = 'x-obelism-content';
|
|
111
|
+
const SITE_PATH = '/api/site/v1';
|
|
112
|
+
const FLOW_MAX_AGE = 10 * 60;
|
|
113
|
+
// The site token from a Cookie header (or a framework's cookie store), or
|
|
114
|
+
// null when not signed in to edit:
|
|
115
|
+
//
|
|
116
|
+
// const token = readSiteToken(request.headers.get('cookie'))
|
|
117
|
+
// const content = token ? new ContentClient({ token, init: { cache: 'no-store' } }) : delivery
|
|
118
|
+
const readSiteToken = (cookies)=>{
|
|
119
|
+
if (!cookies) return null;
|
|
120
|
+
if (typeof cookies !== 'string') return cookies.get(SITE_TOKEN_COOKIE)?.value || null;
|
|
121
|
+
return parseCookies(cookies).get(SITE_TOKEN_COOKIE) || null;
|
|
122
|
+
};
|
|
123
|
+
const serializeCookie = (name, value, { maxAge, path, httpOnly, secure })=>[
|
|
124
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
125
|
+
`Path=${path}`,
|
|
126
|
+
`Max-Age=${Math.max(0, Math.floor(maxAge))}`,
|
|
127
|
+
// Also Expires: Next.js re-serializes response cookies when draft mode
|
|
128
|
+
// changes, and drops a Max-Age of 0.
|
|
129
|
+
`Expires=${new Date(maxAge > 0 ? Date.now() + maxAge * 1000 : 0).toUTCString()}`,
|
|
130
|
+
'SameSite=Lax',
|
|
131
|
+
httpOnly && 'HttpOnly',
|
|
132
|
+
secure && 'Secure'
|
|
133
|
+
].filter(Boolean).join('; ');
|
|
134
|
+
const base64Url = (bytes)=>{
|
|
135
|
+
let binary = '';
|
|
136
|
+
for (const byte of bytes)binary += String.fromCharCode(byte);
|
|
137
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
138
|
+
};
|
|
139
|
+
const randomString = (bytes = 32)=>base64Url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
140
|
+
const pkceChallenge = async (verifier)=>base64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
|
|
141
|
+
const PLACEHOLDER_ORIGIN = 'https://site.invalid';
|
|
142
|
+
// Only a path on this site: never an open redirect. Resolved the way a
|
|
143
|
+
// browser reads Location, which drops tabs and newlines and reads \ as /.
|
|
144
|
+
const safeReturnTo = (value)=>{
|
|
145
|
+
if (!value?.startsWith('/')) return '/';
|
|
146
|
+
const url = new URL(value, PLACEHOLDER_ORIGIN);
|
|
147
|
+
return url.origin === PLACEHOLDER_ORIGIN ? `${url.pathname}${url.search}${url.hash}` : '/';
|
|
148
|
+
};
|
|
149
|
+
const normalizeBasePath = (basePath)=>`/${basePath.replace(/^\/+|\/+$/g, '')}`;
|
|
150
|
+
// The handler for everything under `basePath`. In Next.js:
|
|
151
|
+
//
|
|
152
|
+
// // app/cms/[...path]/route.ts
|
|
153
|
+
// const handler = createSiteEditing({
|
|
154
|
+
// spaceId: process.env.CONTENT_SPACE_ID!,
|
|
155
|
+
// environment: 'production',
|
|
156
|
+
// onSignIn: async () => (await draftMode()).enable(),
|
|
157
|
+
// onSignOut: async () => (await draftMode()).disable(),
|
|
158
|
+
// })
|
|
159
|
+
// export { handler as GET, handler as POST }
|
|
160
|
+
const createSiteEditing = ({ spaceId, environment, url, basePath = '/cms', siteUrl, onSignIn, onSignOut, timeout = config.DEFAULT_TIMEOUT, fetch: fetchImpl })=>{
|
|
161
|
+
const cms = config.normalizeUrl(url || config.DEFAULT_URL);
|
|
162
|
+
const base = normalizeBasePath(basePath);
|
|
163
|
+
const doFetch = fetchImpl ?? ((...args)=>fetch(...args));
|
|
164
|
+
const callCms = (path, init = {})=>doFetch(`${cms}${SITE_PATH}/${path}`, {
|
|
165
|
+
...init,
|
|
166
|
+
redirect: 'manual',
|
|
167
|
+
signal: AbortSignal.timeout(timeout)
|
|
168
|
+
});
|
|
169
|
+
return async (request)=>{
|
|
170
|
+
const requestUrl = new URL(request.url);
|
|
171
|
+
const origin = siteUrl ? config.normalizeUrl(siteUrl) : requestUrl.origin;
|
|
172
|
+
const secure = origin.startsWith('https:');
|
|
173
|
+
const redirectUri = `${origin}${base}/callback`;
|
|
174
|
+
const cookies = parseCookies(request.headers.get('cookie') ?? '');
|
|
175
|
+
const path = requestUrl.pathname.startsWith(`${base}/`) ? requestUrl.pathname.slice(base.length + 1) : null;
|
|
176
|
+
const sessionCookies = (session)=>[
|
|
177
|
+
serializeCookie(SITE_TOKEN_COOKIE, session.token, {
|
|
178
|
+
maxAge: session.maxAge,
|
|
179
|
+
path: '/',
|
|
180
|
+
httpOnly: true,
|
|
181
|
+
secure
|
|
182
|
+
}),
|
|
183
|
+
serializeCookie(SITE_EDITING_COOKIE, session.token ? base : '', {
|
|
184
|
+
maxAge: session.maxAge,
|
|
185
|
+
path: '/',
|
|
186
|
+
httpOnly: false,
|
|
187
|
+
secure
|
|
188
|
+
})
|
|
189
|
+
];
|
|
190
|
+
const clearedCookies = ()=>sessionCookies({
|
|
191
|
+
token: '',
|
|
192
|
+
maxAge: 0
|
|
193
|
+
});
|
|
194
|
+
const redirect = (location, setCookies = [])=>{
|
|
195
|
+
const headers = new Headers({
|
|
196
|
+
Location: location,
|
|
197
|
+
'Cache-Control': 'no-store'
|
|
198
|
+
});
|
|
199
|
+
for (const cookie of setCookies)headers.append('Set-Cookie', cookie);
|
|
200
|
+
return new Response(null, {
|
|
201
|
+
status: 302,
|
|
202
|
+
headers
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
if (path === 'login' && request.method === 'GET') {
|
|
206
|
+
const state = randomString(16);
|
|
207
|
+
const verifier = randomString(48);
|
|
208
|
+
const flow = JSON.stringify({
|
|
209
|
+
state,
|
|
210
|
+
verifier,
|
|
211
|
+
returnTo: safeReturnTo(requestUrl.searchParams.get('return_to'))
|
|
212
|
+
});
|
|
213
|
+
const connect = new URL(`${cms}/site/connect`);
|
|
214
|
+
connect.search = new URLSearchParams({
|
|
215
|
+
space: spaceId,
|
|
216
|
+
environment,
|
|
217
|
+
redirect_uri: redirectUri,
|
|
218
|
+
state,
|
|
219
|
+
code_challenge: await pkceChallenge(verifier),
|
|
220
|
+
code_challenge_method: 'S256'
|
|
221
|
+
}).toString();
|
|
222
|
+
return redirect(connect.toString(), [
|
|
223
|
+
serializeCookie(FLOW_COOKIE, flow, {
|
|
224
|
+
maxAge: FLOW_MAX_AGE,
|
|
225
|
+
path: base,
|
|
226
|
+
httpOnly: true,
|
|
227
|
+
secure
|
|
228
|
+
})
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
if (path === 'callback' && request.method === 'GET') {
|
|
232
|
+
const clearFlow = serializeCookie(FLOW_COOKIE, '', {
|
|
233
|
+
maxAge: 0,
|
|
234
|
+
path: base,
|
|
235
|
+
httpOnly: true,
|
|
236
|
+
secure
|
|
237
|
+
});
|
|
238
|
+
let flow;
|
|
239
|
+
try {
|
|
240
|
+
flow = JSON.parse(cookies.get(FLOW_COOKIE) ?? '');
|
|
241
|
+
} catch {
|
|
242
|
+
flow = {};
|
|
243
|
+
}
|
|
244
|
+
const code = requestUrl.searchParams.get('code');
|
|
245
|
+
if (!code || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || flow.state !== requestUrl.searchParams.get('state')) {
|
|
246
|
+
return textResponse(400, 'This sign-in link is invalid or expired. Start again from the login link.', [
|
|
247
|
+
clearFlow
|
|
248
|
+
]);
|
|
249
|
+
}
|
|
250
|
+
const response = await callCms('token', {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: {
|
|
253
|
+
'Content-Type': 'application/json'
|
|
254
|
+
},
|
|
255
|
+
body: JSON.stringify({
|
|
256
|
+
code,
|
|
257
|
+
code_verifier: flow.verifier,
|
|
258
|
+
redirect_uri: redirectUri
|
|
259
|
+
})
|
|
260
|
+
});
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
return textResponse(400, 'Obelism Content did not accept the sign-in. Start again from the login link.', [
|
|
263
|
+
clearFlow
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
const session = await response.json();
|
|
267
|
+
await onSignIn?.(session);
|
|
268
|
+
const maxAge = (Date.parse(session.expiresAt) - Date.now()) / 1000;
|
|
269
|
+
return redirect(safeReturnTo(flow.returnTo), [
|
|
270
|
+
clearFlow,
|
|
271
|
+
...sessionCookies({
|
|
272
|
+
token: session.token,
|
|
273
|
+
maxAge
|
|
274
|
+
})
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
if (path === 'logout') {
|
|
278
|
+
const token = cookies.get(SITE_TOKEN_COOKIE);
|
|
279
|
+
if (token) {
|
|
280
|
+
await callCms('session', {
|
|
281
|
+
method: 'DELETE',
|
|
282
|
+
headers: {
|
|
283
|
+
Authorization: `Bearer ${token}`
|
|
284
|
+
}
|
|
285
|
+
}).catch(()=>null);
|
|
286
|
+
}
|
|
287
|
+
await onSignOut?.();
|
|
288
|
+
return redirect(safeReturnTo(requestUrl.searchParams.get('return_to')), clearedCookies());
|
|
289
|
+
}
|
|
290
|
+
if (path?.startsWith('api/')) {
|
|
291
|
+
const route = path.slice('api/'.length);
|
|
292
|
+
if (!siteApiAllows(request.method, route)) {
|
|
293
|
+
return Response.json({
|
|
294
|
+
error: 'Not found'
|
|
295
|
+
}, {
|
|
296
|
+
status: 404
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (request.method !== 'GET' && request.headers.get(SITE_REQUEST_HEADER) !== '1') {
|
|
300
|
+
return Response.json({
|
|
301
|
+
error: 'Forbidden'
|
|
302
|
+
}, {
|
|
303
|
+
status: 403
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const token = cookies.get(SITE_TOKEN_COOKIE);
|
|
307
|
+
if (!token) {
|
|
308
|
+
return Response.json({
|
|
309
|
+
error: 'Not signed in'
|
|
310
|
+
}, {
|
|
311
|
+
status: 401
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const response = await callCms(route, {
|
|
315
|
+
method: request.method,
|
|
316
|
+
headers: {
|
|
317
|
+
Authorization: `Bearer ${token}`,
|
|
318
|
+
...request.method !== 'GET' && {
|
|
319
|
+
'Content-Type': 'application/json'
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
...request.method !== 'GET' && {
|
|
323
|
+
body: await request.text()
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
const headers = new Headers({
|
|
327
|
+
'Content-Type': 'application/json',
|
|
328
|
+
'Cache-Control': 'no-store'
|
|
329
|
+
});
|
|
330
|
+
// The session ended in the CMS: stop showing the editing UI.
|
|
331
|
+
if (response.status === 401) {
|
|
332
|
+
for (const cookie of clearedCookies()){
|
|
333
|
+
headers.append('Set-Cookie', cookie);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return new Response(await response.text(), {
|
|
337
|
+
status: response.status,
|
|
338
|
+
headers
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return Response.json({
|
|
342
|
+
error: 'Not found'
|
|
343
|
+
}, {
|
|
344
|
+
status: 404
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
const textResponse = (status, text, setCookies)=>{
|
|
349
|
+
const headers = new Headers({
|
|
350
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
351
|
+
'Cache-Control': 'no-store'
|
|
352
|
+
});
|
|
353
|
+
for (const cookie of setCookies)headers.append('Set-Cookie', cookie);
|
|
354
|
+
return new Response(text, {
|
|
355
|
+
status,
|
|
356
|
+
headers
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
exports.SITE_EDITING_COOKIE = SITE_EDITING_COOKIE;
|
|
361
|
+
exports.SITE_REQUEST_HEADER = SITE_REQUEST_HEADER;
|
|
362
|
+
exports.SITE_TOKEN_COOKIE = SITE_TOKEN_COOKIE;
|
|
363
|
+
exports.createSiteEditing = createSiteEditing;
|
|
364
|
+
exports.parseCookies = parseCookies;
|
|
365
|
+
exports.pkceChallenge = pkceChallenge;
|
|
366
|
+
exports.readSiteToken = readSiteToken;
|
|
367
|
+
exports.safeReturnTo = safeReturnTo;
|
|
368
|
+
exports.siteApi = siteApi;
|