@decocms/blocks 7.31.0 → 7.31.1
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/package.json +1 -1
- package/src/cms/draftSource.test.ts +88 -29
- package/src/cms/draftSource.ts +24 -8
package/package.json
CHANGED
|
@@ -75,27 +75,45 @@ describe("previewApiOriginForHost", () => {
|
|
|
75
75
|
expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
|
|
76
76
|
"https://abc.local.studio.decocms.com",
|
|
77
77
|
);
|
|
78
|
-
expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe(
|
|
78
|
+
expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe(
|
|
79
|
+
"http://abc.localhost:60534",
|
|
80
|
+
);
|
|
81
|
+
// -stg is its own suffix, not a substring match of .preview-studio.decocms.com —
|
|
82
|
+
// both must be listed explicitly (regressed once: staging draft pointers
|
|
83
|
+
// silently fell back to published content with no visible error).
|
|
84
|
+
expect(
|
|
85
|
+
previewApiOriginForHost("abc.preview-studio-stg.decocms.com", {}),
|
|
86
|
+
).toBe("https://abc.preview-studio-stg.decocms.com");
|
|
79
87
|
});
|
|
80
88
|
|
|
81
89
|
it("rejects hosts outside the domains — the token proposes, config disposes", () => {
|
|
82
90
|
expect(previewApiOriginForHost("evil.example", {})).toBeNull();
|
|
83
91
|
// Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
|
|
84
92
|
// that merely ends with the same characters cannot pass.
|
|
85
|
-
expect(
|
|
93
|
+
expect(
|
|
94
|
+
previewApiOriginForHost("evil-preview-studio.decocms.com", {}),
|
|
95
|
+
).toBeNull();
|
|
86
96
|
// The domain itself (no label in front) is not a draft host.
|
|
87
|
-
expect(
|
|
97
|
+
expect(
|
|
98
|
+
previewApiOriginForHost("preview-studio.decocms.com", {}),
|
|
99
|
+
).toBeNull();
|
|
88
100
|
});
|
|
89
101
|
|
|
90
102
|
it("allows an explicit port only under localhost-ish domains", () => {
|
|
91
103
|
// A public-domain token must not steer the fetch at odd ports.
|
|
92
|
-
expect(
|
|
104
|
+
expect(
|
|
105
|
+
previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {}),
|
|
106
|
+
).toBeNull();
|
|
93
107
|
});
|
|
94
108
|
|
|
95
109
|
it("honours a configured override instead of the defaults", () => {
|
|
96
110
|
const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" };
|
|
97
|
-
expect(previewApiOriginForHost("abc.staging.example", env)).toBe(
|
|
98
|
-
|
|
111
|
+
expect(previewApiOriginForHost("abc.staging.example", env)).toBe(
|
|
112
|
+
"https://abc.staging.example",
|
|
113
|
+
);
|
|
114
|
+
expect(
|
|
115
|
+
previewApiOriginForHost("abc.preview-studio.decocms.com", env),
|
|
116
|
+
).toBeNull();
|
|
99
117
|
});
|
|
100
118
|
});
|
|
101
119
|
|
|
@@ -129,7 +147,9 @@ describe("resolveDraftDecofile", () => {
|
|
|
129
147
|
});
|
|
130
148
|
|
|
131
149
|
expect(blocks).toEqual({ "pages-home": { title: "draft" } });
|
|
132
|
-
expect(calls).toEqual([
|
|
150
|
+
expect(calls).toEqual([
|
|
151
|
+
"https://abc.preview-studio.decocms.com/_sandbox/decofile",
|
|
152
|
+
]);
|
|
133
153
|
});
|
|
134
154
|
|
|
135
155
|
it("is inert without a host allowlist — no fetch at all", async () => {
|
|
@@ -168,8 +188,16 @@ describe("resolveDraftDecofile", () => {
|
|
|
168
188
|
}) as unknown as typeof fetch;
|
|
169
189
|
const P = "abc.preview-studio.decocms.com";
|
|
170
190
|
|
|
171
|
-
const a = await resolveDraftDecofile({
|
|
172
|
-
|
|
191
|
+
const a = await resolveDraftDecofile({
|
|
192
|
+
pointer: `${P}@v1`,
|
|
193
|
+
env: ENV_ON,
|
|
194
|
+
fetchImpl,
|
|
195
|
+
});
|
|
196
|
+
const b = await resolveDraftDecofile({
|
|
197
|
+
pointer: `${P}@v1`,
|
|
198
|
+
env: ENV_ON,
|
|
199
|
+
fetchImpl,
|
|
200
|
+
});
|
|
173
201
|
expect(fetches).toBe(1);
|
|
174
202
|
expect(b).toBe(a);
|
|
175
203
|
|
|
@@ -186,7 +214,11 @@ describe("resolveDraftDecofile", () => {
|
|
|
186
214
|
const P = "abc.preview-studio.decocms.com";
|
|
187
215
|
|
|
188
216
|
for (const v of ["v1", "v2", "v3", "v4"]) {
|
|
189
|
-
await resolveDraftDecofile({
|
|
217
|
+
await resolveDraftDecofile({
|
|
218
|
+
pointer: `${P}@${v}`,
|
|
219
|
+
env: ENV_ON,
|
|
220
|
+
fetchImpl,
|
|
221
|
+
});
|
|
190
222
|
}
|
|
191
223
|
expect(fetches).toBe(4);
|
|
192
224
|
await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
|
|
@@ -225,6 +257,7 @@ describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
|
|
|
225
257
|
it("ships the deco-operated origins, dot-prefixed", () => {
|
|
226
258
|
expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
|
|
227
259
|
".preview-studio.decocms.com",
|
|
260
|
+
".preview-studio-stg.decocms.com",
|
|
228
261
|
".local.studio.decocms.com",
|
|
229
262
|
".localhost",
|
|
230
263
|
]);
|
|
@@ -261,10 +294,17 @@ describe("site-block preview hosts", () => {
|
|
|
261
294
|
|
|
262
295
|
describe("draftPointerFromRequest", () => {
|
|
263
296
|
it("reads the pointer from the __deco_draft cookie (in-preview navigation)", () => {
|
|
264
|
-
const req = new Request(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
297
|
+
const req = new Request(
|
|
298
|
+
"https://preview.example/deco/invoke/site/loaders/x.ts",
|
|
299
|
+
{
|
|
300
|
+
headers: {
|
|
301
|
+
cookie: "a=1; __deco_draft=abc.preview-studio.decocms.com@v1; b=2",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
expect(draftPointerFromRequest(req)).toBe(
|
|
306
|
+
"abc.preview-studio.decocms.com@v1",
|
|
307
|
+
);
|
|
268
308
|
});
|
|
269
309
|
|
|
270
310
|
it("lets ?__draft= win over the cookie", () => {
|
|
@@ -282,26 +322,35 @@ describe("draftPointerFromRequest", () => {
|
|
|
282
322
|
});
|
|
283
323
|
|
|
284
324
|
it("returns null with neither param nor cookie", () => {
|
|
285
|
-
expect(
|
|
325
|
+
expect(
|
|
326
|
+
draftPointerFromRequest(new Request("https://preview.example/p")),
|
|
327
|
+
).toBeNull();
|
|
286
328
|
});
|
|
287
329
|
});
|
|
288
330
|
|
|
289
331
|
describe("resolveDraftForRequest", () => {
|
|
290
332
|
const ENV = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };
|
|
291
333
|
function invokeReq(host = "preview.example"): Request {
|
|
292
|
-
return new Request(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"
|
|
296
|
-
|
|
334
|
+
return new Request(
|
|
335
|
+
"https://preview.example/deco/invoke/site/loaders/x.ts",
|
|
336
|
+
{
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: {
|
|
339
|
+
"x-forwarded-host": host,
|
|
340
|
+
cookie: "__deco_draft=abc.preview-studio.decocms.com@v1",
|
|
341
|
+
},
|
|
297
342
|
},
|
|
298
|
-
|
|
343
|
+
);
|
|
299
344
|
}
|
|
300
345
|
|
|
301
346
|
it("binds the draft when host is allowed and the pointer resolves", async () => {
|
|
302
347
|
const fetchImpl = (async () =>
|
|
303
|
-
jsonResponse({
|
|
304
|
-
|
|
348
|
+
jsonResponse({
|
|
349
|
+
"site/x": { value: "draft" },
|
|
350
|
+
})) as unknown as typeof fetch;
|
|
351
|
+
expect(
|
|
352
|
+
await resolveDraftForRequest(invokeReq(), { env: ENV, fetchImpl }),
|
|
353
|
+
).toEqual({
|
|
305
354
|
"site/x": { value: "draft" },
|
|
306
355
|
});
|
|
307
356
|
});
|
|
@@ -312,7 +361,9 @@ describe("resolveDraftForRequest", () => {
|
|
|
312
361
|
called = true;
|
|
313
362
|
return jsonResponse({});
|
|
314
363
|
}) as unknown as typeof fetch;
|
|
315
|
-
expect(
|
|
364
|
+
expect(
|
|
365
|
+
await resolveDraftForRequest(invokeReq(), { env: {}, fetchImpl }),
|
|
366
|
+
).toBeNull();
|
|
316
367
|
expect(called).toBe(false);
|
|
317
368
|
});
|
|
318
369
|
|
|
@@ -323,21 +374,29 @@ describe("resolveDraftForRequest", () => {
|
|
|
323
374
|
return jsonResponse({});
|
|
324
375
|
}) as unknown as typeof fetch;
|
|
325
376
|
expect(
|
|
326
|
-
await resolveDraftForRequest(invokeReq("prod.example"), {
|
|
377
|
+
await resolveDraftForRequest(invokeReq("prod.example"), {
|
|
378
|
+
env: ENV,
|
|
379
|
+
fetchImpl,
|
|
380
|
+
}),
|
|
327
381
|
).toBeNull();
|
|
328
382
|
expect(called).toBe(false);
|
|
329
383
|
});
|
|
330
384
|
|
|
331
385
|
it("returns null when the request carries no draft pointer", async () => {
|
|
332
|
-
const req = new Request(
|
|
333
|
-
|
|
334
|
-
|
|
386
|
+
const req = new Request(
|
|
387
|
+
"https://preview.example/deco/invoke/site/loaders/x.ts",
|
|
388
|
+
{
|
|
389
|
+
headers: { "x-forwarded-host": "preview.example" },
|
|
390
|
+
},
|
|
391
|
+
);
|
|
335
392
|
let called = false;
|
|
336
393
|
const fetchImpl = (async () => {
|
|
337
394
|
called = true;
|
|
338
395
|
return jsonResponse({});
|
|
339
396
|
}) as unknown as typeof fetch;
|
|
340
|
-
expect(
|
|
397
|
+
expect(
|
|
398
|
+
await resolveDraftForRequest(req, { env: ENV, fetchImpl }),
|
|
399
|
+
).toBeNull();
|
|
341
400
|
expect(called).toBe(false);
|
|
342
401
|
});
|
|
343
402
|
});
|
package/src/cms/draftSource.ts
CHANGED
|
@@ -39,7 +39,8 @@ export interface DraftPointer {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */
|
|
42
|
-
const HOST_RE =
|
|
42
|
+
const HOST_RE =
|
|
43
|
+
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
43
44
|
const PORT_RE = /^[0-9]{1,5}$/;
|
|
44
45
|
const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
45
46
|
|
|
@@ -48,7 +49,9 @@ const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
|
48
49
|
* back to published content. Requires EXACTLY one `@`: a naive split accepts
|
|
49
50
|
* `a@b@c` and silently uses the first two segments.
|
|
50
51
|
*/
|
|
51
|
-
export function parseDraftPointer(
|
|
52
|
+
export function parseDraftPointer(
|
|
53
|
+
raw: string | null | undefined,
|
|
54
|
+
): DraftPointer | null {
|
|
52
55
|
if (!raw) return null;
|
|
53
56
|
const parts = raw.split("@");
|
|
54
57
|
if (parts.length !== 2) return null;
|
|
@@ -71,6 +74,7 @@ export function parseDraftPointer(raw: string | null | undefined): DraftPointer
|
|
|
71
74
|
*/
|
|
72
75
|
export const DEFAULT_PREVIEW_API_DOMAINS = [
|
|
73
76
|
".preview-studio.decocms.com",
|
|
77
|
+
".preview-studio-stg.decocms.com",
|
|
74
78
|
".local.studio.decocms.com",
|
|
75
79
|
".localhost",
|
|
76
80
|
];
|
|
@@ -161,7 +165,9 @@ export function isDraftHostAllowed(
|
|
|
161
165
|
env?: Record<string, string | undefined>,
|
|
162
166
|
): boolean {
|
|
163
167
|
if (!host) return false;
|
|
164
|
-
return readAllowedHosts(envOrProcess(env)).includes(
|
|
168
|
+
return readAllowedHosts(envOrProcess(env)).includes(
|
|
169
|
+
host.trim().toLowerCase(),
|
|
170
|
+
);
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
/**
|
|
@@ -170,7 +176,9 @@ export function isDraftHostAllowed(
|
|
|
170
176
|
* unconfigured site never loses static/ISR rendering. The per-request host
|
|
171
177
|
* match happens later, in `isDraftHostAllowed`.
|
|
172
178
|
*/
|
|
173
|
-
export function isDraftPreviewEnabled(
|
|
179
|
+
export function isDraftPreviewEnabled(
|
|
180
|
+
env?: Record<string, string | undefined>,
|
|
181
|
+
): boolean {
|
|
174
182
|
return readAllowedHosts(envOrProcess(env)).length > 0;
|
|
175
183
|
}
|
|
176
184
|
|
|
@@ -179,7 +187,8 @@ function envOrProcess(
|
|
|
179
187
|
): Record<string, string | undefined> {
|
|
180
188
|
return (
|
|
181
189
|
env ??
|
|
182
|
-
(globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
190
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
191
|
+
.process?.env ??
|
|
183
192
|
{}
|
|
184
193
|
);
|
|
185
194
|
}
|
|
@@ -277,7 +286,10 @@ export const DRAFT_COOKIE_NAME = "__deco_draft";
|
|
|
277
286
|
export const DRAFT_QUERY_PARAM = "__draft";
|
|
278
287
|
|
|
279
288
|
/** Read one cookie value out of a raw `Cookie:` header. */
|
|
280
|
-
function readCookieValue(
|
|
289
|
+
function readCookieValue(
|
|
290
|
+
cookieHeader: string | null | undefined,
|
|
291
|
+
name: string,
|
|
292
|
+
): string | null {
|
|
281
293
|
if (!cookieHeader) return null;
|
|
282
294
|
for (const part of cookieHeader.split(";")) {
|
|
283
295
|
const eq = part.indexOf("=");
|
|
@@ -331,7 +343,8 @@ export async function resolveDraftForRequest(
|
|
|
331
343
|
if (readAllowedHosts(env).length === 0) return null;
|
|
332
344
|
const pointer = draftPointerFromRequest(request);
|
|
333
345
|
if (!pointer) return null;
|
|
334
|
-
const host =
|
|
346
|
+
const host =
|
|
347
|
+
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
335
348
|
if (!isDraftHostAllowed(host, env)) return null;
|
|
336
349
|
return resolveDraftDecofile({ pointer, env, fetchImpl: options.fetchImpl });
|
|
337
350
|
}
|
|
@@ -357,6 +370,9 @@ export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
|
|
|
357
370
|
}
|
|
358
371
|
|
|
359
372
|
/** The current request's draft blocks, if a binding registered one. */
|
|
360
|
-
export function getRequestDraftOverride():
|
|
373
|
+
export function getRequestDraftOverride():
|
|
374
|
+
| Record<string, unknown>
|
|
375
|
+
| null
|
|
376
|
+
| undefined {
|
|
361
377
|
return getDraftOverride();
|
|
362
378
|
}
|