@decocms/blocks-admin 7.0.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/package.json +39 -0
- package/src/admin/cors.ts +49 -0
- package/src/admin/decofile.test.ts +146 -0
- package/src/admin/decofile.ts +189 -0
- package/src/admin/index.ts +29 -0
- package/src/admin/invoke.test.ts +141 -0
- package/src/admin/invoke.ts +290 -0
- package/src/admin/liveControls.ts +29 -0
- package/src/admin/meta.ts +91 -0
- package/src/admin/render.ts +289 -0
- package/src/admin/setup.ts +85 -0
- package/src/apps/autoconfig.ts +135 -0
- package/src/apps/index.ts +24 -0
- package/src/createAdminSetup.ts +64 -0
- package/src/sdk/htmlShell.ts +55 -0
- package/src/sdk/setupApps.ts +263 -0
- package/tsconfig.json +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decocms/blocks-admin",
|
|
3
|
+
"version": "7.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deco admin protocol: /live/_meta, /.decofile, /deco/invoke, /deco/render, plus admin-facing site setup and commerce-app auto-configuration",
|
|
6
|
+
"main": "./src/admin/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/admin/index.ts",
|
|
9
|
+
"./setup": "./src/createAdminSetup.ts",
|
|
10
|
+
"./admin/setup": "./src/admin/setup.ts",
|
|
11
|
+
"./apps": "./src/apps/index.ts",
|
|
12
|
+
"./apps/autoconfig": "./src/apps/autoconfig.ts",
|
|
13
|
+
"./sdk/htmlShell": "./src/sdk/htmlShell.ts",
|
|
14
|
+
"./sdk/setupApps": "./src/sdk/setupApps.ts"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"test": "vitest run --root ../.. packages/blocks-admin/",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"lint:unused": "knip"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@decocms/blocks": "7.0.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": "^19.0.0",
|
|
27
|
+
"react-dom": "^19.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^19.0.0",
|
|
31
|
+
"@types/react-dom": "^19.0.0",
|
|
32
|
+
"knip": "^5.86.0",
|
|
33
|
+
"typescript": "^5.9.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"registry": "https://registry.npmjs.org",
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const ADMIN_ORIGINS = new Set([
|
|
2
|
+
"https://admin.deco.cx",
|
|
3
|
+
"https://v0-admin.deco.cx",
|
|
4
|
+
"https://play.deco.cx",
|
|
5
|
+
"https://admin-cx.deco.page",
|
|
6
|
+
"https://deco.chat",
|
|
7
|
+
"https://admin.decocms.com",
|
|
8
|
+
"https://decocms.com",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register additional allowed admin origins.
|
|
13
|
+
* Useful for self-hosted admin UIs or custom dashboards.
|
|
14
|
+
*/
|
|
15
|
+
export function registerAdminOrigin(origin: string): void {
|
|
16
|
+
ADMIN_ORIGINS.add(origin);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register multiple additional allowed admin origins.
|
|
21
|
+
*/
|
|
22
|
+
export function registerAdminOrigins(origins: string[]): void {
|
|
23
|
+
for (const origin of origins) {
|
|
24
|
+
ADMIN_ORIGINS.add(origin);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isAdminOrLocalhost(request: Request): boolean {
|
|
29
|
+
const origin = request.headers.get("origin") || request.headers.get("referer") || "";
|
|
30
|
+
|
|
31
|
+
if (origin.includes("localhost") || origin.includes("127.0.0.1")) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const domain of ADMIN_ORIGINS) {
|
|
36
|
+
if (origin.startsWith(domain)) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function corsHeaders(request: Request): Record<string, string> {
|
|
42
|
+
const origin = request.headers.get("origin") || "*";
|
|
43
|
+
return {
|
|
44
|
+
"Access-Control-Allow-Origin": origin,
|
|
45
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
46
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, If-None-Match",
|
|
47
|
+
"Access-Control-Allow-Credentials": "true",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { KV_KEYS, type KVNamespace, getRevision, loadBlocks, setBlocks } from "@decocms/blocks/cms";
|
|
3
|
+
import { handleDecofileReload, setFastDeployKVGetter } from "./decofile";
|
|
4
|
+
|
|
5
|
+
// In vitest, import.meta.env.DEV is true, so handleDecofileReload skips the
|
|
6
|
+
// auth check (same branch the dev Vite plugin uses) — no token needed here.
|
|
7
|
+
|
|
8
|
+
// decofile.ts no longer hard-imports getFastDeployKV (that would create a
|
|
9
|
+
// admin → tanstack dependency, which is backwards). Instead the
|
|
10
|
+
// getter is injected — mirror the enablement rule that used to live in
|
|
11
|
+
// sdk/kvHydration.ts (DECO_FAST_DEPLOY flag + duck-typed DECO_KV binding) so
|
|
12
|
+
// these tests still exercise the KV write-through path end-to-end.
|
|
13
|
+
setFastDeployKVGetter((env) => {
|
|
14
|
+
const flag = env.DECO_FAST_DEPLOY;
|
|
15
|
+
if (flag !== "1" && flag !== "true") return null;
|
|
16
|
+
const binding = env.DECO_KV as KVNamespace | undefined;
|
|
17
|
+
if (binding && typeof binding.get === "function") return binding;
|
|
18
|
+
return null;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function reload(payload: unknown, env?: Record<string, unknown>) {
|
|
22
|
+
const req = new Request("http://x/.decofile", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
body: JSON.stringify(payload),
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
});
|
|
27
|
+
return handleDecofileReload(req, env);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeKV() {
|
|
31
|
+
const store = new Map<string, string>();
|
|
32
|
+
const kv: KVNamespace = {
|
|
33
|
+
get: (k) => Promise.resolve(store.get(k) ?? null),
|
|
34
|
+
put: (k, v) => {
|
|
35
|
+
store.set(k, v);
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
},
|
|
38
|
+
delete: (k) => {
|
|
39
|
+
store.delete(k);
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return { kv, store };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
setBlocks({ Site: { name: "base" }, "pages-home": { path: "/" } });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("handleDecofileReload — full replacement (back-compat)", () => {
|
|
51
|
+
it("replaces the whole decofile when body is a raw block map", async () => {
|
|
52
|
+
const full = { Site: { name: "new" }, "pages-x": { path: "/x" } };
|
|
53
|
+
const res = await reload(full);
|
|
54
|
+
const json = (await res.json()) as { mode: string; ok: boolean };
|
|
55
|
+
expect(res.status).toBe(200);
|
|
56
|
+
expect(json.ok).toBe(true);
|
|
57
|
+
expect(json.mode).toBe("full");
|
|
58
|
+
expect(loadBlocks()).toEqual(full);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("handleDecofileReload — delta payloads", () => {
|
|
63
|
+
it("adds/updates blocks, leaving others intact", async () => {
|
|
64
|
+
const res = await reload({ blocks: { "pages-x": { path: "/x" } } });
|
|
65
|
+
const json = (await res.json()) as { mode: string };
|
|
66
|
+
expect(json.mode).toBe("delta");
|
|
67
|
+
expect(loadBlocks()).toEqual({
|
|
68
|
+
Site: { name: "base" },
|
|
69
|
+
"pages-home": { path: "/" },
|
|
70
|
+
"pages-x": { path: "/x" },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("deletes a block when its delta value is null", async () => {
|
|
75
|
+
await reload({ blocks: { "pages-home": null } });
|
|
76
|
+
expect(loadBlocks()).toEqual({ Site: { name: "base" } });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does NOT treat a full decofile that has many keys as a delta", async () => {
|
|
80
|
+
const res = await reload({ Site: { name: "a" }, blocks: { name: "a block named blocks" } });
|
|
81
|
+
const json = (await res.json()) as { mode: string };
|
|
82
|
+
// Two top-level keys → full replacement, not a delta envelope.
|
|
83
|
+
expect(json.mode).toBe("full");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("handleDecofileReload — KV write-through", () => {
|
|
88
|
+
it("writes the snapshot + revision to KV when DECO_KV is bound", async () => {
|
|
89
|
+
const { kv, store } = makeKV();
|
|
90
|
+
const res = await reload(
|
|
91
|
+
{ blocks: { "pages-x": { path: "/x" } } },
|
|
92
|
+
{ DECO_KV: kv, DECO_FAST_DEPLOY: "1" },
|
|
93
|
+
);
|
|
94
|
+
const json = (await res.json()) as { kvWritten: boolean; revision: string };
|
|
95
|
+
|
|
96
|
+
expect(json.kvWritten).toBe(true);
|
|
97
|
+
expect(store.get(KV_KEYS.SNAPSHOT)).toBe(JSON.stringify(loadBlocks()));
|
|
98
|
+
expect(store.get(KV_KEYS.REVISION)).toBe(getRevision());
|
|
99
|
+
expect(store.get(KV_KEYS.REVISION)).toBe(json.revision);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reports kvWritten=false when no KV binding is present", async () => {
|
|
103
|
+
const res = await reload({ blocks: { "pages-x": { path: "/x" } } }, {});
|
|
104
|
+
const json = (await res.json()) as { kvWritten: boolean };
|
|
105
|
+
expect(json.kvWritten).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reports kvWritten=false when bound but DECO_FAST_DEPLOY is not set", async () => {
|
|
109
|
+
const { kv, store } = makeKV();
|
|
110
|
+
const res = await reload({ blocks: { "pages-x": { path: "/x" } } }, { DECO_KV: kv });
|
|
111
|
+
const json = (await res.json()) as { kvWritten: boolean };
|
|
112
|
+
expect(json.kvWritten).toBe(false);
|
|
113
|
+
expect(store.size).toBe(0); // nothing written to KV
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("does not fail the request when the KV write throws", async () => {
|
|
117
|
+
const env = {
|
|
118
|
+
DECO_FAST_DEPLOY: "1",
|
|
119
|
+
DECO_KV: {
|
|
120
|
+
get: () => Promise.resolve(null),
|
|
121
|
+
put: () => Promise.reject(new Error("KV down")),
|
|
122
|
+
delete: () => Promise.resolve(),
|
|
123
|
+
} as KVNamespace,
|
|
124
|
+
};
|
|
125
|
+
const res = await reload({ blocks: { "pages-x": { path: "/x" } } }, env);
|
|
126
|
+
const json = (await res.json()) as { ok: boolean; kvWritten: boolean };
|
|
127
|
+
expect(res.status).toBe(200);
|
|
128
|
+
expect(json.ok).toBe(true);
|
|
129
|
+
expect(json.kvWritten).toBe(false);
|
|
130
|
+
// Local state still updated despite KV failure.
|
|
131
|
+
expect(loadBlocks()["pages-x"]).toEqual({ path: "/x" });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("handleDecofileReload — validation", () => {
|
|
136
|
+
it("returns 400 on invalid JSON", async () => {
|
|
137
|
+
const req = new Request("http://x/.decofile", { method: "POST", body: "{not json" });
|
|
138
|
+
const res = await handleDecofileReload(req, {});
|
|
139
|
+
expect(res.status).toBe(400);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns 400 when the body is not an object", async () => {
|
|
143
|
+
const res = await reload([1, 2, 3], {});
|
|
144
|
+
expect(res.status).toBe(400);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { KV_KEYS, getRevision, loadBlocks, setBlocks } from "@decocms/blocks/cms";
|
|
2
|
+
import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
|
|
3
|
+
import { getRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
|
|
4
|
+
import { invalidateMetaCache } from "./meta";
|
|
5
|
+
|
|
6
|
+
type FastDeployKVGetter = (
|
|
7
|
+
env: Record<string, unknown>,
|
|
8
|
+
) => { get: Function; put: Function; delete: Function } | null;
|
|
9
|
+
|
|
10
|
+
let getFastDeployKV: FastDeployKVGetter = () => null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Inject the fast-deploy KV getter. Fast-deploy is Cloudflare/tanstack-only —
|
|
14
|
+
* `admin` cannot depend on `tanstack`'s `sdk/kvHydration`
|
|
15
|
+
* directly (that dependency direction is backwards), so the site's own
|
|
16
|
+
* setup wiring calls this once at startup, e.g.
|
|
17
|
+
* `setFastDeployKVGetter(getFastDeployKV)` from its own
|
|
18
|
+
* `sdk/kvHydration`. `next` never calls it, so the KV write-through
|
|
19
|
+
* below silently no-ops there.
|
|
20
|
+
*/
|
|
21
|
+
export function setFastDeployKVGetter(getter: FastDeployKVGetter): void {
|
|
22
|
+
getFastDeployKV = getter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function handleDecofileRead(): Response {
|
|
26
|
+
const blocks = loadBlocks();
|
|
27
|
+
const revision = getRevision();
|
|
28
|
+
|
|
29
|
+
return new Response(JSON.stringify(blocks), {
|
|
30
|
+
status: 200,
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"Cache-Control": "no-cache",
|
|
34
|
+
...(revision ? { ETag: `"${revision}"` } : {}),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Delta payloads
|
|
41
|
+
//
|
|
42
|
+
// Studio (admin.deco.cx) and CI can POST a partial update instead of the whole
|
|
43
|
+
// decofile. The delta envelope is:
|
|
44
|
+
//
|
|
45
|
+
// { "blocks": { "<blockName>": <blockJson> | null, ... } }
|
|
46
|
+
//
|
|
47
|
+
// where a `null` value deletes that block. A delta is identified by a body that
|
|
48
|
+
// has EXACTLY one top-level key, `blocks`, holding an object — a full decofile
|
|
49
|
+
// always carries many top-level block keys (Site, pages-*, …), so there is no
|
|
50
|
+
// realistic collision. Any other object body is treated as a full decofile
|
|
51
|
+
// replacement (backward-compatible with the dev Vite plugin).
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
interface DeltaPayload {
|
|
55
|
+
blocks: Record<string, unknown | null>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
59
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isDeltaPayload(body: unknown): body is DeltaPayload {
|
|
63
|
+
return isObject(body) && Object.keys(body).length === 1 && isObject(body.blocks);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Apply a delta over the current decofile: set non-null values, delete nulls. */
|
|
67
|
+
function applyDelta(
|
|
68
|
+
base: Record<string, unknown>,
|
|
69
|
+
delta: Record<string, unknown | null>,
|
|
70
|
+
): Record<string, unknown> {
|
|
71
|
+
const merged = { ...base };
|
|
72
|
+
for (const [name, value] of Object.entries(delta)) {
|
|
73
|
+
if (value === null || value === undefined) {
|
|
74
|
+
delete merged[name];
|
|
75
|
+
} else {
|
|
76
|
+
merged[name] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Write the current in-memory decofile snapshot to KV so other isolates pick
|
|
84
|
+
* it up on their next revision poll. No-op (returns false) when fast-deploy is
|
|
85
|
+
* disabled. The revision stored MUST equal the runtime's `getRevision()` so
|
|
86
|
+
* pollers don't see a permanent mismatch.
|
|
87
|
+
*/
|
|
88
|
+
async function writeSnapshotToKV(env: Record<string, unknown> | undefined): Promise<boolean> {
|
|
89
|
+
const kv = env ? getFastDeployKV(env) : null;
|
|
90
|
+
if (!kv) return false;
|
|
91
|
+
|
|
92
|
+
const blocks = loadBlocks();
|
|
93
|
+
const revision = getRevision();
|
|
94
|
+
await kv.put(KV_KEYS.SNAPSHOT, JSON.stringify(blocks));
|
|
95
|
+
if (revision) await kv.put(KV_KEYS.REVISION, revision);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function handleDecofileReload(
|
|
100
|
+
request: Request,
|
|
101
|
+
env?: Record<string, unknown>,
|
|
102
|
+
): Promise<Response> {
|
|
103
|
+
// Resolve the runtime env once. Prefer an explicitly-passed env, then the
|
|
104
|
+
// per-request Workers env stashed by workerEntry (`setRuntimeEnv`). Used for
|
|
105
|
+
// both the reload token and the fast-deploy KV binding.
|
|
106
|
+
const runtimeEnv = env ?? getRuntimeEnv();
|
|
107
|
+
|
|
108
|
+
// In dev mode the Vite plugin POSTs new blocks here to hot-reload without
|
|
109
|
+
// module invalidation (which breaks TanStack Start/Router state). Skip auth
|
|
110
|
+
// so the plugin can POST from localhost.
|
|
111
|
+
// Uses import.meta.env.DEV directly (not isDevMode()) because isDevMode()
|
|
112
|
+
// bypass auth. Vite statically replaces import.meta.env.DEV with `false`
|
|
113
|
+
// in production builds, so this branch is dead-code-eliminated.
|
|
114
|
+
const isViteDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV;
|
|
115
|
+
if (!isViteDev) {
|
|
116
|
+
const authHeader = request.headers.get("Authorization") || "";
|
|
117
|
+
const expectedToken =
|
|
118
|
+
(runtimeEnv?.DECO_RELEASE_RELOAD_TOKEN as string | undefined) ??
|
|
119
|
+
(typeof globalThis.process !== "undefined"
|
|
120
|
+
? globalThis.process.env?.DECO_RELEASE_RELOAD_TOKEN
|
|
121
|
+
: undefined);
|
|
122
|
+
|
|
123
|
+
if (!expectedToken || authHeader !== expectedToken) {
|
|
124
|
+
return new Response("Unauthorized", { status: 401 });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let body: unknown;
|
|
129
|
+
try {
|
|
130
|
+
body = await request.json();
|
|
131
|
+
} catch {
|
|
132
|
+
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
|
|
133
|
+
status: 400,
|
|
134
|
+
headers: { "Content-Type": "application/json" },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isObject(body)) {
|
|
139
|
+
return new Response(JSON.stringify({ error: "Body must be a JSON object" }), {
|
|
140
|
+
status: 400,
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const previousBlockCount = Object.keys(loadBlocks()).length;
|
|
146
|
+
|
|
147
|
+
// Delta merge (partial update) or full decofile replacement.
|
|
148
|
+
let nextBlocks: Record<string, unknown>;
|
|
149
|
+
let isDelta: boolean;
|
|
150
|
+
if (isDeltaPayload(body)) {
|
|
151
|
+
nextBlocks = applyDelta(loadBlocks(), body.blocks);
|
|
152
|
+
isDelta = true;
|
|
153
|
+
} else {
|
|
154
|
+
nextBlocks = body;
|
|
155
|
+
isDelta = false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
setBlocks(nextBlocks);
|
|
159
|
+
// Invalidate the meta ETag so the admin re-fetches the schema on next poll.
|
|
160
|
+
invalidateMetaCache();
|
|
161
|
+
// Clear stale loader cache entries after decofile update
|
|
162
|
+
clearLoaderCache();
|
|
163
|
+
|
|
164
|
+
// Fast-deploy: persist the new snapshot to KV so other isolates converge.
|
|
165
|
+
// A failed KV write does NOT fail the request — this isolate is already
|
|
166
|
+
// updated; we surface `kvWritten: false` so the caller can retry.
|
|
167
|
+
let kvWritten = false;
|
|
168
|
+
try {
|
|
169
|
+
kvWritten = await writeSnapshotToKV(runtimeEnv);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.warn("[CMS/KV] write-through after publish failed:", e);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const newBlockCount = Object.keys(nextBlocks).length;
|
|
175
|
+
const revision = getRevision();
|
|
176
|
+
|
|
177
|
+
return new Response(
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
ok: true,
|
|
180
|
+
mode: isDelta ? "delta" : "full",
|
|
181
|
+
previousBlockCount,
|
|
182
|
+
newBlockCount,
|
|
183
|
+
revision,
|
|
184
|
+
kvWritten,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
}),
|
|
187
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export { corsHeaders, isAdminOrLocalhost, registerAdminOrigin, registerAdminOrigins } from "./cors";
|
|
2
|
+
export { handleDecofileRead, handleDecofileReload, setFastDeployKVGetter } from "./decofile";
|
|
3
|
+
export {
|
|
4
|
+
clearInvokeHandlers,
|
|
5
|
+
handleInvoke,
|
|
6
|
+
type InvokeAction,
|
|
7
|
+
type InvokeLoader,
|
|
8
|
+
registerInvokeHandlers,
|
|
9
|
+
setInvokeActions,
|
|
10
|
+
setInvokeLoaders,
|
|
11
|
+
} from "./invoke";
|
|
12
|
+
export { LIVE_CONTROLS_SCRIPT } from "./liveControls";
|
|
13
|
+
export { handleMeta, setMetaData } from "./meta";
|
|
14
|
+
export { handleRender, setPreviewWrapper, setRenderShell } from "./render";
|
|
15
|
+
export {
|
|
16
|
+
composeMeta,
|
|
17
|
+
getRegisteredLoaders,
|
|
18
|
+
getRegisteredMatchers,
|
|
19
|
+
type ActionConfig,
|
|
20
|
+
type LoaderConfig,
|
|
21
|
+
type MatcherConfig,
|
|
22
|
+
type MetaResponse,
|
|
23
|
+
registerActionSchema,
|
|
24
|
+
registerActionSchemas,
|
|
25
|
+
registerLoaderSchema,
|
|
26
|
+
registerLoaderSchemas,
|
|
27
|
+
registerMatcherSchema,
|
|
28
|
+
registerMatcherSchemas,
|
|
29
|
+
} from "@decocms/blocks/cms";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for /deco/invoke Set-Cookie propagation.
|
|
3
|
+
*
|
|
4
|
+
* The historical bug: the single- and batch-invoke paths copied
|
|
5
|
+
* `RequestContext.responseHeaders` to the HTTP response via
|
|
6
|
+
* `headers.entries()`, which collapses multiple `Set-Cookie` values
|
|
7
|
+
* into a single comma-joined string. Browsers silently discard those,
|
|
8
|
+
* so every VTEX cart action lost its session cookies and the user
|
|
9
|
+
* ended up at /checkout with an empty cart.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin the fix: when a handler appends multiple
|
|
12
|
+
* Set-Cookie values to `RequestContext.responseHeaders`, the response
|
|
13
|
+
* returned by `handleInvoke` must surface them as N distinct
|
|
14
|
+
* Set-Cookie headers (readable via `response.headers.getSetCookie()`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
18
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
19
|
+
import {
|
|
20
|
+
clearInvokeHandlers,
|
|
21
|
+
handleInvoke,
|
|
22
|
+
registerInvokeHandlers,
|
|
23
|
+
} from "./invoke";
|
|
24
|
+
|
|
25
|
+
const COOKIE_A = "checkout.vtex.com__orderFormId=of-123; Path=/; HttpOnly";
|
|
26
|
+
const COOKIE_B = "segment=eyJjYW1wYWlnbnMiOiJ4In0=; Path=/; HttpOnly";
|
|
27
|
+
const COOKIE_C = "sc=1; Path=/; HttpOnly";
|
|
28
|
+
|
|
29
|
+
function makeInvokeRequest(key: string, body: unknown = {}): Request {
|
|
30
|
+
return new Request(`http://localhost/deco/invoke/${key}`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "content-type": "application/json" },
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeBatchRequest(body: Record<string, unknown>): Request {
|
|
38
|
+
return new Request("http://localhost/deco/invoke", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("handleInvoke — Set-Cookie propagation (single)", () => {
|
|
46
|
+
beforeEach(() => clearInvokeHandlers());
|
|
47
|
+
afterEach(() => clearInvokeHandlers());
|
|
48
|
+
|
|
49
|
+
it("forwards multiple Set-Cookie values as distinct headers", async () => {
|
|
50
|
+
registerInvokeHandlers({
|
|
51
|
+
"vtex/actions/addItemsToCart": async () => {
|
|
52
|
+
RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
|
|
53
|
+
RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
|
|
54
|
+
RequestContext.responseHeaders.append("set-cookie", COOKIE_C);
|
|
55
|
+
return { orderFormId: "of-123" };
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const request = makeInvokeRequest("vtex/actions/addItemsToCart");
|
|
60
|
+
const response = await RequestContext.run(request, () => handleInvoke(request));
|
|
61
|
+
|
|
62
|
+
const cookies = response.headers.getSetCookie();
|
|
63
|
+
expect(cookies).toHaveLength(3);
|
|
64
|
+
expect(cookies).toContain(COOKIE_A);
|
|
65
|
+
expect(cookies).toContain(COOKIE_B);
|
|
66
|
+
expect(cookies).toContain(COOKIE_C);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not collapse cookies into a single Set-Cookie entry", async () => {
|
|
70
|
+
registerInvokeHandlers({
|
|
71
|
+
"vtex/actions/foo": async () => {
|
|
72
|
+
RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
|
|
73
|
+
RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
|
|
74
|
+
return {};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const request = makeInvokeRequest("vtex/actions/foo");
|
|
79
|
+
const response = await RequestContext.run(request, () => handleInvoke(request));
|
|
80
|
+
|
|
81
|
+
// The regressed bug appended a single comma-joined string, so
|
|
82
|
+
// `getSetCookie()` returned a 1-element array. The fix appends each
|
|
83
|
+
// value individually — verifying the count alone catches the regression.
|
|
84
|
+
expect(response.headers.getSetCookie()).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("forwards non-cookie headers unchanged", async () => {
|
|
88
|
+
registerInvokeHandlers({
|
|
89
|
+
"vtex/actions/withHeader": async () => {
|
|
90
|
+
RequestContext.responseHeaders.append("x-vtex-trace-id", "abc-123");
|
|
91
|
+
return {};
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const request = makeInvokeRequest("vtex/actions/withHeader");
|
|
96
|
+
const response = await RequestContext.run(request, () => handleInvoke(request));
|
|
97
|
+
expect(response.headers.get("x-vtex-trace-id")).toBe("abc-123");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not forward Set-Cookie when handler writes none", async () => {
|
|
101
|
+
registerInvokeHandlers({
|
|
102
|
+
"vtex/loaders/productList": async () => ({ items: [] }),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const request = makeInvokeRequest("vtex/loaders/productList");
|
|
106
|
+
const response = await RequestContext.run(request, () => handleInvoke(request));
|
|
107
|
+
expect(response.headers.getSetCookie()).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("handleInvoke — Set-Cookie propagation (batch)", () => {
|
|
112
|
+
beforeEach(() => clearInvokeHandlers());
|
|
113
|
+
afterEach(() => clearInvokeHandlers());
|
|
114
|
+
|
|
115
|
+
it("forwards cookies that batch handlers append to the shared context", async () => {
|
|
116
|
+
registerInvokeHandlers({
|
|
117
|
+
"vtex/actions/addItemsToCart": async () => {
|
|
118
|
+
RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
|
|
119
|
+
RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
|
|
120
|
+
return { orderFormId: "of-123" };
|
|
121
|
+
},
|
|
122
|
+
"vtex/loaders/productList": async () => {
|
|
123
|
+
// Loader writes its own cookie (e.g. segment) — must also propagate.
|
|
124
|
+
RequestContext.responseHeaders.append("set-cookie", COOKIE_C);
|
|
125
|
+
return { items: [] };
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const request = makeBatchRequest({
|
|
130
|
+
"vtex/actions/addItemsToCart": { orderFormId: "x" },
|
|
131
|
+
"vtex/loaders/productList": {},
|
|
132
|
+
});
|
|
133
|
+
const response = await RequestContext.run(request, () => handleInvoke(request));
|
|
134
|
+
|
|
135
|
+
const cookies = response.headers.getSetCookie();
|
|
136
|
+
expect(cookies).toHaveLength(3);
|
|
137
|
+
expect(cookies).toContain(COOKIE_A);
|
|
138
|
+
expect(cookies).toContain(COOKIE_B);
|
|
139
|
+
expect(cookies).toContain(COOKIE_C);
|
|
140
|
+
});
|
|
141
|
+
});
|