@bettercms-ai/sdk 1.38.0 → 1.39.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/draft.cjs +8 -0
- package/dist/draft.cjs.map +1 -1
- package/dist/draft.d.cts +9 -1
- package/dist/draft.d.ts +9 -1
- package/dist/draft.js +6 -0
- package/dist/draft.js.map +1 -1
- package/dist/index.cjs +5 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/{read-client-B1uDVNKk.d.cts → read-client-CnkVzNP0.d.cts} +5 -0
- package/dist/{read-client-B1uDVNKk.d.ts → read-client-CnkVzNP0.d.ts} +5 -0
- package/dist/read.cjs +5 -0
- package/dist/read.cjs.map +1 -1
- package/dist/read.d.cts +1 -1
- package/dist/read.d.ts +1 -1
- package/dist/read.js +5 -0
- package/dist/read.js.map +1 -1
- package/dist/sections.cjs +76 -74
- package/dist/sections.cjs.map +1 -1
- package/dist/sections.d.cts +1 -1
- package/dist/sections.d.ts +1 -1
- package/dist/sections.js +76 -74
- package/dist/sections.js.map +1 -1
- package/package.json +1 -1
package/dist/draft.cjs
CHANGED
|
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/draft.ts
|
|
21
21
|
var draft_exports = {};
|
|
22
22
|
__export(draft_exports, {
|
|
23
|
+
BRANCH_SIGNATURE_HEADER: () => BRANCH_SIGNATURE_HEADER,
|
|
23
24
|
DRAFT_COOKIE: () => DRAFT_COOKIE,
|
|
24
25
|
decodeTokenBranch: () => decodeTokenBranch,
|
|
25
26
|
decodeTokenSlug: () => decodeTokenSlug,
|
|
@@ -28,6 +29,7 @@ __export(draft_exports, {
|
|
|
28
29
|
draftTokenFrom: () => draftTokenFrom,
|
|
29
30
|
previewTokenValid: () => previewTokenValid,
|
|
30
31
|
safeRedirectPath: () => safeRedirectPath,
|
|
32
|
+
signBranchRead: () => signBranchRead,
|
|
31
33
|
signValue: () => signValue,
|
|
32
34
|
verifyValue: () => verifyValue
|
|
33
35
|
});
|
|
@@ -56,6 +58,10 @@ async function verifyValue(signed, secret) {
|
|
|
56
58
|
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
57
59
|
return diff === 0 ? value : null;
|
|
58
60
|
}
|
|
61
|
+
var BRANCH_SIGNATURE_HEADER = "X-BCMS-Branch-Signature";
|
|
62
|
+
async function signBranchRead(branch, secret, now = Date.now()) {
|
|
63
|
+
return signValue(`${Math.floor(now / 1e3)}:${branch}`, secret);
|
|
64
|
+
}
|
|
59
65
|
function safeRedirectPath(to) {
|
|
60
66
|
return to.startsWith("/") && !to.startsWith("//") && !to.startsWith("/\\") ? to : "/";
|
|
61
67
|
}
|
|
@@ -119,6 +125,7 @@ function draftDisableResponse(request) {
|
|
|
119
125
|
}
|
|
120
126
|
// Annotate the CommonJS export names for ESM import in node:
|
|
121
127
|
0 && (module.exports = {
|
|
128
|
+
BRANCH_SIGNATURE_HEADER,
|
|
122
129
|
DRAFT_COOKIE,
|
|
123
130
|
decodeTokenBranch,
|
|
124
131
|
decodeTokenSlug,
|
|
@@ -127,6 +134,7 @@ function draftDisableResponse(request) {
|
|
|
127
134
|
draftTokenFrom,
|
|
128
135
|
previewTokenValid,
|
|
129
136
|
safeRedirectPath,
|
|
137
|
+
signBranchRead,
|
|
130
138
|
signValue,
|
|
131
139
|
verifyValue
|
|
132
140
|
});
|
package/dist/draft.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/draft.ts"],"sourcesContent":["/**\n * Draft mode, shared by every framework adapter: the signed cookie that says \"this visitor is\n * previewing\", the preview token's slug, and the redirect a draft route may follow.\n *\n * Web Crypto and `atob` only, so it runs in Node, edge runtimes and workers alike. Each adapter\n * mounts its own routes around these; the rules live here once.\n */\n\n/** The draft cookie every adapter sets: one name, so an editor link works on any framework. */\nexport const DRAFT_COOKIE = \"bcms-draft\";\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\"raw\", enc.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage (HMAC-SHA256, base64url). */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** The original value of a signed one, or null when it was tampered with. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths: blocks \"//evil.com\", \"https://evil.com\" and \"/\\\\evil.com\". */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") && !to.startsWith(\"/\\\\\") ? to : \"/\";\n}\n\nfunction tokenPayload(token: string): { entrySlug?: string; branch?: string } | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n return JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string; branch?: string };\n } catch {\n return null;\n }\n}\n\n/**\n * The content branch a preview token was minted on, or null for main. Read UNVERIFIED: an adapter\n * only stores a token the backend accepted, and branch reads need the site's draft key regardless.\n * Pass it as the client's `branch` in draft mode and the whole site previews from that branch.\n */\nexport function decodeTokenBranch(token: string | null | undefined): string | null {\n return (token && tokenPayload(token)?.branch) || null;\n}\n\n/** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n const json = JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Ask the backend whether a preview token is good: the backend is the signature authority, so a\n * draft route enables draft mode only on its yes.\n */\nexport async function previewTokenValid(apiUrl: string, token: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {\n const slug = decodeTokenSlug(token);\n if (!slug) return false;\n const res = await fetchImpl(`${apiUrl.replace(/\\/+$/, \"\")}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`);\n await res.body?.cancel().catch(() => {});\n return res.ok;\n}\n\n/** The draft cookie's preview token from a `Cookie` header, or null when absent or tampered with. */\nexport async function draftTokenFrom(cookieHeader: string | null | undefined, secret: string | undefined): Promise<string | null> {\n if (!cookieHeader || !secret) return null;\n const m = new RegExp(`(?:^|;\\\\s*)${DRAFT_COOKIE}=([^;]+)`).exec(cookieHeader);\n if (!m) return null;\n try {\n return await verifyValue(decodeURIComponent(m[1]!), secret);\n } catch {\n return null;\n }\n}\n\n/**\n * The draft-enable route for any server that speaks Web `Request`/`Response` (SvelteKit `+server.ts`,\n * Nuxt via `toWebRequest`, Remix, Hono): `GET ?token=…&redirect=/x` checks the token with the\n * backend, sets the signed draft cookie and redirects. 401 on a refused token; 500 without a secret.\n */\nexport async function draftEnableResponse(request: Request, opts: { apiUrl: string; secret: string | undefined; fetchImpl?: typeof fetch }): Promise<Response> {\n const url = new URL(request.url);\n const token = url.searchParams.get(\"token\");\n if (!opts.secret) return new Response(\"BCMS_DRAFT_SECRET is not configured\", { status: 500 });\n if (!token) return new Response(\"Missing token\", { status: 400 });\n if (!(await previewTokenValid(opts.apiUrl, token, opts.fetchImpl))) return new Response(\"Invalid or expired token\", { status: 401 });\n const cookie = `${DRAFT_COOKIE}=${encodeURIComponent(await signValue(token, opts.secret))}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${url.protocol === \"https:\" ? \"; Secure\" : \"\"}`;\n return new Response(null, { status: 302, headers: { Location: safeRedirectPath(url.searchParams.get(\"redirect\") ?? \"/\"), \"Set-Cookie\": cookie } });\n}\n\n/** Leave draft mode: clear the cookie and redirect (`?redirect=/x`, same-origin only). */\nexport function draftDisableResponse(request: Request): Response {\n const url = new URL(request.url);\n return new Response(null, {\n status: 302,\n headers: { Location: safeRedirectPath(url.searchParams.get(\"redirect\") ?? \"/\"), \"Set-Cookie\": `${DRAFT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` },\n });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/draft.ts"],"sourcesContent":["/**\n * Draft mode, shared by every framework adapter: the signed cookie that says \"this visitor is\n * previewing\", the preview token's slug, and the redirect a draft route may follow.\n *\n * Web Crypto and `atob` only, so it runs in Node, edge runtimes and workers alike. Each adapter\n * mounts its own routes around these; the rules live here once.\n */\n\n/** The draft cookie every adapter sets: one name, so an editor link works on any framework. */\nexport const DRAFT_COOKIE = \"bcms-draft\";\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\"raw\", enc.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage (HMAC-SHA256, base64url). */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** The original value of a signed one, or null when it was tampered with. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** The header a server signs a content-branch read with (@see signBranchRead). */\nexport const BRANCH_SIGNATURE_HEADER = \"X-BCMS-Branch-Signature\";\n\n/**\n * Sign a read of content branch `branch` with `BCMS_BRANCH_SECRET`, which BetterCMS hosting gives a\n * server it runs (and nothing else has). The Delivery API serves the branch to this signature with\n * no draft key. A signature is good for five minutes: sign per request.\n */\nexport async function signBranchRead(branch: string, secret: string, now = Date.now()): Promise<string> {\n return signValue(`${Math.floor(now / 1000)}:${branch}`, secret);\n}\n\n/** Only same-origin relative paths: blocks \"//evil.com\", \"https://evil.com\" and \"/\\\\evil.com\". */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") && !to.startsWith(\"/\\\\\") ? to : \"/\";\n}\n\nfunction tokenPayload(token: string): { entrySlug?: string; branch?: string } | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n return JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string; branch?: string };\n } catch {\n return null;\n }\n}\n\n/**\n * The content branch a preview token was minted on, or null for main. Read UNVERIFIED: an adapter\n * only stores a token the backend accepted, and branch reads need the site's draft key regardless.\n * Pass it as the client's `branch` in draft mode and the whole site previews from that branch.\n */\nexport function decodeTokenBranch(token: string | null | undefined): string | null {\n return (token && tokenPayload(token)?.branch) || null;\n}\n\n/** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n const json = JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Ask the backend whether a preview token is good: the backend is the signature authority, so a\n * draft route enables draft mode only on its yes.\n */\nexport async function previewTokenValid(apiUrl: string, token: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {\n const slug = decodeTokenSlug(token);\n if (!slug) return false;\n const res = await fetchImpl(`${apiUrl.replace(/\\/+$/, \"\")}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`);\n await res.body?.cancel().catch(() => {});\n return res.ok;\n}\n\n/** The draft cookie's preview token from a `Cookie` header, or null when absent or tampered with. */\nexport async function draftTokenFrom(cookieHeader: string | null | undefined, secret: string | undefined): Promise<string | null> {\n if (!cookieHeader || !secret) return null;\n const m = new RegExp(`(?:^|;\\\\s*)${DRAFT_COOKIE}=([^;]+)`).exec(cookieHeader);\n if (!m) return null;\n try {\n return await verifyValue(decodeURIComponent(m[1]!), secret);\n } catch {\n return null;\n }\n}\n\n/**\n * The draft-enable route for any server that speaks Web `Request`/`Response` (SvelteKit `+server.ts`,\n * Nuxt via `toWebRequest`, Remix, Hono): `GET ?token=…&redirect=/x` checks the token with the\n * backend, sets the signed draft cookie and redirects. 401 on a refused token; 500 without a secret.\n */\nexport async function draftEnableResponse(request: Request, opts: { apiUrl: string; secret: string | undefined; fetchImpl?: typeof fetch }): Promise<Response> {\n const url = new URL(request.url);\n const token = url.searchParams.get(\"token\");\n if (!opts.secret) return new Response(\"BCMS_DRAFT_SECRET is not configured\", { status: 500 });\n if (!token) return new Response(\"Missing token\", { status: 400 });\n if (!(await previewTokenValid(opts.apiUrl, token, opts.fetchImpl))) return new Response(\"Invalid or expired token\", { status: 401 });\n const cookie = `${DRAFT_COOKIE}=${encodeURIComponent(await signValue(token, opts.secret))}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${url.protocol === \"https:\" ? \"; Secure\" : \"\"}`;\n return new Response(null, { status: 302, headers: { Location: safeRedirectPath(url.searchParams.get(\"redirect\") ?? \"/\"), \"Set-Cookie\": cookie } });\n}\n\n/** Leave draft mode: clear the cookie and redirect (`?redirect=/x`, same-origin only). */\nexport function draftDisableResponse(request: Request): Response {\n const url = new URL(request.url);\n return new Response(null, {\n status: 302,\n headers: { Location: safeRedirectPath(url.searchParams.get(\"redirect\") ?? \"/\"), \"Set-Cookie\": `${DRAFT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` },\n });\n}\n"],"mappings":"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","names":[]}
|
package/dist/draft.d.cts
CHANGED
|
@@ -11,6 +11,14 @@ declare const DRAFT_COOKIE = "bcms-draft";
|
|
|
11
11
|
declare function signValue(value: string, secret: string): Promise<string>;
|
|
12
12
|
/** The original value of a signed one, or null when it was tampered with. */
|
|
13
13
|
declare function verifyValue(signed: string, secret: string): Promise<string | null>;
|
|
14
|
+
/** The header a server signs a content-branch read with (@see signBranchRead). */
|
|
15
|
+
declare const BRANCH_SIGNATURE_HEADER = "X-BCMS-Branch-Signature";
|
|
16
|
+
/**
|
|
17
|
+
* Sign a read of content branch `branch` with `BCMS_BRANCH_SECRET`, which BetterCMS hosting gives a
|
|
18
|
+
* server it runs (and nothing else has). The Delivery API serves the branch to this signature with
|
|
19
|
+
* no draft key. A signature is good for five minutes: sign per request.
|
|
20
|
+
*/
|
|
21
|
+
declare function signBranchRead(branch: string, secret: string, now?: number): Promise<string>;
|
|
14
22
|
/** Only same-origin relative paths: blocks "//evil.com", "https://evil.com" and "/\\evil.com". */
|
|
15
23
|
declare function safeRedirectPath(to: string): string;
|
|
16
24
|
/**
|
|
@@ -41,4 +49,4 @@ declare function draftEnableResponse(request: Request, opts: {
|
|
|
41
49
|
/** Leave draft mode: clear the cookie and redirect (`?redirect=/x`, same-origin only). */
|
|
42
50
|
declare function draftDisableResponse(request: Request): Response;
|
|
43
51
|
|
|
44
|
-
export { DRAFT_COOKIE, decodeTokenBranch, decodeTokenSlug, draftDisableResponse, draftEnableResponse, draftTokenFrom, previewTokenValid, safeRedirectPath, signValue, verifyValue };
|
|
52
|
+
export { BRANCH_SIGNATURE_HEADER, DRAFT_COOKIE, decodeTokenBranch, decodeTokenSlug, draftDisableResponse, draftEnableResponse, draftTokenFrom, previewTokenValid, safeRedirectPath, signBranchRead, signValue, verifyValue };
|
package/dist/draft.d.ts
CHANGED
|
@@ -11,6 +11,14 @@ declare const DRAFT_COOKIE = "bcms-draft";
|
|
|
11
11
|
declare function signValue(value: string, secret: string): Promise<string>;
|
|
12
12
|
/** The original value of a signed one, or null when it was tampered with. */
|
|
13
13
|
declare function verifyValue(signed: string, secret: string): Promise<string | null>;
|
|
14
|
+
/** The header a server signs a content-branch read with (@see signBranchRead). */
|
|
15
|
+
declare const BRANCH_SIGNATURE_HEADER = "X-BCMS-Branch-Signature";
|
|
16
|
+
/**
|
|
17
|
+
* Sign a read of content branch `branch` with `BCMS_BRANCH_SECRET`, which BetterCMS hosting gives a
|
|
18
|
+
* server it runs (and nothing else has). The Delivery API serves the branch to this signature with
|
|
19
|
+
* no draft key. A signature is good for five minutes: sign per request.
|
|
20
|
+
*/
|
|
21
|
+
declare function signBranchRead(branch: string, secret: string, now?: number): Promise<string>;
|
|
14
22
|
/** Only same-origin relative paths: blocks "//evil.com", "https://evil.com" and "/\\evil.com". */
|
|
15
23
|
declare function safeRedirectPath(to: string): string;
|
|
16
24
|
/**
|
|
@@ -41,4 +49,4 @@ declare function draftEnableResponse(request: Request, opts: {
|
|
|
41
49
|
/** Leave draft mode: clear the cookie and redirect (`?redirect=/x`, same-origin only). */
|
|
42
50
|
declare function draftDisableResponse(request: Request): Response;
|
|
43
51
|
|
|
44
|
-
export { DRAFT_COOKIE, decodeTokenBranch, decodeTokenSlug, draftDisableResponse, draftEnableResponse, draftTokenFrom, previewTokenValid, safeRedirectPath, signValue, verifyValue };
|
|
52
|
+
export { BRANCH_SIGNATURE_HEADER, DRAFT_COOKIE, decodeTokenBranch, decodeTokenSlug, draftDisableResponse, draftEnableResponse, draftTokenFrom, previewTokenValid, safeRedirectPath, signBranchRead, signValue, verifyValue };
|
package/dist/draft.js
CHANGED
|
@@ -23,6 +23,10 @@ async function verifyValue(signed, secret) {
|
|
|
23
23
|
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
24
24
|
return diff === 0 ? value : null;
|
|
25
25
|
}
|
|
26
|
+
var BRANCH_SIGNATURE_HEADER = "X-BCMS-Branch-Signature";
|
|
27
|
+
async function signBranchRead(branch, secret, now = Date.now()) {
|
|
28
|
+
return signValue(`${Math.floor(now / 1e3)}:${branch}`, secret);
|
|
29
|
+
}
|
|
26
30
|
function safeRedirectPath(to) {
|
|
27
31
|
return to.startsWith("/") && !to.startsWith("//") && !to.startsWith("/\\") ? to : "/";
|
|
28
32
|
}
|
|
@@ -85,6 +89,7 @@ function draftDisableResponse(request) {
|
|
|
85
89
|
});
|
|
86
90
|
}
|
|
87
91
|
export {
|
|
92
|
+
BRANCH_SIGNATURE_HEADER,
|
|
88
93
|
DRAFT_COOKIE,
|
|
89
94
|
decodeTokenBranch,
|
|
90
95
|
decodeTokenSlug,
|
|
@@ -93,6 +98,7 @@ export {
|
|
|
93
98
|
draftTokenFrom,
|
|
94
99
|
previewTokenValid,
|
|
95
100
|
safeRedirectPath,
|
|
101
|
+
signBranchRead,
|
|
96
102
|
signValue,
|
|
97
103
|
verifyValue
|
|
98
104
|
};
|
package/dist/draft.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/draft.ts"],"sourcesContent":["/**\n * Draft mode, shared by every framework adapter: the signed cookie that says \"this visitor is\n * previewing\", the preview token's slug, and the redirect a draft route may follow.\n *\n * Web Crypto and `atob` only, so it runs in Node, edge runtimes and workers alike. Each adapter\n * mounts its own routes around these; the rules live here once.\n */\n\n/** The draft cookie every adapter sets: one name, so an editor link works on any framework. */\nexport const DRAFT_COOKIE = \"bcms-draft\";\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\"raw\", enc.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage (HMAC-SHA256, base64url). */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** The original value of a signed one, or null when it was tampered with. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths: blocks \"//evil.com\", \"https://evil.com\" and \"/\\\\evil.com\". */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") && !to.startsWith(\"/\\\\\") ? to : \"/\";\n}\n\nfunction tokenPayload(token: string): { entrySlug?: string; branch?: string } | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n return JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string; branch?: string };\n } catch {\n return null;\n }\n}\n\n/**\n * The content branch a preview token was minted on, or null for main. Read UNVERIFIED: an adapter\n * only stores a token the backend accepted, and branch reads need the site's draft key regardless.\n * Pass it as the client's `branch` in draft mode and the whole site previews from that branch.\n */\nexport function decodeTokenBranch(token: string | null | undefined): string | null {\n return (token && tokenPayload(token)?.branch) || null;\n}\n\n/** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n const json = JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Ask the backend whether a preview token is good: the backend is the signature authority, so a\n * draft route enables draft mode only on its yes.\n */\nexport async function previewTokenValid(apiUrl: string, token: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {\n const slug = decodeTokenSlug(token);\n if (!slug) return false;\n const res = await fetchImpl(`${apiUrl.replace(/\\/+$/, \"\")}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`);\n await res.body?.cancel().catch(() => {});\n return res.ok;\n}\n\n/** The draft cookie's preview token from a `Cookie` header, or null when absent or tampered with. */\nexport async function draftTokenFrom(cookieHeader: string | null | undefined, secret: string | undefined): Promise<string | null> {\n if (!cookieHeader || !secret) return null;\n const m = new RegExp(`(?:^|;\\\\s*)${DRAFT_COOKIE}=([^;]+)`).exec(cookieHeader);\n if (!m) return null;\n try {\n return await verifyValue(decodeURIComponent(m[1]!), secret);\n } catch {\n return null;\n }\n}\n\n/**\n * The draft-enable route for any server that speaks Web `Request`/`Response` (SvelteKit `+server.ts`,\n * Nuxt via `toWebRequest`, Remix, Hono): `GET ?token=…&redirect=/x` checks the token with the\n * backend, sets the signed draft cookie and redirects. 401 on a refused token; 500 without a secret.\n */\nexport async function draftEnableResponse(request: Request, opts: { apiUrl: string; secret: string | undefined; fetchImpl?: typeof fetch }): Promise<Response> {\n const url = new URL(request.url);\n const token = url.searchParams.get(\"token\");\n if (!opts.secret) return new Response(\"BCMS_DRAFT_SECRET is not configured\", { status: 500 });\n if (!token) return new Response(\"Missing token\", { status: 400 });\n if (!(await previewTokenValid(opts.apiUrl, token, opts.fetchImpl))) return new Response(\"Invalid or expired token\", { status: 401 });\n const cookie = `${DRAFT_COOKIE}=${encodeURIComponent(await signValue(token, opts.secret))}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${url.protocol === \"https:\" ? \"; Secure\" : \"\"}`;\n return new Response(null, { status: 302, headers: { Location: safeRedirectPath(url.searchParams.get(\"redirect\") ?? \"/\"), \"Set-Cookie\": cookie } });\n}\n\n/** Leave draft mode: clear the cookie and redirect (`?redirect=/x`, same-origin only). */\nexport function draftDisableResponse(request: Request): Response {\n const url = new URL(request.url);\n return new Response(null, {\n status: 302,\n headers: { Location: safeRedirectPath(url.searchParams.get(\"redirect\") ?? \"/\"), \"Set-Cookie\": `${DRAFT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` },\n });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/draft.ts"],"sourcesContent":["/**\n * Draft mode, shared by every framework adapter: the signed cookie that says \"this visitor is\n * previewing\", the preview token's slug, and the redirect a draft route may follow.\n *\n * Web Crypto and `atob` only, so it runs in Node, edge runtimes and workers alike. Each adapter\n * mounts its own routes around these; the rules live here once.\n */\n\n/** The draft cookie every adapter sets: one name, so an editor link works on any framework. */\nexport const DRAFT_COOKIE = \"bcms-draft\";\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\"raw\", enc.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage (HMAC-SHA256, base64url). */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** The original value of a signed one, or null when it was tampered with. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** The header a server signs a content-branch read with (@see signBranchRead). */\nexport const BRANCH_SIGNATURE_HEADER = \"X-BCMS-Branch-Signature\";\n\n/**\n * Sign a read of content branch `branch` with `BCMS_BRANCH_SECRET`, which BetterCMS hosting gives a\n * server it runs (and nothing else has). The Delivery API serves the branch to this signature with\n * no draft key. A signature is good for five minutes: sign per request.\n */\nexport async function signBranchRead(branch: string, secret: string, now = Date.now()): Promise<string> {\n return signValue(`${Math.floor(now / 1000)}:${branch}`, secret);\n}\n\n/** Only same-origin relative paths: blocks \"//evil.com\", \"https://evil.com\" and \"/\\\\evil.com\". */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") && !to.startsWith(\"/\\\\\") ? to : \"/\";\n}\n\nfunction tokenPayload(token: string): { entrySlug?: string; branch?: string } | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n return JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string; branch?: string };\n } catch {\n return null;\n }\n}\n\n/**\n * The content branch a preview token was minted on, or null for main. Read UNVERIFIED: an adapter\n * only stores a token the backend accepted, and branch reads need the site's draft key regardless.\n * Pass it as the client's `branch` in draft mode and the whole site previews from that branch.\n */\nexport function decodeTokenBranch(token: string | null | undefined): string | null {\n return (token && tokenPayload(token)?.branch) || null;\n}\n\n/** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n const json = JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Ask the backend whether a preview token is good: the backend is the signature authority, so a\n * draft route enables draft mode only on its yes.\n */\nexport async function previewTokenValid(apiUrl: string, token: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {\n const slug = decodeTokenSlug(token);\n if (!slug) return false;\n const res = await fetchImpl(`${apiUrl.replace(/\\/+$/, \"\")}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`);\n await res.body?.cancel().catch(() => {});\n return res.ok;\n}\n\n/** The draft cookie's preview token from a `Cookie` header, or null when absent or tampered with. */\nexport async function draftTokenFrom(cookieHeader: string | null | undefined, secret: string | undefined): Promise<string | null> {\n if (!cookieHeader || !secret) return null;\n const m = new RegExp(`(?:^|;\\\\s*)${DRAFT_COOKIE}=([^;]+)`).exec(cookieHeader);\n if (!m) return null;\n try {\n return await verifyValue(decodeURIComponent(m[1]!), secret);\n } catch {\n return null;\n }\n}\n\n/**\n * The draft-enable route for any server that speaks Web `Request`/`Response` (SvelteKit `+server.ts`,\n * Nuxt via `toWebRequest`, Remix, Hono): `GET ?token=…&redirect=/x` checks the token with the\n * backend, sets the signed draft cookie and redirects. 401 on a refused token; 500 without a secret.\n */\nexport async function draftEnableResponse(request: Request, opts: { apiUrl: string; secret: string | undefined; fetchImpl?: typeof fetch }): Promise<Response> {\n const url = new URL(request.url);\n const token = url.searchParams.get(\"token\");\n if (!opts.secret) return new Response(\"BCMS_DRAFT_SECRET is not configured\", { status: 500 });\n if (!token) return new Response(\"Missing token\", { status: 400 });\n if (!(await previewTokenValid(opts.apiUrl, token, opts.fetchImpl))) return new Response(\"Invalid or expired token\", { status: 401 });\n const cookie = `${DRAFT_COOKIE}=${encodeURIComponent(await signValue(token, opts.secret))}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${url.protocol === \"https:\" ? \"; Secure\" : \"\"}`;\n return new Response(null, { status: 302, headers: { Location: safeRedirectPath(url.searchParams.get(\"redirect\") ?? \"/\"), \"Set-Cookie\": cookie } });\n}\n\n/** Leave draft mode: clear the cookie and redirect (`?redirect=/x`, same-origin only). */\nexport function draftDisableResponse(request: Request): Response {\n const url = new URL(request.url);\n return new Response(null, {\n status: 302,\n headers: { Location: safeRedirectPath(url.searchParams.get(\"redirect\") ?? \"/\"), \"Set-Cookie\": `${DRAFT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` },\n });\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.cjs
CHANGED
|
@@ -1301,6 +1301,10 @@ var BetterCMS = {
|
|
|
1301
1301
|
}
|
|
1302
1302
|
};
|
|
1303
1303
|
|
|
1304
|
+
// src/draft.ts
|
|
1305
|
+
var enc = new TextEncoder();
|
|
1306
|
+
var BRANCH_SIGNATURE_HEADER = "X-BCMS-Branch-Signature";
|
|
1307
|
+
|
|
1304
1308
|
// src/read-client.ts
|
|
1305
1309
|
var DEFAULT_TIMEOUT3 = 1e4;
|
|
1306
1310
|
function qs(params2) {
|
|
@@ -1360,6 +1364,7 @@ function createClient(options) {
|
|
|
1360
1364
|
const headers = () => {
|
|
1361
1365
|
const h = { "Content-Type": "application/json" };
|
|
1362
1366
|
if (apiKey) h["X-API-Key"] = apiKey;
|
|
1367
|
+
if (branch && options.branchSignature) h[BRANCH_SIGNATURE_HEADER] = options.branchSignature;
|
|
1363
1368
|
return h;
|
|
1364
1369
|
};
|
|
1365
1370
|
const collapse = options.cache && perspective === "published" ? createRequestCache() : void 0;
|