@decocms/blocks 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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ deleteResponseCookie,
4
+ getCookies,
5
+ setResponseCookie,
6
+ } from "./cookie";
7
+
8
+ describe("getCookies", () => {
9
+ it("returns an empty object when no Cookie header is present", () => {
10
+ expect(getCookies(new Headers())).toEqual({});
11
+ });
12
+
13
+ it("parses a single cookie", () => {
14
+ const h = new Headers({ cookie: "session=abc" });
15
+ expect(getCookies(h)).toEqual({ session: "abc" });
16
+ });
17
+
18
+ it("parses multiple cookies separated by '; '", () => {
19
+ const h = new Headers({ cookie: "a=1; b=2; c=3" });
20
+ expect(getCookies(h)).toEqual({ a: "1", b: "2", c: "3" });
21
+ });
22
+
23
+ it("URL-decodes values", () => {
24
+ const h = new Headers({ cookie: "u=hello%20world" });
25
+ expect(getCookies(h)).toEqual({ u: "hello world" });
26
+ });
27
+
28
+ it("falls back to the raw value when decoding fails", () => {
29
+ // Lone '%' is invalid in URL encoding.
30
+ const h = new Headers({ cookie: "x=100%" });
31
+ expect(getCookies(h)).toEqual({ x: "100%" });
32
+ });
33
+
34
+ it("ignores entries without an '='", () => {
35
+ const h = new Headers({ cookie: "garbage; ok=yes" });
36
+ expect(getCookies(h)).toEqual({ ok: "yes" });
37
+ });
38
+
39
+ it("trims whitespace around names", () => {
40
+ const h = new Headers({ cookie: " a=1; b=2 " });
41
+ expect(getCookies(h)).toEqual({ a: "1", b: "2" });
42
+ });
43
+ });
44
+
45
+ describe("setResponseCookie", () => {
46
+ it("appends a Set-Cookie header with the cookie name and value", () => {
47
+ const h = new Headers();
48
+ setResponseCookie(h, { name: "session", value: "abc" });
49
+ expect(h.get("set-cookie")).toBe("session=abc");
50
+ });
51
+
52
+ it("serializes maxAge, path, secure, httpOnly, sameSite, domain", () => {
53
+ const h = new Headers();
54
+ setResponseCookie(h, {
55
+ name: "session",
56
+ value: "abc",
57
+ maxAge: 3600,
58
+ path: "/",
59
+ domain: "example.com",
60
+ secure: true,
61
+ httpOnly: true,
62
+ sameSite: "Lax",
63
+ });
64
+ const value = h.get("set-cookie")!;
65
+ expect(value).toContain("session=abc");
66
+ expect(value).toContain("Max-Age=3600");
67
+ expect(value).toContain("Domain=example.com");
68
+ expect(value).toContain("Path=/");
69
+ expect(value).toContain("Secure");
70
+ expect(value).toContain("HttpOnly");
71
+ expect(value).toContain("SameSite=Lax");
72
+ });
73
+
74
+ it("serializes expires as a UTC string", () => {
75
+ const h = new Headers();
76
+ const date = new Date("2030-01-01T00:00:00Z");
77
+ setResponseCookie(h, { name: "x", value: "y", expires: date });
78
+ expect(h.get("set-cookie")).toContain(`Expires=${date.toUTCString()}`);
79
+ });
80
+
81
+ it("appends multiple cookies (does not overwrite the first)", () => {
82
+ const h = new Headers();
83
+ setResponseCookie(h, { name: "a", value: "1" });
84
+ setResponseCookie(h, { name: "b", value: "2" });
85
+ // Headers.getAll isn't standard; getSetCookie() is the modern API.
86
+ const all = (h as any).getSetCookie?.() as string[] | undefined;
87
+ if (all) {
88
+ expect(all).toEqual(["a=1", "b=2"]);
89
+ } else {
90
+ // Fallback: the combined header value should mention both.
91
+ const v = h.get("set-cookie")!;
92
+ expect(v).toContain("a=1");
93
+ expect(v).toContain("b=2");
94
+ }
95
+ });
96
+ });
97
+
98
+ describe("deleteResponseCookie", () => {
99
+ it("emits a Set-Cookie that expires immediately", () => {
100
+ const h = new Headers();
101
+ deleteResponseCookie(h, "session", { path: "/" });
102
+ const value = h.get("set-cookie")!;
103
+ expect(value).toContain("session=");
104
+ expect(value).toContain("Max-Age=0");
105
+ expect(value).toContain(`Expires=${new Date(0).toUTCString()}`);
106
+ expect(value).toContain("Path=/");
107
+ });
108
+ });
@@ -0,0 +1,129 @@
1
+ export function getCookie(name: string): string {
2
+ return (
3
+ globalThis.window?.document?.cookie?.split("; ").reduce((r, v) => {
4
+ const parts = v.split("=");
5
+ return parts[0] === name ? decodeURIComponent(parts[1]) : r;
6
+ }, "") ?? ""
7
+ );
8
+ }
9
+
10
+ export function setCookie(name: string, value: string, days: number) {
11
+ const expires = new Date(Date.now() + days * 864e5).toUTCString();
12
+ if (globalThis?.window?.document) {
13
+ globalThis.window.document.cookie =
14
+ name + "=" + encodeURIComponent(value) + "; expires=" + expires + "; path=/";
15
+ }
16
+ }
17
+
18
+ export function deleteCookie(name: string) {
19
+ if (globalThis?.window?.document) {
20
+ globalThis.window.document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
21
+ }
22
+ }
23
+
24
+ export function getServerSideCookie(req: Request, name: string): string {
25
+ const cookie = req.headers
26
+ .get("cookie")
27
+ ?.split(";")
28
+ .find((c) => c.trim().startsWith(name))
29
+ ?.split("=")[1];
30
+ return cookie ? decodeURIComponent(cookie) : "";
31
+ }
32
+
33
+ export function decodeCookie(cookieValue: string): any {
34
+ try {
35
+ return JSON.parse(decodeURIComponent(cookieValue));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Server-side cookie helpers — Web-platform / Workers-friendly.
43
+ //
44
+ // These mirror the surface area of Deno's `@std/http/cookie`, which deco
45
+ // storefronts depended on heavily before TanStack/Workers migration. Sites
46
+ // can now import from "@decocms/start/sdk/cookie" instead of shipping a
47
+ // per-site shim or pulling JSR.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface Cookie {
51
+ name: string;
52
+ value: string;
53
+ expires?: Date | number;
54
+ maxAge?: number;
55
+ domain?: string;
56
+ path?: string;
57
+ secure?: boolean;
58
+ httpOnly?: boolean;
59
+ sameSite?: "Strict" | "Lax" | "None";
60
+ }
61
+
62
+ /**
63
+ * Parse all cookies from a Request's `Cookie` header into a plain object.
64
+ * Returns `{}` when no cookies are present. Values are URL-decoded.
65
+ *
66
+ * Equivalent to `getCookies(req.headers)` from `@std/http/cookie`.
67
+ */
68
+ export function getCookies(headers: Headers): Record<string, string> {
69
+ const cookie = headers.get("cookie");
70
+ if (!cookie) return {};
71
+ const out: Record<string, string> = {};
72
+ for (const pair of cookie.split(/;\s*/)) {
73
+ const eq = pair.indexOf("=");
74
+ if (eq === -1) continue;
75
+ const name = pair.slice(0, eq).trim();
76
+ if (!name) continue;
77
+ const value = pair.slice(eq + 1).trim();
78
+ try {
79
+ out[name] = decodeURIComponent(value);
80
+ } catch {
81
+ out[name] = value;
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Serialize a cookie spec and append a `Set-Cookie` header to a Response's
89
+ * `Headers`. Equivalent to `setCookie(headers, cookie)` from `@std/http/cookie`.
90
+ *
91
+ * Note: this uses `headers.append`, not `set`, so multiple cookies stack
92
+ * correctly (a single `Set-Cookie` header cannot represent multiple cookies).
93
+ */
94
+ export function setResponseCookie(headers: Headers, cookie: Cookie): void {
95
+ const parts = [`${cookie.name}=${cookie.value}`];
96
+ if (cookie.expires !== undefined) {
97
+ const date = cookie.expires instanceof Date
98
+ ? cookie.expires
99
+ : new Date(cookie.expires);
100
+ parts.push(`Expires=${date.toUTCString()}`);
101
+ }
102
+ if (cookie.maxAge !== undefined) parts.push(`Max-Age=${cookie.maxAge}`);
103
+ if (cookie.domain) parts.push(`Domain=${cookie.domain}`);
104
+ if (cookie.path) parts.push(`Path=${cookie.path}`);
105
+ if (cookie.secure) parts.push("Secure");
106
+ if (cookie.httpOnly) parts.push("HttpOnly");
107
+ if (cookie.sameSite) parts.push(`SameSite=${cookie.sameSite}`);
108
+ headers.append("Set-Cookie", parts.join("; "));
109
+ }
110
+
111
+ /**
112
+ * Append a delete instruction (`Max-Age=0` + epoch `Expires`) for a cookie.
113
+ * `path` and `domain` should match the original `setResponseCookie` call to
114
+ * actually clear the cookie in the browser.
115
+ */
116
+ export function deleteResponseCookie(
117
+ headers: Headers,
118
+ name: string,
119
+ attributes: { path?: string; domain?: string } = {},
120
+ ): void {
121
+ setResponseCookie(headers, {
122
+ name,
123
+ value: "",
124
+ expires: new Date(0),
125
+ maxAge: 0,
126
+ path: attributes.path,
127
+ domain: attributes.domain,
128
+ });
129
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Secret decryption for CMS encrypted values.
3
+ *
4
+ * CMS blocks store sensitive values (API keys, tokens) encrypted with AES-CBC.
5
+ * The encryption key is stored in the DECO_CRYPTO_KEY environment variable
6
+ * as a base64-encoded JSON: { key: number[], iv: number[] }
7
+ *
8
+ * Usage:
9
+ * import { decryptSecret, resolveSecret } from "@decocms/start/sdk/crypto";
10
+ *
11
+ * // Decrypt a hex-encoded encrypted string
12
+ * const apiKey = await decryptSecret("888fafd937dd...");
13
+ *
14
+ * // Or resolve a CMS secret block (handles all formats)
15
+ * const apiKey = await resolveSecret(block.apiKey, "RESEND_API_KEY");
16
+ */
17
+
18
+ const textDecoder = new TextDecoder();
19
+ const textEncoder = new TextEncoder();
20
+
21
+ // Cache the imported key
22
+ let cachedKey: Promise<{ key: CryptoKey; iv: Uint8Array }> | null = null;
23
+
24
+ function hexToBytes(hex: string): Uint8Array {
25
+ const bytes = new Uint8Array(hex.length / 2);
26
+ for (let i = 0; i < hex.length; i += 2) {
27
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
28
+ }
29
+ return bytes;
30
+ }
31
+
32
+ /**
33
+ * Get the AES key from DECO_CRYPTO_KEY environment variable.
34
+ * Returns null if not set.
35
+ */
36
+ function getKeyFromEnv(): Promise<{ key: CryptoKey; iv: Uint8Array }> | null {
37
+ const envKey = getEnvVar("DECO_CRYPTO_KEY");
38
+ if (!envKey) return null;
39
+
40
+ return cachedKey ??= (async () => {
41
+ const parsed = JSON.parse(atob(envKey));
42
+ const keyBytes = new Uint8Array(
43
+ Array.isArray(parsed.key) ? parsed.key : Object.values(parsed.key),
44
+ );
45
+ const iv = new Uint8Array(
46
+ Array.isArray(parsed.iv) ? parsed.iv : Object.values(parsed.iv),
47
+ );
48
+
49
+ const importedKey = await crypto.subtle.importKey(
50
+ "raw",
51
+ keyBytes.buffer,
52
+ "AES-CBC",
53
+ false,
54
+ ["decrypt"],
55
+ );
56
+
57
+ return { key: importedKey, iv };
58
+ })();
59
+ }
60
+
61
+ /**
62
+ * Check if the crypto key is available.
63
+ */
64
+ export function hasCryptoKey(): boolean {
65
+ return !!getEnvVar("DECO_CRYPTO_KEY");
66
+ }
67
+
68
+ /**
69
+ * Decrypt a hex-encoded AES-CBC encrypted string.
70
+ * Requires DECO_CRYPTO_KEY environment variable.
71
+ *
72
+ * @returns The decrypted string, or null if decryption fails.
73
+ */
74
+ export async function decryptSecret(encryptedHex: string): Promise<string | null> {
75
+ const keyPromise = getKeyFromEnv();
76
+ if (!keyPromise) {
77
+ return null;
78
+ }
79
+
80
+ try {
81
+ const { key, iv } = await keyPromise;
82
+ const encryptedBytes = hexToBytes(encryptedHex);
83
+ const decrypted = await crypto.subtle.decrypt(
84
+ { name: "AES-CBC", iv } as AesCbcParams,
85
+ key,
86
+ encryptedBytes as unknown as BufferSource,
87
+ );
88
+ return textDecoder.decode(new Uint8Array(decrypted));
89
+ } catch (e) {
90
+ console.warn("[crypto] Failed to decrypt secret:", (e as Error).message);
91
+ return null;
92
+ }
93
+ }
94
+
95
+ // In-memory cache for resolved secrets
96
+ const secretCache = new Map<string, string | null>();
97
+
98
+ /**
99
+ * Resolve a CMS secret value from multiple sources.
100
+ *
101
+ * Resolution order:
102
+ * 1. Plain string → use directly
103
+ * 2. Object with .get() → call .get() (old Secret loader pattern)
104
+ * 3. Object with .encrypted → decrypt using DECO_CRYPTO_KEY
105
+ * 4. Environment variable (envVarName) → fallback
106
+ *
107
+ * @param value - The secret value from CMS block (string | { encrypted } | { get })
108
+ * @param envVarName - Optional env var name to use as fallback (e.g. "RESEND_API_KEY")
109
+ * @returns The resolved secret string, or null if not available
110
+ */
111
+ export async function resolveSecret(
112
+ value: unknown,
113
+ envVarName?: string,
114
+ ): Promise<string | null> {
115
+ // 1. Plain string
116
+ if (typeof value === "string" && value.length > 0) {
117
+ return value;
118
+ }
119
+
120
+ if (value && typeof value === "object") {
121
+ const obj = value as Record<string, any>;
122
+
123
+ // 2. Secret object with .get()
124
+ if (typeof obj.get === "function") {
125
+ const result = obj.get();
126
+ if (typeof result === "string" && result.length > 0) return result;
127
+ }
128
+
129
+ // 3. Encrypted secret
130
+ if (typeof obj.encrypted === "string" && obj.encrypted.length > 0) {
131
+ const cacheKey = obj.encrypted;
132
+ if (secretCache.has(cacheKey)) return secretCache.get(cacheKey)!;
133
+
134
+ const decrypted = await decryptSecret(obj.encrypted);
135
+ // Only cache successful decryptions — null would block env var fallback
136
+ if (decrypted) {
137
+ secretCache.set(cacheKey, decrypted);
138
+ return decrypted;
139
+ }
140
+ }
141
+ }
142
+
143
+ // 4. Environment variable fallback
144
+ if (envVarName) {
145
+ const envValue = getEnvVar(envVarName);
146
+ if (envValue) return envValue;
147
+ }
148
+
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Get an environment variable, checking process.env first, then .dev.vars file.
154
+ * Cloudflare Workers dev mode stores env vars in .dev.vars but they're only
155
+ * accessible via the `env` binding inside request handlers. During setup.ts
156
+ * (module-level init), we need to read the file directly.
157
+ */
158
+ function getEnvVar(name: string): string | undefined {
159
+ // 1. process.env (works in Node, may work in Workers with nodejs_compat)
160
+ if (typeof process !== "undefined" && process.env?.[name]) {
161
+ return process.env[name];
162
+ }
163
+
164
+ // 2. Read .dev.vars file (Cloudflare Workers dev mode)
165
+ try {
166
+ const fs = require("node:fs");
167
+ const path = require("node:path");
168
+ const devVarsPath = path.resolve(".dev.vars");
169
+ if (fs.existsSync(devVarsPath)) {
170
+ const content = fs.readFileSync(devVarsPath, "utf-8");
171
+ for (const line of content.split("\n")) {
172
+ const trimmed = line.trim();
173
+ if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
174
+ const eqIdx = trimmed.indexOf("=");
175
+ const key = trimmed.slice(0, eqIdx).trim();
176
+ const val = trimmed.slice(eqIdx + 1).trim();
177
+ if (key === name) return val;
178
+ }
179
+ }
180
+ } catch {
181
+ // fs not available (e.g., pure Worker runtime) — ignore
182
+ }
183
+
184
+ return undefined;
185
+ }
package/src/sdk/csp.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Content Security Policy header utilities.
3
+ *
4
+ * Sets frame-ancestors to allow the Deco admin to embed the
5
+ * storefront in an iframe for live preview.
6
+ */
7
+
8
+ const DEFAULT_ADMIN_ORIGINS = ["https://admin.deco.cx", "https://deco.cx", "https://localhost:*"];
9
+
10
+ export interface CSPOptions {
11
+ /** Additional origins allowed to frame the storefront. */
12
+ extraOrigins?: string[];
13
+ /**
14
+ * Deco admin origins. Defaults to admin.deco.cx + localhost.
15
+ * Set to empty array to disallow all external framing.
16
+ */
17
+ adminOrigins?: string[];
18
+ }
19
+
20
+ /**
21
+ * Set Content-Security-Policy frame-ancestors header on a Response.
22
+ *
23
+ * This is required for the Deco admin live preview iframe to work.
24
+ * Also removes X-Frame-Options if present (CSP supersedes it).
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * import { setCSPHeaders } from "@decocms/start/sdk/csp";
29
+ *
30
+ * // In middleware:
31
+ * const response = await next();
32
+ * setCSPHeaders(response);
33
+ * return response;
34
+ * ```
35
+ */
36
+ export function setCSPHeaders(response: Response, options?: CSPOptions): void {
37
+ const origins = [
38
+ "'self'",
39
+ ...(options?.adminOrigins ?? DEFAULT_ADMIN_ORIGINS),
40
+ ...(options?.extraOrigins ?? []),
41
+ ];
42
+
43
+ response.headers.set("Content-Security-Policy", `frame-ancestors ${origins.join(" ")}`);
44
+
45
+ response.headers.delete("X-Frame-Options");
46
+ }
47
+
48
+ /**
49
+ * Build the CSP header value string without applying it.
50
+ * Useful when constructing headers in route definitions.
51
+ */
52
+ export function buildCSPHeaderValue(options?: CSPOptions): string {
53
+ const origins = [
54
+ "'self'",
55
+ ...(options?.adminOrigins ?? DEFAULT_ADMIN_ORIGINS),
56
+ ...(options?.extraOrigins ?? []),
57
+ ];
58
+ return `frame-ancestors ${origins.join(" ")}`;
59
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * DJB2 hash — a fast, non-cryptographic hash function.
3
+ *
4
+ * Used for ETags, cache keys, and content fingerprinting throughout the framework.
5
+ * Produces consistent unsigned 32-bit integers.
6
+ */
7
+
8
+ /** Compute a DJB2 hash and return the raw unsigned 32-bit integer. */
9
+ export function djb2(str: string): number {
10
+ let hash = 5381;
11
+ for (let i = 0; i < str.length; i++) {
12
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
13
+ }
14
+ return hash >>> 0;
15
+ }
16
+
17
+ /** Compute a DJB2 hash and return a base-36 string. */
18
+ export function djb2Hex(str: string): string {
19
+ return djb2(str).toString(36);
20
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ decodeBase64,
4
+ decodeBase64Url,
5
+ encodeBase64,
6
+ encodeBase64Url,
7
+ } from "./encoding";
8
+
9
+ describe("encodeBase64 / decodeBase64", () => {
10
+ it("round-trips ASCII strings", () => {
11
+ const data = "hello, world";
12
+ const b64 = encodeBase64(data);
13
+ expect(b64).toBe("aGVsbG8sIHdvcmxk");
14
+ const decoded = new TextDecoder().decode(decodeBase64(b64));
15
+ expect(decoded).toBe(data);
16
+ });
17
+
18
+ it("round-trips multi-byte UTF-8", () => {
19
+ const data = "São Paulo — café ☕";
20
+ const b64 = encodeBase64(data);
21
+ const decoded = new TextDecoder().decode(decodeBase64(b64));
22
+ expect(decoded).toBe(data);
23
+ });
24
+
25
+ it("accepts Uint8Array input", () => {
26
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
27
+ const b64 = encodeBase64(bytes);
28
+ expect(decodeBase64(b64)).toEqual(bytes);
29
+ });
30
+
31
+ it("accepts ArrayBuffer input", () => {
32
+ const buf = new Uint8Array([255, 254, 253]).buffer;
33
+ const b64 = encodeBase64(buf);
34
+ expect(Array.from(decodeBase64(b64))).toEqual([255, 254, 253]);
35
+ });
36
+
37
+ it("handles inputs larger than the chunking window", () => {
38
+ // 0x8000 + 7 bytes — forces the chunking branch.
39
+ const bytes = new Uint8Array(0x8000 + 7);
40
+ for (let i = 0; i < bytes.length; i++) bytes[i] = i & 0xff;
41
+ const b64 = encodeBase64(bytes);
42
+ expect(decodeBase64(b64)).toEqual(bytes);
43
+ });
44
+ });
45
+
46
+ describe("encodeBase64Url / decodeBase64Url", () => {
47
+ it("uses URL-safe alphabet and strips padding", () => {
48
+ // Inputs that produce '+', '/', and padding under standard base64.
49
+ const bytes = new Uint8Array([251, 255, 191, 251, 239, 254]);
50
+ const standard = encodeBase64(bytes);
51
+ const url = encodeBase64Url(bytes);
52
+ // Every '+' becomes '-', '/' becomes '_', trailing '=' is stripped.
53
+ expect(url).toBe(
54
+ standard.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""),
55
+ );
56
+ expect(url).not.toContain("+");
57
+ expect(url).not.toContain("/");
58
+ expect(url).not.toContain("=");
59
+ });
60
+
61
+ it("round-trips through the URL-safe pair", () => {
62
+ const data = new Uint8Array(64);
63
+ for (let i = 0; i < data.length; i++) data[i] = (i * 7) & 0xff;
64
+ expect(decodeBase64Url(encodeBase64Url(data))).toEqual(data);
65
+ });
66
+
67
+ it("decodes inputs missing their padding", () => {
68
+ // 'a' = 0x61. encodeBase64('a') = 'YQ==', URL form drops to 'YQ'.
69
+ expect(new TextDecoder().decode(decodeBase64Url("YQ"))).toBe("a");
70
+ });
71
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Web-platform base64 / base64url helpers.
3
+ *
4
+ * Replaces the surface area of Deno's `@std/encoding/base64` so deco
5
+ * storefronts on TanStack/Workers can drop the per-site shim.
6
+ *
7
+ * All implementations use the global `btoa` / `atob` (available in Workers,
8
+ * browsers, and Node 16+) so there is zero runtime dependency.
9
+ */
10
+
11
+ function toBytes(data: ArrayBuffer | Uint8Array | string): Uint8Array {
12
+ if (typeof data === "string") return new TextEncoder().encode(data);
13
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
14
+ return data;
15
+ }
16
+
17
+ export function encodeBase64(data: ArrayBuffer | Uint8Array | string): string {
18
+ const bytes = toBytes(data);
19
+ // Build the binary string in chunks to avoid blowing the call stack on
20
+ // large inputs (`String.fromCharCode(...bytes)` spreads the entire array).
21
+ let bin = "";
22
+ const CHUNK = 0x8000;
23
+ for (let i = 0; i < bytes.byteLength; i += CHUNK) {
24
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
25
+ }
26
+ return btoa(bin);
27
+ }
28
+
29
+ export function decodeBase64(b64: string): Uint8Array {
30
+ const bin = atob(b64);
31
+ const out = new Uint8Array(bin.length);
32
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
33
+ return out;
34
+ }
35
+
36
+ export function encodeBase64Url(data: ArrayBuffer | Uint8Array | string): string {
37
+ return encodeBase64(data)
38
+ .replace(/\+/g, "-")
39
+ .replace(/\//g, "_")
40
+ .replace(/=+$/, "");
41
+ }
42
+
43
+ export function decodeBase64Url(b64url: string): Uint8Array {
44
+ const padded = b64url.replace(/-/g, "+").replace(/_/g, "/");
45
+ const padLen = (4 - (padded.length % 4)) % 4;
46
+ return decodeBase64(padded + "=".repeat(padLen));
47
+ }
package/src/sdk/env.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Centralized environment detection for @decocms/start.
3
+ *
4
+ * Works in Cloudflare Workers (wrangler dev), Node, and Vite SSR.
5
+ * Evaluates lazily on first call so it picks up env vars set after module load.
6
+ */
7
+
8
+ let _isDev: boolean | null = null;
9
+
10
+ /**
11
+ * Returns `true` when running in a development environment.
12
+ *
13
+ * Detection order:
14
+ * 1. `import.meta.env.DEV` — Vite build-time constant (reliable in Workers/Miniflare)
15
+ * 2. `NODE_ENV=development` — standard Node/Vite convention
16
+ *
17
+ * The result is memoised after the first evaluation.
18
+ */
19
+ export function isDevMode(): boolean {
20
+ if (_isDev !== null) return _isDev;
21
+
22
+ const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
23
+
24
+ // Vite statically replaces import.meta.env.DEV at build time (true in dev, false in prod).
25
+ // In Miniflare/Workers, process.env is unavailable, so this is the reliable signal.
26
+ const vitaDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV;
27
+
28
+ _isDev = vitaDev || env?.NODE_ENV === "development" || env?.DECO_PREVIEW === "true";
29
+
30
+ return _isDev;
31
+ }