@bettercms-ai/sdk 1.37.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 CHANGED
@@ -20,13 +20,16 @@ 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,
25
+ decodeTokenBranch: () => decodeTokenBranch,
24
26
  decodeTokenSlug: () => decodeTokenSlug,
25
27
  draftDisableResponse: () => draftDisableResponse,
26
28
  draftEnableResponse: () => draftEnableResponse,
27
29
  draftTokenFrom: () => draftTokenFrom,
28
30
  previewTokenValid: () => previewTokenValid,
29
31
  safeRedirectPath: () => safeRedirectPath,
32
+ signBranchRead: () => signBranchRead,
30
33
  signValue: () => signValue,
31
34
  verifyValue: () => verifyValue
32
35
  });
@@ -55,9 +58,26 @@ async function verifyValue(signed, secret) {
55
58
  for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
56
59
  return diff === 0 ? value : null;
57
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
+ }
58
65
  function safeRedirectPath(to) {
59
66
  return to.startsWith("/") && !to.startsWith("//") && !to.startsWith("/\\") ? to : "/";
60
67
  }
68
+ function tokenPayload(token) {
69
+ try {
70
+ const part = token.split(".")[1];
71
+ if (!part) return null;
72
+ const bytes = Uint8Array.from(atob(part.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
73
+ return JSON.parse(new TextDecoder().decode(bytes));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+ function decodeTokenBranch(token) {
79
+ return token && tokenPayload(token)?.branch || null;
80
+ }
61
81
  function decodeTokenSlug(token) {
62
82
  try {
63
83
  const part = token.split(".")[1];
@@ -105,13 +125,16 @@ function draftDisableResponse(request) {
105
125
  }
106
126
  // Annotate the CommonJS export names for ESM import in node:
107
127
  0 && (module.exports = {
128
+ BRANCH_SIGNATURE_HEADER,
108
129
  DRAFT_COOKIE,
130
+ decodeTokenBranch,
109
131
  decodeTokenSlug,
110
132
  draftDisableResponse,
111
133
  draftEnableResponse,
112
134
  draftTokenFrom,
113
135
  previewTokenValid,
114
136
  safeRedirectPath,
137
+ signBranchRead,
115
138
  signValue,
116
139
  verifyValue
117
140
  });
@@ -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\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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASO,IAAM,eAAe;AAE5B,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO,UAAU,OAAO,IAAI,OAAO,MAAM,GAAG,EAAE,MAAM,QAAQ,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;AACvH,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAGA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AACzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;AAGO,IAAM,0BAA0B;AAOvC,eAAsB,eAAe,QAAgB,QAAgB,MAAM,KAAK,IAAI,GAAoB;AACtG,SAAO,UAAU,GAAG,KAAK,MAAM,MAAM,GAAI,CAAC,IAAI,MAAM,IAAI,MAAM;AAChE;AAGO,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,KAAK,CAAC,GAAG,WAAW,KAAK,IAAI,KAAK;AACpF;AAEA,SAAS,aAAa,OAA+D;AACnF,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAElB,UAAM,QAAQ,WAAW,KAAK,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AACtG,WAAO,KAAK,MAAM,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC;AAAA,EACnD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOO,SAAS,kBAAkB,OAAiD;AACjF,SAAQ,SAAS,aAAa,KAAK,GAAG,UAAW;AACnD;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAElB,UAAM,QAAQ,WAAW,KAAK,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AACtG,UAAM,OAAO,KAAK,MAAM,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC;AACvD,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,eAAsB,kBAAkB,QAAgB,OAAe,YAA0B,OAAyB;AACxH,QAAM,OAAO,gBAAgB,KAAK;AAClC,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,MAAM,MAAM,UAAU,GAAG,OAAO,QAAQ,QAAQ,EAAE,CAAC,mBAAmB,mBAAmB,IAAI,CAAC,UAAU,mBAAmB,KAAK,CAAC,EAAE;AACzI,QAAM,IAAI,MAAM,OAAO,EAAE,MAAM,MAAM;AAAA,EAAC,CAAC;AACvC,SAAO,IAAI;AACb;AAGA,eAAsB,eAAe,cAAyC,QAAoD;AAChI,MAAI,CAAC,gBAAgB,CAAC,OAAQ,QAAO;AACrC,QAAM,IAAI,IAAI,OAAO,cAAc,YAAY,UAAU,EAAE,KAAK,YAAY;AAC5E,MAAI,CAAC,EAAG,QAAO;AACf,MAAI;AACF,WAAO,MAAM,YAAY,mBAAmB,EAAE,CAAC,CAAE,GAAG,MAAM;AAAA,EAC5D,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOA,eAAsB,oBAAoB,SAAkB,MAAmG;AAC7J,QAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,QAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,MAAI,CAAC,KAAK,OAAQ,QAAO,IAAI,SAAS,uCAAuC,EAAE,QAAQ,IAAI,CAAC;AAC5F,MAAI,CAAC,MAAO,QAAO,IAAI,SAAS,iBAAiB,EAAE,QAAQ,IAAI,CAAC;AAChE,MAAI,CAAE,MAAM,kBAAkB,KAAK,QAAQ,OAAO,KAAK,SAAS,EAAI,QAAO,IAAI,SAAS,4BAA4B,EAAE,QAAQ,IAAI,CAAC;AACnI,QAAM,SAAS,GAAG,YAAY,IAAI,mBAAmB,MAAM,UAAU,OAAO,KAAK,MAAM,CAAC,CAAC,kDAAkD,IAAI,aAAa,WAAW,aAAa,EAAE;AACtL,SAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,KAAK,SAAS,EAAE,UAAU,iBAAiB,IAAI,aAAa,IAAI,UAAU,KAAK,GAAG,GAAG,cAAc,OAAO,EAAE,CAAC;AACnJ;AAGO,SAAS,qBAAqB,SAA4B;AAC/D,QAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,SAAO,IAAI,SAAS,MAAM;AAAA,IACxB,QAAQ;AAAA,IACR,SAAS,EAAE,UAAU,iBAAiB,IAAI,aAAa,IAAI,UAAU,KAAK,GAAG,GAAG,cAAc,GAAG,YAAY,+CAA+C;AAAA,EAC9J,CAAC;AACH;","names":[]}
package/dist/draft.d.cts CHANGED
@@ -11,8 +11,22 @@ 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;
24
+ /**
25
+ * The content branch a preview token was minted on, or null for main. Read UNVERIFIED: an adapter
26
+ * only stores a token the backend accepted, and branch reads need the site's draft key regardless.
27
+ * Pass it as the client's `branch` in draft mode and the whole site previews from that branch.
28
+ */
29
+ declare function decodeTokenBranch(token: string | null | undefined): string | null;
16
30
  /** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */
17
31
  declare function decodeTokenSlug(token: string): string | null;
18
32
  /**
@@ -35,4 +49,4 @@ declare function draftEnableResponse(request: Request, opts: {
35
49
  /** Leave draft mode: clear the cookie and redirect (`?redirect=/x`, same-origin only). */
36
50
  declare function draftDisableResponse(request: Request): Response;
37
51
 
38
- export { DRAFT_COOKIE, 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,8 +11,22 @@ 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;
24
+ /**
25
+ * The content branch a preview token was minted on, or null for main. Read UNVERIFIED: an adapter
26
+ * only stores a token the backend accepted, and branch reads need the site's draft key regardless.
27
+ * Pass it as the client's `branch` in draft mode and the whole site previews from that branch.
28
+ */
29
+ declare function decodeTokenBranch(token: string | null | undefined): string | null;
16
30
  /** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */
17
31
  declare function decodeTokenSlug(token: string): string | null;
18
32
  /**
@@ -35,4 +49,4 @@ declare function draftEnableResponse(request: Request, opts: {
35
49
  /** Leave draft mode: clear the cookie and redirect (`?redirect=/x`, same-origin only). */
36
50
  declare function draftDisableResponse(request: Request): Response;
37
51
 
38
- export { DRAFT_COOKIE, 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,9 +23,26 @@ 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
  }
33
+ function tokenPayload(token) {
34
+ try {
35
+ const part = token.split(".")[1];
36
+ if (!part) return null;
37
+ const bytes = Uint8Array.from(atob(part.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
38
+ return JSON.parse(new TextDecoder().decode(bytes));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ function decodeTokenBranch(token) {
44
+ return token && tokenPayload(token)?.branch || null;
45
+ }
29
46
  function decodeTokenSlug(token) {
30
47
  try {
31
48
  const part = token.split(".")[1];
@@ -72,13 +89,16 @@ function draftDisableResponse(request) {
72
89
  });
73
90
  }
74
91
  export {
92
+ BRANCH_SIGNATURE_HEADER,
75
93
  DRAFT_COOKIE,
94
+ decodeTokenBranch,
76
95
  decodeTokenSlug,
77
96
  draftDisableResponse,
78
97
  draftEnableResponse,
79
98
  draftTokenFrom,
80
99
  previewTokenValid,
81
100
  safeRedirectPath,
101
+ signBranchRead,
82
102
  signValue,
83
103
  verifyValue
84
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\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":";AASO,IAAM,eAAe;AAE5B,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO,UAAU,OAAO,IAAI,OAAO,MAAM,GAAG,EAAE,MAAM,QAAQ,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;AACvH,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAGA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AACzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;AAGO,IAAM,0BAA0B;AAOvC,eAAsB,eAAe,QAAgB,QAAgB,MAAM,KAAK,IAAI,GAAoB;AACtG,SAAO,UAAU,GAAG,KAAK,MAAM,MAAM,GAAI,CAAC,IAAI,MAAM,IAAI,MAAM;AAChE;AAGO,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,KAAK,CAAC,GAAG,WAAW,KAAK,IAAI,KAAK;AACpF;AAEA,SAAS,aAAa,OAA+D;AACnF,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAElB,UAAM,QAAQ,WAAW,KAAK,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AACtG,WAAO,KAAK,MAAM,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC;AAAA,EACnD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOO,SAAS,kBAAkB,OAAiD;AACjF,SAAQ,SAAS,aAAa,KAAK,GAAG,UAAW;AACnD;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAElB,UAAM,QAAQ,WAAW,KAAK,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AACtG,UAAM,OAAO,KAAK,MAAM,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC;AACvD,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,eAAsB,kBAAkB,QAAgB,OAAe,YAA0B,OAAyB;AACxH,QAAM,OAAO,gBAAgB,KAAK;AAClC,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,MAAM,MAAM,UAAU,GAAG,OAAO,QAAQ,QAAQ,EAAE,CAAC,mBAAmB,mBAAmB,IAAI,CAAC,UAAU,mBAAmB,KAAK,CAAC,EAAE;AACzI,QAAM,IAAI,MAAM,OAAO,EAAE,MAAM,MAAM;AAAA,EAAC,CAAC;AACvC,SAAO,IAAI;AACb;AAGA,eAAsB,eAAe,cAAyC,QAAoD;AAChI,MAAI,CAAC,gBAAgB,CAAC,OAAQ,QAAO;AACrC,QAAM,IAAI,IAAI,OAAO,cAAc,YAAY,UAAU,EAAE,KAAK,YAAY;AAC5E,MAAI,CAAC,EAAG,QAAO;AACf,MAAI;AACF,WAAO,MAAM,YAAY,mBAAmB,EAAE,CAAC,CAAE,GAAG,MAAM;AAAA,EAC5D,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOA,eAAsB,oBAAoB,SAAkB,MAAmG;AAC7J,QAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,QAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,MAAI,CAAC,KAAK,OAAQ,QAAO,IAAI,SAAS,uCAAuC,EAAE,QAAQ,IAAI,CAAC;AAC5F,MAAI,CAAC,MAAO,QAAO,IAAI,SAAS,iBAAiB,EAAE,QAAQ,IAAI,CAAC;AAChE,MAAI,CAAE,MAAM,kBAAkB,KAAK,QAAQ,OAAO,KAAK,SAAS,EAAI,QAAO,IAAI,SAAS,4BAA4B,EAAE,QAAQ,IAAI,CAAC;AACnI,QAAM,SAAS,GAAG,YAAY,IAAI,mBAAmB,MAAM,UAAU,OAAO,KAAK,MAAM,CAAC,CAAC,kDAAkD,IAAI,aAAa,WAAW,aAAa,EAAE;AACtL,SAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,KAAK,SAAS,EAAE,UAAU,iBAAiB,IAAI,aAAa,IAAI,UAAU,KAAK,GAAG,GAAG,cAAc,OAAO,EAAE,CAAC;AACnJ;AAGO,SAAS,qBAAqB,SAA4B;AAC/D,QAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,SAAO,IAAI,SAAS,MAAM;AAAA,IACxB,QAAQ;AAAA,IACR,SAAS,EAAE,UAAU,iBAAiB,IAAI,aAAa,IAAI,UAAU,KAAK,GAAG,GAAG,cAAc,GAAG,YAAY,+CAA+C;AAAA,EAC9J,CAAC;AACH;","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) {
@@ -1354,11 +1358,13 @@ function createClient(options) {
1354
1358
  const apiUrl = options.apiUrl.replace(/\/+$/, "");
1355
1359
  const { workspace, apiKey, perspective = "published", previewToken } = options;
1356
1360
  const defaultProjectId = options.projectId;
1361
+ const branch = options.branch && options.branch !== "main" ? options.branch : void 0;
1357
1362
  const timeout = options.timeout ?? DEFAULT_TIMEOUT3;
1358
1363
  const base = `${apiUrl}/api/v1/delivery/${workspace}`;
1359
1364
  const headers = () => {
1360
1365
  const h = { "Content-Type": "application/json" };
1361
1366
  if (apiKey) h["X-API-Key"] = apiKey;
1367
+ if (branch && options.branchSignature) h[BRANCH_SIGNATURE_HEADER] = options.branchSignature;
1362
1368
  return h;
1363
1369
  };
1364
1370
  const collapse = options.cache && perspective === "published" ? createRequestCache() : void 0;
@@ -1413,7 +1419,8 @@ function createClient(options) {
1413
1419
  select: opts?.select?.join(","),
1414
1420
  perspective: draft ? "draft" : void 0,
1415
1421
  stega: draft && stega ? "1" : void 0,
1416
- projectId: defaultProjectId
1422
+ projectId: defaultProjectId,
1423
+ branch
1417
1424
  })}`;
1418
1425
  return unwrap(await fetchJSON(url));
1419
1426
  },
@@ -1426,7 +1433,8 @@ function createClient(options) {
1426
1433
  select: opts?.select?.join(","),
1427
1434
  perspective: draft ? "draft" : void 0,
1428
1435
  stega: draft && stega ? "1" : void 0,
1429
- projectId: defaultProjectId
1436
+ projectId: defaultProjectId,
1437
+ branch
1430
1438
  })}`;
1431
1439
  return unwrap(await fetchJSON(url));
1432
1440
  },
@@ -1463,7 +1471,8 @@ function createClient(options) {
1463
1471
  const url = `${base}/pages${qs({
1464
1472
  page: opts?.page,
1465
1473
  perPage: opts?.perPage,
1466
- projectId: defaultProjectId
1474
+ projectId: defaultProjectId,
1475
+ branch
1467
1476
  })}`;
1468
1477
  return unwrap(await fetchJSON(url));
1469
1478
  },
@@ -1471,12 +1480,13 @@ function createClient(options) {
1471
1480
  const url = `${base}/components${qs({
1472
1481
  perspective: draft ? "draft" : void 0,
1473
1482
  token: draft ? previewToken : void 0,
1474
- projectId: defaultProjectId
1483
+ projectId: defaultProjectId,
1484
+ branch
1475
1485
  })}`;
1476
1486
  return unwrap(await fetchJSON(url));
1477
1487
  },
1478
1488
  async getLayout() {
1479
- const url = `${base}/layout${qs({ perspective: draft ? "draft" : void 0, projectId: defaultProjectId })}`;
1489
+ const url = `${base}/layout${qs({ perspective: draft ? "draft" : void 0, projectId: defaultProjectId, branch })}`;
1480
1490
  try {
1481
1491
  return unwrap(await fetchJSON(url));
1482
1492
  } catch (error) {
@@ -1486,7 +1496,8 @@ function createClient(options) {
1486
1496
  },
1487
1497
  async listForms(opts) {
1488
1498
  const url = `${base}/forms${qs({
1489
- projectId: opts?.projectId ?? defaultProjectId
1499
+ projectId: opts?.projectId ?? defaultProjectId,
1500
+ branch
1490
1501
  })}`;
1491
1502
  return unwrap(
1492
1503
  await fetchJSON(url)