@stack-mode/site-manager-kit 1.44.0 → 1.45.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 (61) hide show
  1. package/README.md +618 -606
  2. package/audit/ts-hooks.mjs +68 -68
  3. package/audit/wiring-audit.d.mts +37 -37
  4. package/audit/wiring-audit.mjs +604 -604
  5. package/bin/site-manager-kit.mjs +10 -10
  6. package/package.json +73 -72
  7. package/src/announcements.ts +13 -13
  8. package/src/attribution-fields.ts +52 -52
  9. package/src/attribution.tsx +47 -47
  10. package/src/blog.ts +122 -122
  11. package/src/chat-route.ts +136 -136
  12. package/src/chat-types.ts +76 -76
  13. package/src/client.ts +116 -116
  14. package/src/config.ts +78 -78
  15. package/src/copy-keys-route.ts +21 -21
  16. package/src/copy-keys.ts +77 -77
  17. package/src/data.ts +189 -189
  18. package/src/downloads.ts +7 -7
  19. package/src/faq.ts +21 -21
  20. package/src/floor-plans.ts +45 -45
  21. package/src/form-guard.tsx +37 -37
  22. package/src/form-types.ts +13 -13
  23. package/src/forms.ts +76 -76
  24. package/src/glossary.ts +19 -19
  25. package/src/health-route.ts +34 -34
  26. package/src/index.ts +36 -36
  27. package/src/indexnow.ts +40 -40
  28. package/src/listings.ts +38 -38
  29. package/src/llms-route.ts +45 -0
  30. package/src/metadata.ts +55 -55
  31. package/src/not-found-reporter.tsx +16 -16
  32. package/src/not-found.ts +53 -53
  33. package/src/payment.ts +70 -70
  34. package/src/phone-input.tsx +26 -26
  35. package/src/phone.ts +37 -37
  36. package/src/preview-route.ts +48 -48
  37. package/src/preview.ts +94 -94
  38. package/src/residents.ts +23 -23
  39. package/src/revalidate-route.ts +34 -34
  40. package/src/robots.ts +5 -1
  41. package/src/rss-route.ts +26 -26
  42. package/src/services.ts +52 -52
  43. package/src/site-chat-panel.tsx +537 -537
  44. package/src/site-chat.tsx +26 -26
  45. package/src/sitemap.ts +26 -26
  46. package/src/structured-data.tsx +207 -207
  47. package/src/types.ts +581 -580
  48. package/src/version.ts +1 -1
  49. package/src/visual-accents.ts +126 -126
  50. package/src/visual-bridge-client.tsx +1070 -1070
  51. package/src/visual-bridge.tsx +30 -30
  52. package/src/visual-clicks.ts +13 -13
  53. package/src/visual-lists.ts +37 -37
  54. package/src/visual-match.ts +77 -77
  55. package/src/visual-messages.ts +160 -160
  56. package/src/visual-mode.ts +18 -18
  57. package/src/visual-preview.ts +69 -69
  58. package/src/visual-tags.ts +90 -90
  59. package/src/visual.ts +59 -59
  60. package/src/waitlist-form.ts +39 -39
  61. package/src/waitlist.ts +48 -48
package/src/chat-types.ts CHANGED
@@ -1,76 +1,76 @@
1
- // Website Chat shapes and the reader for its streamed replies. Plain code, safe in the browser and on the server.
2
-
3
- /** GET /api/v1/chat. `live` false: the site shows no chat. */
4
- export type ChatConfig = {
5
- live: boolean;
6
- name: string;
7
- greeting: string;
8
- suggestedQuestions: string[];
9
- /** The chat can offer open times and book one. */
10
- canBook: boolean;
11
- /** The site's accent color from Site Manager, or null. The component's own `accent` prop wins. */
12
- accent: string | null;
13
- retentionDays: number;
14
- };
15
-
16
- /** One line of a streamed reply. */
17
- export type ChatEvent =
18
- | { type: "start"; conversationId: string }
19
- | { type: "delta"; text: string }
20
- | { type: "done"; unanswered: boolean; handoff: boolean; booking: boolean }
21
- | { type: "error"; code: string };
22
-
23
- export type ChatSlotDay = { date: string; slots: { start: string; end: string; label: string }[] };
24
- export type ChatSlots = { timezone: string; slotMinutes: number; days: ChatSlotDay[] };
25
-
26
- export type ChatLeadResult = { ok: true; successMessage: string } | { ok: false; error: "invalid" | "slot_taken" | "rate_limited" | "unavailable"; fields?: Record<string, string> };
27
-
28
- /** Splits streamed NDJSON into events, keeping a partial last line for the next chunk. Unknown lines are skipped. */
29
- export function parseChatLines(buffer: string): { events: ChatEvent[]; rest: string } {
30
- const events: ChatEvent[] = [];
31
- const lines = buffer.split("\n");
32
- const rest = lines.pop() ?? "";
33
- for (const line of lines) {
34
- if (!line.trim()) continue;
35
- try {
36
- const e = JSON.parse(line) as ChatEvent;
37
- if (e && typeof e === "object" && typeof e.type === "string") events.push(e);
38
- } catch {
39
- // A broken line: skip it rather than lose the rest of the reply.
40
- }
41
- }
42
- return { events, rest };
43
- }
44
-
45
- /** Reads a streamed reply, calling `onEvent` for each event as it arrives. */
46
- export async function readChatStream(body: ReadableStream<Uint8Array>, onEvent: (e: ChatEvent) => void): Promise<void> {
47
- const reader = body.getReader();
48
- const decoder = new TextDecoder();
49
- let buf = "";
50
- for (;;) {
51
- const { done, value } = await reader.read();
52
- if (done) break;
53
- buf += decoder.decode(value, { stream: true });
54
- const { events, rest } = parseChatLines(buf);
55
- buf = rest;
56
- for (const e of events) onEvent(e);
57
- }
58
- for (const e of parseChatLines(`${buf}\n`).events) onEvent(e);
59
- }
60
-
61
- /**
62
- * Text color on the accent: near-black on a light accent (a lime or yellow), white on a dark one, so the button and
63
- * the visitor's messages stay readable whatever the site's color. Colors it can't read get white.
64
- */
65
- export function textOn(color: string): string {
66
- const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
67
- if (!m) return "#ffffff";
68
- const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
69
- const [r, g, b] = [0, 2, 4].map((i) => {
70
- const c = parseInt(hex.slice(i, i + 2), 16) / 255;
71
- return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
72
- });
73
- const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
74
- // White wins below this luminance (the point where both have equal contrast).
75
- return lum > 0.179 ? "#111318" : "#ffffff";
76
- }
1
+ // Website Chat shapes and the reader for its streamed replies. Plain code, safe in the browser and on the server.
2
+
3
+ /** GET /api/v1/chat. `live` false: the site shows no chat. */
4
+ export type ChatConfig = {
5
+ live: boolean;
6
+ name: string;
7
+ greeting: string;
8
+ suggestedQuestions: string[];
9
+ /** The chat can offer open times and book one. */
10
+ canBook: boolean;
11
+ /** The site's accent color from Site Manager, or null. The component's own `accent` prop wins. */
12
+ accent: string | null;
13
+ retentionDays: number;
14
+ };
15
+
16
+ /** One line of a streamed reply. */
17
+ export type ChatEvent =
18
+ | { type: "start"; conversationId: string }
19
+ | { type: "delta"; text: string }
20
+ | { type: "done"; unanswered: boolean; handoff: boolean; booking: boolean }
21
+ | { type: "error"; code: string };
22
+
23
+ export type ChatSlotDay = { date: string; slots: { start: string; end: string; label: string }[] };
24
+ export type ChatSlots = { timezone: string; slotMinutes: number; days: ChatSlotDay[] };
25
+
26
+ export type ChatLeadResult = { ok: true; successMessage: string } | { ok: false; error: "invalid" | "slot_taken" | "rate_limited" | "unavailable"; fields?: Record<string, string> };
27
+
28
+ /** Splits streamed NDJSON into events, keeping a partial last line for the next chunk. Unknown lines are skipped. */
29
+ export function parseChatLines(buffer: string): { events: ChatEvent[]; rest: string } {
30
+ const events: ChatEvent[] = [];
31
+ const lines = buffer.split("\n");
32
+ const rest = lines.pop() ?? "";
33
+ for (const line of lines) {
34
+ if (!line.trim()) continue;
35
+ try {
36
+ const e = JSON.parse(line) as ChatEvent;
37
+ if (e && typeof e === "object" && typeof e.type === "string") events.push(e);
38
+ } catch {
39
+ // A broken line: skip it rather than lose the rest of the reply.
40
+ }
41
+ }
42
+ return { events, rest };
43
+ }
44
+
45
+ /** Reads a streamed reply, calling `onEvent` for each event as it arrives. */
46
+ export async function readChatStream(body: ReadableStream<Uint8Array>, onEvent: (e: ChatEvent) => void): Promise<void> {
47
+ const reader = body.getReader();
48
+ const decoder = new TextDecoder();
49
+ let buf = "";
50
+ for (;;) {
51
+ const { done, value } = await reader.read();
52
+ if (done) break;
53
+ buf += decoder.decode(value, { stream: true });
54
+ const { events, rest } = parseChatLines(buf);
55
+ buf = rest;
56
+ for (const e of events) onEvent(e);
57
+ }
58
+ for (const e of parseChatLines(`${buf}\n`).events) onEvent(e);
59
+ }
60
+
61
+ /**
62
+ * Text color on the accent: near-black on a light accent (a lime or yellow), white on a dark one, so the button and
63
+ * the visitor's messages stay readable whatever the site's color. Colors it can't read get white.
64
+ */
65
+ export function textOn(color: string): string {
66
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
67
+ if (!m) return "#ffffff";
68
+ const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
69
+ const [r, g, b] = [0, 2, 4].map((i) => {
70
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
71
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
72
+ });
73
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
74
+ // White wins below this luminance (the point where both have equal contrast).
75
+ return lum > 0.179 ? "#111318" : "#ffffff";
76
+ }
package/src/client.ts CHANGED
@@ -1,116 +1,116 @@
1
- /*
2
- * The one place requests to Site Manager are made. Server only: the API key must never reach a browser.
3
- *
4
- * Env (server only, never NEXT_PUBLIC_):
5
- * BLUEPRINT_URL Site Manager's address, for example https://manager.example.com
6
- * BLUEPRINT_API_KEY this site's key (bp_...)
7
- * BLUEPRINT_SITE_ID this site's id, shown on its Connection tab in Site Manager
8
- * BLUEPRINT_REVALIDATE_SECRET the website connection secret, also used to sign preview reads
9
- */
10
-
11
- import { unstable_cache } from "next/cache";
12
- import { draftMode } from "next/headers";
13
- import { PREVIEW_HEADER, signPreviewHeader } from "./preview";
14
-
15
- export class BlueprintError extends Error {
16
- constructor(
17
- message: string,
18
- public status: number,
19
- public code?: string,
20
- ) {
21
- super(message);
22
- this.name = "BlueprintError";
23
- }
24
- }
25
-
26
- function env(name: string): string {
27
- const v = process.env[name]?.trim();
28
- if (!v) throw new BlueprintError(`${name} is not set`, 0, "config");
29
- return v;
30
- }
31
-
32
- export function siteId(): string {
33
- return env("BLUEPRINT_SITE_ID");
34
- }
35
-
36
- /** Cache tags match what Site Manager sends when content changes: bp:<siteId> and bp:<siteId>:<module>. */
37
- export function tagsFor(...modules: string[]): string[] {
38
- const id = siteId();
39
- return [`bp:${id}`, ...modules.map((m) => `bp:${id}:${m}`)];
40
- }
41
-
42
- type Options = { tags?: string[]; method?: "GET" | "POST"; body?: unknown; cache?: "no-store" };
43
-
44
- type Envelope<T> = { data: T; meta: Record<string, unknown> };
45
-
46
- async function request<T>(base: string, path: string, init: RequestInit, whole = false): Promise<T | null> {
47
- const res = await fetch(`${base}/api/v1${path}`, init);
48
- if (res.status === 404) return null;
49
- if (res.status === 204) return null;
50
- const json = (await res.json().catch(() => null)) as { data?: unknown; meta?: Record<string, unknown>; error?: { code?: string } } | null;
51
- if (!res.ok) throw new BlueprintError(`Site Manager answered ${res.status} for ${path}`, res.status, json?.error?.code);
52
- if (whole) return (json && json.data !== undefined ? { data: json.data, meta: json.meta ?? {} } : null) as T | null;
53
- return (json?.data ?? null) as T | null;
54
- }
55
-
56
- /**
57
- * Fetches `/api/v1<path>`. Reads are cached for an hour and refreshed early by tag when Site Manager says content
58
- * changed. Returns null for a 404, throws BlueprintError for anything else that isn't OK (errors aren't cached).
59
- *
60
- * Reads use unstable_cache rather than fetch's own cache options because it behaves the same with Cache Components
61
- * on or off, and like the fetch cache it lasts across deployments and servers.
62
- *
63
- * In a preview opened from Site Manager (draft mode), reads skip the cache and include drafts and scheduled changes.
64
- */
65
- export async function bp<T>(path: string, opts: Options = {}): Promise<T | null> {
66
- return read<T>(path, opts, false);
67
- }
68
-
69
- /** Like bp, but keeps the response's `meta` too (paging counts), as `{ data, meta }`. */
70
- export async function bpWithMeta<T>(path: string, opts: Options = {}): Promise<Envelope<T> | null> {
71
- return read<Envelope<T>>(path, opts, true);
72
- }
73
-
74
- async function read<T>(path: string, opts: Options, whole: boolean): Promise<T | null> {
75
- const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
76
- const method = opts.method ?? "GET";
77
- const preview = method === "GET" && (await inPreview());
78
- const headers: Record<string, string> = { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}` };
79
- if (opts.body !== undefined) headers["content-type"] = "application/json";
80
- if (preview) headers[PREVIEW_HEADER] = signPreviewHeader(env("BLUEPRINT_REVALIDATE_SECRET"));
81
- const init: RequestInit = {
82
- method,
83
- cache: "no-store",
84
- headers,
85
- ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
86
- };
87
- if (preview || opts.cache === "no-store" || method === "POST") return request<T>(base, path, init, whole);
88
- return unstable_cache(() => request<T>(base, path, init, whole), ["site-manager-kit", base, siteId(), path, ...(whole ? ["meta"] : [])], {
89
- tags: opts.tags ?? tagsFor(),
90
- revalidate: 3600,
91
- })();
92
- }
93
-
94
- /**
95
- * Whether this request is a preview opened from Site Manager (Next.js draft mode). Readable inside `use cache`
96
- * scopes too. Outside a request (build time, generateStaticParams) there is no preview.
97
- */
98
- async function inPreview(): Promise<boolean> {
99
- try {
100
- return (await draftMode()).isEnabled;
101
- } catch {
102
- return false;
103
- }
104
- }
105
-
106
- /** For writes that return an error body worth reading (form submits). */
107
- export async function bpRaw(path: string, body: unknown): Promise<{ status: number; json: unknown }> {
108
- const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
109
- const res = await fetch(`${base}/api/v1${path}`, {
110
- method: "POST",
111
- cache: "no-store",
112
- headers: { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}`, "content-type": "application/json" },
113
- body: JSON.stringify(body),
114
- });
115
- return { status: res.status, json: await res.json().catch(() => null) };
116
- }
1
+ /*
2
+ * The one place requests to Site Manager are made. Server only: the API key must never reach a browser.
3
+ *
4
+ * Env (server only, never NEXT_PUBLIC_):
5
+ * BLUEPRINT_URL Site Manager's address, for example https://manager.example.com
6
+ * BLUEPRINT_API_KEY this site's key (bp_...)
7
+ * BLUEPRINT_SITE_ID this site's id, shown on its Connection tab in Site Manager
8
+ * BLUEPRINT_REVALIDATE_SECRET the website connection secret, also used to sign preview reads
9
+ */
10
+
11
+ import { unstable_cache } from "next/cache";
12
+ import { draftMode } from "next/headers";
13
+ import { PREVIEW_HEADER, signPreviewHeader } from "./preview";
14
+
15
+ export class BlueprintError extends Error {
16
+ constructor(
17
+ message: string,
18
+ public status: number,
19
+ public code?: string,
20
+ ) {
21
+ super(message);
22
+ this.name = "BlueprintError";
23
+ }
24
+ }
25
+
26
+ function env(name: string): string {
27
+ const v = process.env[name]?.trim();
28
+ if (!v) throw new BlueprintError(`${name} is not set`, 0, "config");
29
+ return v;
30
+ }
31
+
32
+ export function siteId(): string {
33
+ return env("BLUEPRINT_SITE_ID");
34
+ }
35
+
36
+ /** Cache tags match what Site Manager sends when content changes: bp:<siteId> and bp:<siteId>:<module>. */
37
+ export function tagsFor(...modules: string[]): string[] {
38
+ const id = siteId();
39
+ return [`bp:${id}`, ...modules.map((m) => `bp:${id}:${m}`)];
40
+ }
41
+
42
+ type Options = { tags?: string[]; method?: "GET" | "POST"; body?: unknown; cache?: "no-store" };
43
+
44
+ type Envelope<T> = { data: T; meta: Record<string, unknown> };
45
+
46
+ async function request<T>(base: string, path: string, init: RequestInit, whole = false): Promise<T | null> {
47
+ const res = await fetch(`${base}/api/v1${path}`, init);
48
+ if (res.status === 404) return null;
49
+ if (res.status === 204) return null;
50
+ const json = (await res.json().catch(() => null)) as { data?: unknown; meta?: Record<string, unknown>; error?: { code?: string } } | null;
51
+ if (!res.ok) throw new BlueprintError(`Site Manager answered ${res.status} for ${path}`, res.status, json?.error?.code);
52
+ if (whole) return (json && json.data !== undefined ? { data: json.data, meta: json.meta ?? {} } : null) as T | null;
53
+ return (json?.data ?? null) as T | null;
54
+ }
55
+
56
+ /**
57
+ * Fetches `/api/v1<path>`. Reads are cached for an hour and refreshed early by tag when Site Manager says content
58
+ * changed. Returns null for a 404, throws BlueprintError for anything else that isn't OK (errors aren't cached).
59
+ *
60
+ * Reads use unstable_cache rather than fetch's own cache options because it behaves the same with Cache Components
61
+ * on or off, and like the fetch cache it lasts across deployments and servers.
62
+ *
63
+ * In a preview opened from Site Manager (draft mode), reads skip the cache and include drafts and scheduled changes.
64
+ */
65
+ export async function bp<T>(path: string, opts: Options = {}): Promise<T | null> {
66
+ return read<T>(path, opts, false);
67
+ }
68
+
69
+ /** Like bp, but keeps the response's `meta` too (paging counts), as `{ data, meta }`. */
70
+ export async function bpWithMeta<T>(path: string, opts: Options = {}): Promise<Envelope<T> | null> {
71
+ return read<Envelope<T>>(path, opts, true);
72
+ }
73
+
74
+ async function read<T>(path: string, opts: Options, whole: boolean): Promise<T | null> {
75
+ const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
76
+ const method = opts.method ?? "GET";
77
+ const preview = method === "GET" && (await inPreview());
78
+ const headers: Record<string, string> = { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}` };
79
+ if (opts.body !== undefined) headers["content-type"] = "application/json";
80
+ if (preview) headers[PREVIEW_HEADER] = signPreviewHeader(env("BLUEPRINT_REVALIDATE_SECRET"));
81
+ const init: RequestInit = {
82
+ method,
83
+ cache: "no-store",
84
+ headers,
85
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
86
+ };
87
+ if (preview || opts.cache === "no-store" || method === "POST") return request<T>(base, path, init, whole);
88
+ return unstable_cache(() => request<T>(base, path, init, whole), ["site-manager-kit", base, siteId(), path, ...(whole ? ["meta"] : [])], {
89
+ tags: opts.tags ?? tagsFor(),
90
+ revalidate: 3600,
91
+ })();
92
+ }
93
+
94
+ /**
95
+ * Whether this request is a preview opened from Site Manager (Next.js draft mode). Readable inside `use cache`
96
+ * scopes too. Outside a request (build time, generateStaticParams) there is no preview.
97
+ */
98
+ async function inPreview(): Promise<boolean> {
99
+ try {
100
+ return (await draftMode()).isEnabled;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** For writes that return an error body worth reading (form submits). */
107
+ export async function bpRaw(path: string, body: unknown): Promise<{ status: number; json: unknown }> {
108
+ const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
109
+ const res = await fetch(`${base}/api/v1${path}`, {
110
+ method: "POST",
111
+ cache: "no-store",
112
+ headers: { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}`, "content-type": "application/json" },
113
+ body: JSON.stringify(body),
114
+ });
115
+ return { status: res.status, json: await res.json().catch(() => null) };
116
+ }
package/src/config.ts CHANGED
@@ -1,78 +1,78 @@
1
- import type { NextConfig } from "next";
2
- import { VISUAL_COOKIE } from "./preview";
3
-
4
- // For next.config.ts. Use it inside withBotId: withBotId(withSiteManager({ ... })).
5
-
6
- type Header = { key: string; value: string };
7
- type HeaderRule = Awaited<ReturnType<NonNullable<NextConfig["headers"]>>>[number];
8
-
9
- export type SiteManagerConfigOptions = {
10
- /** Browser features to switch off for every page. Default: camera, microphone and USB. Payment and location stay on for checkout and maps. */
11
- disableFeatures?: string[];
12
- /** Send HSTS for subdomains too. Only when every subdomain serves HTTPS. Default true. */
13
- hstsSubdomains?: boolean;
14
- /** Who may show this site in a frame: "SAMEORIGIN" (default) or "DENY". */
15
- frameOptions?: "SAMEORIGIN" | "DENY";
16
- };
17
-
18
- /** The baseline security headers, so every website connected to Site Manager sends the same ones. */
19
- export function securityHeaders(opts: SiteManagerConfigOptions = {}): Header[] {
20
- const features = opts.disableFeatures ?? ["camera", "microphone", "usb"];
21
- return [
22
- { key: "X-Content-Type-Options", value: "nosniff" },
23
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
24
- { key: "X-Frame-Options", value: opts.frameOptions ?? "SAMEORIGIN" },
25
- { key: "Permissions-Policy", value: features.map((f) => `${f}=()`).join(", ") },
26
- { key: "Strict-Transport-Security", value: `max-age=63072000${opts.hstsSubdomains === false ? "" : "; includeSubDomains"}` },
27
- ];
28
- }
29
-
30
- /** Where Site Manager's uploaded media lives, so next/image can resize it. */
31
- export const MEDIA_REMOTE_PATTERN = { protocol: "https", hostname: "*.public.blob.vercel-storage.com" } as const;
32
-
33
- const FRAME_ANCESTORS = /^frame-ancestors(\s|$)/i;
34
-
35
- /**
36
- * Lets the Visual Editor show the site when the site sets its own Content-Security-Policy here, where there is no
37
- * request to read the editor's address from. A browser that opened the page from the Visual Editor (it has the visual
38
- * cookie) gets the same policy without its frame-ancestors; visualFrameHeaders in the proxy then adds frame-ancestors
39
- * for the signed Site Manager address only. X-Frame-Options stays on every response, so a cookie the proxy doesn't
40
- * accept, or a path the proxy doesn't run on, still can't be framed: browsers ignore X-Frame-Options only when a
41
- * frame-ancestors is present.
42
- */
43
- export function allowVisualFrames(rules: HeaderRule[]): HeaderRule[] {
44
- const cookie = { type: "cookie" as const, key: VISUAL_COOKIE };
45
- return rules.flatMap((rule) => {
46
- const csp = rule.headers.find(
47
- (h) => h.key.toLowerCase() === "content-security-policy" && h.value.split(";").some((d) => FRAME_ANCESTORS.test(d.trim())),
48
- );
49
- if (!csp) return [rule];
50
- const policy = csp.value
51
- .split(";")
52
- .map((d) => d.trim())
53
- .filter((d) => d && !FRAME_ANCESTORS.test(d))
54
- .join("; ");
55
- return [
56
- { ...rule, missing: [...(rule.missing ?? []), cookie] },
57
- { ...rule, has: [...(rule.has ?? []), cookie], headers: rule.headers.map((h) => (h === csp ? { key: h.key, value: policy } : h)) },
58
- ];
59
- });
60
- }
61
-
62
- /**
63
- * Adds the baseline security headers to every route, lets next/image resize Site Manager's media, and stops sending
64
- * X-Powered-By. Keeps everything already in the config: its own headers come after these, so a site can override one.
65
- * A Content-Security-Policy of its own keeps working with the Visual Editor (allowVisualFrames).
66
- */
67
- export function withSiteManager(config: NextConfig = {}, opts: SiteManagerConfigOptions = {}): NextConfig {
68
- const own = config.headers;
69
- const patterns = config.images?.remotePatterns ?? [];
70
- return {
71
- ...config,
72
- poweredByHeader: false,
73
- images: { ...config.images, remotePatterns: [...patterns, MEDIA_REMOTE_PATTERN] },
74
- async headers() {
75
- return allowVisualFrames([{ source: "/:path*", headers: securityHeaders(opts) }, ...(own ? await own() : [])]);
76
- },
77
- };
78
- }
1
+ import type { NextConfig } from "next";
2
+ import { VISUAL_COOKIE } from "./preview";
3
+
4
+ // For next.config.ts. Use it inside withBotId: withBotId(withSiteManager({ ... })).
5
+
6
+ type Header = { key: string; value: string };
7
+ type HeaderRule = Awaited<ReturnType<NonNullable<NextConfig["headers"]>>>[number];
8
+
9
+ export type SiteManagerConfigOptions = {
10
+ /** Browser features to switch off for every page. Default: camera, microphone and USB. Payment and location stay on for checkout and maps. */
11
+ disableFeatures?: string[];
12
+ /** Send HSTS for subdomains too. Only when every subdomain serves HTTPS. Default true. */
13
+ hstsSubdomains?: boolean;
14
+ /** Who may show this site in a frame: "SAMEORIGIN" (default) or "DENY". */
15
+ frameOptions?: "SAMEORIGIN" | "DENY";
16
+ };
17
+
18
+ /** The baseline security headers, so every website connected to Site Manager sends the same ones. */
19
+ export function securityHeaders(opts: SiteManagerConfigOptions = {}): Header[] {
20
+ const features = opts.disableFeatures ?? ["camera", "microphone", "usb"];
21
+ return [
22
+ { key: "X-Content-Type-Options", value: "nosniff" },
23
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
24
+ { key: "X-Frame-Options", value: opts.frameOptions ?? "SAMEORIGIN" },
25
+ { key: "Permissions-Policy", value: features.map((f) => `${f}=()`).join(", ") },
26
+ { key: "Strict-Transport-Security", value: `max-age=63072000${opts.hstsSubdomains === false ? "" : "; includeSubDomains"}` },
27
+ ];
28
+ }
29
+
30
+ /** Where Site Manager's uploaded media lives, so next/image can resize it. */
31
+ export const MEDIA_REMOTE_PATTERN = { protocol: "https", hostname: "*.public.blob.vercel-storage.com" } as const;
32
+
33
+ const FRAME_ANCESTORS = /^frame-ancestors(\s|$)/i;
34
+
35
+ /**
36
+ * Lets the Visual Editor show the site when the site sets its own Content-Security-Policy here, where there is no
37
+ * request to read the editor's address from. A browser that opened the page from the Visual Editor (it has the visual
38
+ * cookie) gets the same policy without its frame-ancestors; visualFrameHeaders in the proxy then adds frame-ancestors
39
+ * for the signed Site Manager address only. X-Frame-Options stays on every response, so a cookie the proxy doesn't
40
+ * accept, or a path the proxy doesn't run on, still can't be framed: browsers ignore X-Frame-Options only when a
41
+ * frame-ancestors is present.
42
+ */
43
+ export function allowVisualFrames(rules: HeaderRule[]): HeaderRule[] {
44
+ const cookie = { type: "cookie" as const, key: VISUAL_COOKIE };
45
+ return rules.flatMap((rule) => {
46
+ const csp = rule.headers.find(
47
+ (h) => h.key.toLowerCase() === "content-security-policy" && h.value.split(";").some((d) => FRAME_ANCESTORS.test(d.trim())),
48
+ );
49
+ if (!csp) return [rule];
50
+ const policy = csp.value
51
+ .split(";")
52
+ .map((d) => d.trim())
53
+ .filter((d) => d && !FRAME_ANCESTORS.test(d))
54
+ .join("; ");
55
+ return [
56
+ { ...rule, missing: [...(rule.missing ?? []), cookie] },
57
+ { ...rule, has: [...(rule.has ?? []), cookie], headers: rule.headers.map((h) => (h === csp ? { key: h.key, value: policy } : h)) },
58
+ ];
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Adds the baseline security headers to every route, lets next/image resize Site Manager's media, and stops sending
64
+ * X-Powered-By. Keeps everything already in the config: its own headers come after these, so a site can override one.
65
+ * A Content-Security-Policy of its own keeps working with the Visual Editor (allowVisualFrames).
66
+ */
67
+ export function withSiteManager(config: NextConfig = {}, opts: SiteManagerConfigOptions = {}): NextConfig {
68
+ const own = config.headers;
69
+ const patterns = config.images?.remotePatterns ?? [];
70
+ return {
71
+ ...config,
72
+ poweredByHeader: false,
73
+ images: { ...config.images, remotePatterns: [...patterns, MEDIA_REMOTE_PATTERN] },
74
+ async headers() {
75
+ return allowVisualFrames([{ source: "/:path*", headers: securityHeaders(opts) }, ...(own ? await own() : [])]);
76
+ },
77
+ };
78
+ }
@@ -1,21 +1,21 @@
1
- import { declaredKeys, declaredLists, type CopyKeyDef, type CopyListRule } from "./copy-keys";
2
- import { SIGNATURE_HEADER, verify } from "./signature";
3
- import { KIT_VERSION } from "./version";
4
-
5
- /**
6
- * Site Manager's Sync from Website reads the site's declared copy keys here. Put in
7
- * app/api/blueprint/copy-keys/route.ts:
8
- *
9
- * import { copyKeysRoute } from "site-manager-kit/copy-keys-route";
10
- * import { COPY } from "@/copy-keys";
11
- * export const GET = copyKeysRoute(COPY);
12
- *
13
- * Signed like the health route, over an empty body. Unsigned requests get a 401. Sends the keys and the list rules.
14
- */
15
- export function copyKeysRoute(copy: { __defs: Record<string, CopyKeyDef>; __lists?: Record<string, CopyListRule> }): (req: Request) => Promise<Response> {
16
- return async (req) => {
17
- const check = verify(process.env.BLUEPRINT_REVALIDATE_SECRET, req.headers.get(SIGNATURE_HEADER), "");
18
- if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
19
- return Response.json({ kitVersion: KIT_VERSION, keys: declaredKeys(copy), lists: declaredLists(copy) }, { headers: { "cache-control": "no-store" } });
20
- };
21
- }
1
+ import { declaredKeys, declaredLists, type CopyKeyDef, type CopyListRule } from "./copy-keys";
2
+ import { SIGNATURE_HEADER, verify } from "./signature";
3
+ import { KIT_VERSION } from "./version";
4
+
5
+ /**
6
+ * Site Manager's Sync from Website reads the site's declared copy keys here. Put in
7
+ * app/api/blueprint/copy-keys/route.ts:
8
+ *
9
+ * import { copyKeysRoute } from "site-manager-kit/copy-keys-route";
10
+ * import { COPY } from "@/copy-keys";
11
+ * export const GET = copyKeysRoute(COPY);
12
+ *
13
+ * Signed like the health route, over an empty body. Unsigned requests get a 401. Sends the keys and the list rules.
14
+ */
15
+ export function copyKeysRoute(copy: { __defs: Record<string, CopyKeyDef>; __lists?: Record<string, CopyListRule> }): (req: Request) => Promise<Response> {
16
+ return async (req) => {
17
+ const check = verify(process.env.BLUEPRINT_REVALIDATE_SECRET, req.headers.get(SIGNATURE_HEADER), "");
18
+ if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
19
+ return Response.json({ kitVersion: KIT_VERSION, keys: declaredKeys(copy), lists: declaredLists(copy) }, { headers: { "cache-control": "no-store" } });
20
+ };
21
+ }