@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/blog.ts CHANGED
@@ -1,122 +1,122 @@
1
- import { bp, bpWithMeta, tagsFor } from "./client";
2
- import type { BlogCategory, BlogFeed, BlogPage, BlogPost, BlogPostSummary, BlogTag, Paging } from "./types";
3
-
4
- // Reads for the Blog section. Posts are cached under the blog tag; a single post also under seo, since its search
5
- // listing is edited in Search Listings.
6
-
7
- type PageOpts = { page?: number; perPage?: number };
8
-
9
- const q = (params: Record<string, string | number | undefined | null>) => {
10
- const sp = new URLSearchParams();
11
- for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
12
- const s = sp.toString();
13
- return s ? `?${s}` : "";
14
- };
15
-
16
- const emptyPaging = (o: PageOpts): Paging => ({ page: o.page ?? 1, perPage: o.perPage ?? 10, total: 0, totalPages: 0 });
17
-
18
- function paging(meta: Record<string, unknown>, o: PageOpts): Paging {
19
- const n = (k: keyof Paging) => (typeof meta[k] === "number" ? (meta[k] as number) : emptyPaging(o)[k]);
20
- return { page: n("page"), perPage: n("perPage"), total: n("total"), totalPages: n("totalPages") };
21
- }
22
-
23
- /** Published posts, newest first, a page at a time (`perPage` up to 50), optionally in one category or with one tag (by slug). */
24
- export async function getPosts(opts: PageOpts & { category?: string; tag?: string } = {}): Promise<BlogPage> {
25
- const r = await bpWithMeta<BlogPostSummary[]>(`/blog/posts${q({ page: opts.page, perPage: opts.perPage, category: opts.category, tag: opts.tag })}`, { tags: tagsFor("blog") });
26
- return r ? { posts: r.data, paging: paging(r.meta, opts) } : { posts: [], paging: emptyPaging(opts) };
27
- }
28
-
29
- /** One published post with its body and search listing, or null. */
30
- export const getPost = (slug: string) => bp<BlogPost>(`/blog/posts/${encodeURIComponent(slug)}`, { tags: tagsFor("blog", "seo") });
31
-
32
- export const getBlogCategories = async () => (await bp<BlogCategory[]>("/blog/categories", { tags: tagsFor("blog") })) ?? [];
33
-
34
- /** One category and a page of its posts, or null when there's no such category. */
35
- export async function getBlogCategory(slug: string, opts: PageOpts = {}): Promise<{ category: BlogCategory; posts: BlogPostSummary[]; paging: Paging } | null> {
36
- const r = await bpWithMeta<{ category: BlogCategory; posts: BlogPostSummary[] }>(`/blog/categories/${encodeURIComponent(slug)}${q({ page: opts.page, perPage: opts.perPage })}`, { tags: tagsFor("blog") });
37
- return r ? { ...r.data, paging: paging(r.meta, opts) } : null;
38
- }
39
-
40
- export const getBlogTags = async () => (await bp<BlogTag[]>("/blog/tags", { tags: tagsFor("blog") })) ?? [];
41
-
42
- /** The newest posts with full bodies, shaped for RSS (`limit` up to 50, default 20). */
43
- export const getBlogFeed = (opts: { limit?: number } = {}) => bp<BlogFeed>(`/blog/feed${q({ limit: opts.limit })}`, { tags: tagsFor("blog", "business_info", "seo") });
44
-
45
- const xml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
46
- // CDATA can't contain its own end marker, so one is split across two sections.
47
- const cdata = (s: string) => `<![CDATA[${s.replace(/]]>/g, "]]]]><![CDATA[>")}]]>`;
48
-
49
- /**
50
- * Pure: an RSS 2.0 document from the feed. `feedUrl` is this feed's own address (for the atom:link self reference),
51
- * and `link` overrides the website address when Site Manager doesn't have one.
52
- */
53
- export function buildRss(feed: BlogFeed, opts: { feedUrl?: string; link?: string; title?: string; description?: string } = {}): string {
54
- const link = opts.link ?? feed.link ?? "";
55
- const self = opts.feedUrl ?? feed.feedUrl;
56
- const items = feed.items.map((i) =>
57
- [
58
- "<item>",
59
- `<title>${xml(i.title)}</title>`,
60
- i.link ? `<link>${xml(i.link)}</link>` : "",
61
- `<guid isPermaLink="${i.guidIsPermaLink ? "true" : "false"}">${xml(i.guid)}</guid>`,
62
- i.pubDate ? `<pubDate>${xml(i.pubDate)}</pubDate>` : "",
63
- i.author ? `<dc:creator>${xml(i.author)}</dc:creator>` : "",
64
- ...i.categories.map((c) => `<category>${xml(c)}</category>`),
65
- i.summary ? `<description>${xml(i.summary)}</description>` : "",
66
- i.contentHtml ? `<content:encoded>${cdata(i.contentHtml)}</content:encoded>` : "",
67
- i.image ? `<enclosure url="${xml(i.image.url)}" type="${xml(imageType(i.image.url))}" length="0"/>` : "",
68
- "</item>",
69
- ]
70
- .filter(Boolean)
71
- .join(""),
72
- );
73
- return [
74
- '<?xml version="1.0" encoding="UTF-8"?>',
75
- '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">',
76
- "<channel>",
77
- `<title>${xml(opts.title ?? feed.title)}</title>`,
78
- `<link>${xml(link)}</link>`,
79
- `<description>${xml(opts.description ?? feed.description ?? feed.title)}</description>`,
80
- `<language>${xml(feed.language)}</language>`,
81
- feed.updatedAt ? `<lastBuildDate>${new Date(feed.updatedAt).toUTCString()}</lastBuildDate>` : "",
82
- self ? `<atom:link href="${xml(self)}" rel="self" type="application/rss+xml"/>` : "",
83
- ...items,
84
- "</channel>",
85
- "</rss>",
86
- ]
87
- .filter(Boolean)
88
- .join("\n");
89
- }
90
-
91
- function imageType(url: string): string {
92
- const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
93
- return ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : ext === "avif" ? "image/avif" : "image/jpeg";
94
- }
95
-
96
- /**
97
- * Pure: schema.org BlogPosting JSON-LD for a post page. Put it in a `<script type="application/ld+json">` with
98
- * `JSON.stringify(...).replace(/</g, "\\u003c")`. Fields that aren't known are left out rather than guessed.
99
- */
100
- export function blogPostingJsonLd(post: BlogPost, opts: { publisherName?: string; publisherLogo?: string; url?: string } = {}): Record<string, unknown> {
101
- const url = opts.url ?? post.seo?.canonical ?? post.url ?? undefined;
102
- const image = post.cover?.url ?? post.seo?.ogImage?.url;
103
- const out: Record<string, unknown> = {
104
- "@context": "https://schema.org",
105
- "@type": "BlogPosting",
106
- headline: post.title.slice(0, 110),
107
- };
108
- if (url) {
109
- out.url = url;
110
- out.mainEntityOfPage = { "@type": "WebPage", "@id": url };
111
- }
112
- if (post.seo?.description ?? post.excerpt) out.description = post.seo?.description ?? post.excerpt;
113
- if (image) out.image = [image];
114
- if (post.publishedAt) out.datePublished = post.publishedAt;
115
- out.dateModified = post.updatedAt;
116
- if (post.author) out.author = { "@type": "Person", name: post.author.name, ...(post.author.url ? { url: post.author.url } : {}) };
117
- if (opts.publisherName) out.publisher = { "@type": "Organization", name: opts.publisherName, ...(opts.publisherLogo ? { logo: { "@type": "ImageObject", url: opts.publisherLogo } } : {}) };
118
- if (post.categories.length) out.articleSection = post.categories.map((c) => c.name);
119
- if (post.tags.length) out.keywords = post.tags.map((t) => t.name).join(", ");
120
- if (post.wordCount) out.wordCount = post.wordCount;
121
- return out;
122
- }
1
+ import { bp, bpWithMeta, tagsFor } from "./client";
2
+ import type { BlogCategory, BlogFeed, BlogPage, BlogPost, BlogPostSummary, BlogTag, Paging } from "./types";
3
+
4
+ // Reads for the Blog section. Posts are cached under the blog tag; a single post also under seo, since its search
5
+ // listing is edited in Search Listings.
6
+
7
+ type PageOpts = { page?: number; perPage?: number };
8
+
9
+ const q = (params: Record<string, string | number | undefined | null>) => {
10
+ const sp = new URLSearchParams();
11
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
12
+ const s = sp.toString();
13
+ return s ? `?${s}` : "";
14
+ };
15
+
16
+ const emptyPaging = (o: PageOpts): Paging => ({ page: o.page ?? 1, perPage: o.perPage ?? 10, total: 0, totalPages: 0 });
17
+
18
+ function paging(meta: Record<string, unknown>, o: PageOpts): Paging {
19
+ const n = (k: keyof Paging) => (typeof meta[k] === "number" ? (meta[k] as number) : emptyPaging(o)[k]);
20
+ return { page: n("page"), perPage: n("perPage"), total: n("total"), totalPages: n("totalPages") };
21
+ }
22
+
23
+ /** Published posts, newest first, a page at a time (`perPage` up to 50), optionally in one category or with one tag (by slug). */
24
+ export async function getPosts(opts: PageOpts & { category?: string; tag?: string } = {}): Promise<BlogPage> {
25
+ const r = await bpWithMeta<BlogPostSummary[]>(`/blog/posts${q({ page: opts.page, perPage: opts.perPage, category: opts.category, tag: opts.tag })}`, { tags: tagsFor("blog") });
26
+ return r ? { posts: r.data, paging: paging(r.meta, opts) } : { posts: [], paging: emptyPaging(opts) };
27
+ }
28
+
29
+ /** One published post with its body and search listing, or null. */
30
+ export const getPost = (slug: string) => bp<BlogPost>(`/blog/posts/${encodeURIComponent(slug)}`, { tags: tagsFor("blog", "seo") });
31
+
32
+ export const getBlogCategories = async () => (await bp<BlogCategory[]>("/blog/categories", { tags: tagsFor("blog") })) ?? [];
33
+
34
+ /** One category and a page of its posts, or null when there's no such category. */
35
+ export async function getBlogCategory(slug: string, opts: PageOpts = {}): Promise<{ category: BlogCategory; posts: BlogPostSummary[]; paging: Paging } | null> {
36
+ const r = await bpWithMeta<{ category: BlogCategory; posts: BlogPostSummary[] }>(`/blog/categories/${encodeURIComponent(slug)}${q({ page: opts.page, perPage: opts.perPage })}`, { tags: tagsFor("blog") });
37
+ return r ? { ...r.data, paging: paging(r.meta, opts) } : null;
38
+ }
39
+
40
+ export const getBlogTags = async () => (await bp<BlogTag[]>("/blog/tags", { tags: tagsFor("blog") })) ?? [];
41
+
42
+ /** The newest posts with full bodies, shaped for RSS (`limit` up to 50, default 20). */
43
+ export const getBlogFeed = (opts: { limit?: number } = {}) => bp<BlogFeed>(`/blog/feed${q({ limit: opts.limit })}`, { tags: tagsFor("blog", "business_info", "seo") });
44
+
45
+ const xml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
46
+ // CDATA can't contain its own end marker, so one is split across two sections.
47
+ const cdata = (s: string) => `<![CDATA[${s.replace(/]]>/g, "]]]]><![CDATA[>")}]]>`;
48
+
49
+ /**
50
+ * Pure: an RSS 2.0 document from the feed. `feedUrl` is this feed's own address (for the atom:link self reference),
51
+ * and `link` overrides the website address when Site Manager doesn't have one.
52
+ */
53
+ export function buildRss(feed: BlogFeed, opts: { feedUrl?: string; link?: string; title?: string; description?: string } = {}): string {
54
+ const link = opts.link ?? feed.link ?? "";
55
+ const self = opts.feedUrl ?? feed.feedUrl;
56
+ const items = feed.items.map((i) =>
57
+ [
58
+ "<item>",
59
+ `<title>${xml(i.title)}</title>`,
60
+ i.link ? `<link>${xml(i.link)}</link>` : "",
61
+ `<guid isPermaLink="${i.guidIsPermaLink ? "true" : "false"}">${xml(i.guid)}</guid>`,
62
+ i.pubDate ? `<pubDate>${xml(i.pubDate)}</pubDate>` : "",
63
+ i.author ? `<dc:creator>${xml(i.author)}</dc:creator>` : "",
64
+ ...i.categories.map((c) => `<category>${xml(c)}</category>`),
65
+ i.summary ? `<description>${xml(i.summary)}</description>` : "",
66
+ i.contentHtml ? `<content:encoded>${cdata(i.contentHtml)}</content:encoded>` : "",
67
+ i.image ? `<enclosure url="${xml(i.image.url)}" type="${xml(imageType(i.image.url))}" length="0"/>` : "",
68
+ "</item>",
69
+ ]
70
+ .filter(Boolean)
71
+ .join(""),
72
+ );
73
+ return [
74
+ '<?xml version="1.0" encoding="UTF-8"?>',
75
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">',
76
+ "<channel>",
77
+ `<title>${xml(opts.title ?? feed.title)}</title>`,
78
+ `<link>${xml(link)}</link>`,
79
+ `<description>${xml(opts.description ?? feed.description ?? feed.title)}</description>`,
80
+ `<language>${xml(feed.language)}</language>`,
81
+ feed.updatedAt ? `<lastBuildDate>${new Date(feed.updatedAt).toUTCString()}</lastBuildDate>` : "",
82
+ self ? `<atom:link href="${xml(self)}" rel="self" type="application/rss+xml"/>` : "",
83
+ ...items,
84
+ "</channel>",
85
+ "</rss>",
86
+ ]
87
+ .filter(Boolean)
88
+ .join("\n");
89
+ }
90
+
91
+ function imageType(url: string): string {
92
+ const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
93
+ return ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : ext === "avif" ? "image/avif" : "image/jpeg";
94
+ }
95
+
96
+ /**
97
+ * Pure: schema.org BlogPosting JSON-LD for a post page. Put it in a `<script type="application/ld+json">` with
98
+ * `JSON.stringify(...).replace(/</g, "\\u003c")`. Fields that aren't known are left out rather than guessed.
99
+ */
100
+ export function blogPostingJsonLd(post: BlogPost, opts: { publisherName?: string; publisherLogo?: string; url?: string } = {}): Record<string, unknown> {
101
+ const url = opts.url ?? post.seo?.canonical ?? post.url ?? undefined;
102
+ const image = post.cover?.url ?? post.seo?.ogImage?.url;
103
+ const out: Record<string, unknown> = {
104
+ "@context": "https://schema.org",
105
+ "@type": "BlogPosting",
106
+ headline: post.title.slice(0, 110),
107
+ };
108
+ if (url) {
109
+ out.url = url;
110
+ out.mainEntityOfPage = { "@type": "WebPage", "@id": url };
111
+ }
112
+ if (post.seo?.description ?? post.excerpt) out.description = post.seo?.description ?? post.excerpt;
113
+ if (image) out.image = [image];
114
+ if (post.publishedAt) out.datePublished = post.publishedAt;
115
+ out.dateModified = post.updatedAt;
116
+ if (post.author) out.author = { "@type": "Person", name: post.author.name, ...(post.author.url ? { url: post.author.url } : {}) };
117
+ if (opts.publisherName) out.publisher = { "@type": "Organization", name: opts.publisherName, ...(opts.publisherLogo ? { logo: { "@type": "ImageObject", url: opts.publisherLogo } } : {}) };
118
+ if (post.categories.length) out.articleSection = post.categories.map((c) => c.name);
119
+ if (post.tags.length) out.keywords = post.tags.map((t) => t.name).join(", ");
120
+ if (post.wordCount) out.wordCount = post.wordCount;
121
+ return out;
122
+ }
package/src/chat-route.ts CHANGED
@@ -1,136 +1,136 @@
1
- import { createHash } from "node:crypto";
2
-
3
- /*
4
- * The website's own chat route, between the visitor's browser and Site Manager: the browser never sees the API key,
5
- * Site Manager never sees the visitor's IP address (only its hash), and only this website's pages can use it.
6
- *
7
- * // app/api/blueprint/chat/route.ts
8
- * export { GET, POST } from "site-manager-kit/chat-route";
9
- *
10
- * POST { type: "message", conversationId?, visitorId, message, pageUrl?, hp? } streams the reply (NDJSON)
11
- * POST { type: "lead", conversationId, visitorId, name, email, phone, message, slot?, hp?, attribution? }
12
- * GET ?slots=1&from=YYYY-MM-DD open times the chat can book
13
- */
14
-
15
- const MAX_BODY = 20_000;
16
-
17
- function env(name: string): string {
18
- const v = process.env[name]?.trim();
19
- if (!v) throw new Error(`${name} is not set`);
20
- return v;
21
- }
22
-
23
- const json = (body: unknown, status = 200, headers: Record<string, string> = {}) => Response.json(body, { status, headers: { "cache-control": "no-store", ...headers } });
24
-
25
- /** Only this website's own pages may use the route, so nobody else can spend the site's AI key through it. */
26
- export function sameOrigin(req: Request): boolean {
27
- const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
28
- if (!host) return false;
29
- const origin = req.headers.get("origin");
30
- if (!origin) return req.headers.get("sec-fetch-site") === "same-origin";
31
- try {
32
- return new URL(origin).host === host;
33
- } catch {
34
- return false;
35
- }
36
- }
37
-
38
- function visitorIpHash(req: Request): string | undefined {
39
- const ip = req.headers.get("x-real-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
40
- return ip ? createHash("sha256").update(ip).digest("hex") : undefined;
41
- }
42
-
43
- async function isBot(): Promise<boolean> {
44
- try {
45
- const { checkBotId } = await import("botid/server");
46
- const r = await checkBotId();
47
- return r.isBot && !r.isVerifiedBot;
48
- } catch {
49
- // BotID isn't set up on this site. Site Manager's limits and the honeypot still apply.
50
- return false;
51
- }
52
- }
53
-
54
- async function upstream(path: string, init: RequestInit): Promise<Response> {
55
- const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
56
- return fetch(`${base}/api/v1${path}`, {
57
- cache: "no-store",
58
- ...init,
59
- headers: { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}`, ...(init.body ? { "content-type": "application/json" } : {}) },
60
- });
61
- }
62
-
63
- const str = (v: unknown, max: number) => (typeof v === "string" ? v.slice(0, max) : undefined);
64
-
65
- export async function POST(req: Request): Promise<Response> {
66
- if (!sameOrigin(req)) return json({ error: "forbidden" }, 403);
67
- const raw = await req.text();
68
- if (raw.length > MAX_BODY) return json({ error: "too_large" }, 413);
69
- let body: Record<string, unknown>;
70
- try {
71
- body = JSON.parse(raw) as Record<string, unknown>;
72
- } catch {
73
- return json({ error: "bad_request" }, 400);
74
- }
75
- const common = {
76
- conversationId: str(body.conversationId, 64),
77
- visitorId: str(body.visitorId, 100),
78
- ipHash: visitorIpHash(req),
79
- userAgent: req.headers.get("user-agent")?.slice(0, 500) ?? undefined,
80
- hp: str(body.hp, 200),
81
- };
82
-
83
- if (body.type === "message") {
84
- if (await isBot()) return json({ error: "forbidden" }, 403);
85
- const res = await upstream("/chat", {
86
- method: "POST",
87
- body: JSON.stringify({ ...common, message: str(body.message, 5000) ?? "", pageUrl: str(body.pageUrl, 2000) ?? req.headers.get("referer") ?? undefined }),
88
- });
89
- if (!res.ok || !res.body) {
90
- const err = (await res.json().catch(() => null)) as { error?: { code?: string; reason?: string } } | null;
91
- const retry = res.headers.get("retry-after");
92
- const status = res.status === 429 || res.status === 422 || res.status === 404 || res.status === 400 ? res.status : 502;
93
- return json({ error: err?.error?.reason ?? err?.error?.code ?? "unavailable" }, status, retry ? { "retry-after": retry } : {});
94
- }
95
- // Passed through as it streams, so the visitor sees the reply being written.
96
- return new Response(res.body, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no" } });
97
- }
98
-
99
- if (body.type === "lead") {
100
- const attribution = body.attribution && typeof body.attribution === "object" ? (body.attribution as Record<string, unknown>) : {};
101
- const res = await upstream("/chat/lead", {
102
- method: "POST",
103
- body: JSON.stringify({
104
- ...common,
105
- name: str(body.name, 200) ?? "",
106
- email: str(body.email, 320) ?? "",
107
- phone: str(body.phone, 40) ?? "",
108
- message: str(body.message, 2000) ?? "",
109
- slot: str(body.slot, 40),
110
- // A bot gets the usual thanks and nothing is stored, as with forms.
111
- ...((await isBot()) ? { hp: "kit:bot" } : {}),
112
- attribution: { landingPath: str(attribution.landingPath, 2000), referrer: str(attribution.referrer, 2000) },
113
- }),
114
- });
115
- const out = (await res.json().catch(() => null)) as { data?: { successMessage: string }; error?: { fields?: Record<string, string> } } | null;
116
- if (res.status === 201 && out?.data) return json({ ok: true, successMessage: out.data.successMessage });
117
- if (res.status === 422) return json({ ok: false, error: "invalid", fields: out?.error?.fields ?? {} });
118
- if (res.status === 409) return json({ ok: false, error: "slot_taken", fields: out?.error?.fields ?? {} });
119
- if (res.status === 429) return json({ ok: false, error: "rate_limited" });
120
- return json({ ok: false, error: "unavailable" });
121
- }
122
-
123
- return json({ error: "bad_request" }, 400);
124
- }
125
-
126
- export async function GET(req: Request): Promise<Response> {
127
- if (!sameOrigin(req)) return json({ error: "forbidden" }, 403);
128
- const url = new URL(req.url);
129
- if (!url.searchParams.has("slots")) return json({ error: "bad_request" }, 400);
130
- const q = new URLSearchParams({ days: "7" });
131
- const from = url.searchParams.get("from");
132
- if (from && /^\d{4}-\d{2}-\d{2}$/.test(from)) q.set("from", from);
133
- const res = await upstream(`/chat/slots?${q}`, { method: "GET" });
134
- const out = (await res.json().catch(() => null)) as { data?: unknown } | null;
135
- return res.ok && out?.data ? json(out.data) : json({ error: "unavailable" }, 404);
136
- }
1
+ import { createHash } from "node:crypto";
2
+
3
+ /*
4
+ * The website's own chat route, between the visitor's browser and Site Manager: the browser never sees the API key,
5
+ * Site Manager never sees the visitor's IP address (only its hash), and only this website's pages can use it.
6
+ *
7
+ * // app/api/blueprint/chat/route.ts
8
+ * export { GET, POST } from "site-manager-kit/chat-route";
9
+ *
10
+ * POST { type: "message", conversationId?, visitorId, message, pageUrl?, hp? } streams the reply (NDJSON)
11
+ * POST { type: "lead", conversationId, visitorId, name, email, phone, message, slot?, hp?, attribution? }
12
+ * GET ?slots=1&from=YYYY-MM-DD open times the chat can book
13
+ */
14
+
15
+ const MAX_BODY = 20_000;
16
+
17
+ function env(name: string): string {
18
+ const v = process.env[name]?.trim();
19
+ if (!v) throw new Error(`${name} is not set`);
20
+ return v;
21
+ }
22
+
23
+ const json = (body: unknown, status = 200, headers: Record<string, string> = {}) => Response.json(body, { status, headers: { "cache-control": "no-store", ...headers } });
24
+
25
+ /** Only this website's own pages may use the route, so nobody else can spend the site's AI key through it. */
26
+ export function sameOrigin(req: Request): boolean {
27
+ const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
28
+ if (!host) return false;
29
+ const origin = req.headers.get("origin");
30
+ if (!origin) return req.headers.get("sec-fetch-site") === "same-origin";
31
+ try {
32
+ return new URL(origin).host === host;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function visitorIpHash(req: Request): string | undefined {
39
+ const ip = req.headers.get("x-real-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
40
+ return ip ? createHash("sha256").update(ip).digest("hex") : undefined;
41
+ }
42
+
43
+ async function isBot(): Promise<boolean> {
44
+ try {
45
+ const { checkBotId } = await import("botid/server");
46
+ const r = await checkBotId();
47
+ return r.isBot && !r.isVerifiedBot;
48
+ } catch {
49
+ // BotID isn't set up on this site. Site Manager's limits and the honeypot still apply.
50
+ return false;
51
+ }
52
+ }
53
+
54
+ async function upstream(path: string, init: RequestInit): Promise<Response> {
55
+ const base = env("BLUEPRINT_URL").replace(/\/+$/, "");
56
+ return fetch(`${base}/api/v1${path}`, {
57
+ cache: "no-store",
58
+ ...init,
59
+ headers: { authorization: `Bearer ${env("BLUEPRINT_API_KEY")}`, ...(init.body ? { "content-type": "application/json" } : {}) },
60
+ });
61
+ }
62
+
63
+ const str = (v: unknown, max: number) => (typeof v === "string" ? v.slice(0, max) : undefined);
64
+
65
+ export async function POST(req: Request): Promise<Response> {
66
+ if (!sameOrigin(req)) return json({ error: "forbidden" }, 403);
67
+ const raw = await req.text();
68
+ if (raw.length > MAX_BODY) return json({ error: "too_large" }, 413);
69
+ let body: Record<string, unknown>;
70
+ try {
71
+ body = JSON.parse(raw) as Record<string, unknown>;
72
+ } catch {
73
+ return json({ error: "bad_request" }, 400);
74
+ }
75
+ const common = {
76
+ conversationId: str(body.conversationId, 64),
77
+ visitorId: str(body.visitorId, 100),
78
+ ipHash: visitorIpHash(req),
79
+ userAgent: req.headers.get("user-agent")?.slice(0, 500) ?? undefined,
80
+ hp: str(body.hp, 200),
81
+ };
82
+
83
+ if (body.type === "message") {
84
+ if (await isBot()) return json({ error: "forbidden" }, 403);
85
+ const res = await upstream("/chat", {
86
+ method: "POST",
87
+ body: JSON.stringify({ ...common, message: str(body.message, 5000) ?? "", pageUrl: str(body.pageUrl, 2000) ?? req.headers.get("referer") ?? undefined }),
88
+ });
89
+ if (!res.ok || !res.body) {
90
+ const err = (await res.json().catch(() => null)) as { error?: { code?: string; reason?: string } } | null;
91
+ const retry = res.headers.get("retry-after");
92
+ const status = res.status === 429 || res.status === 422 || res.status === 404 || res.status === 400 ? res.status : 502;
93
+ return json({ error: err?.error?.reason ?? err?.error?.code ?? "unavailable" }, status, retry ? { "retry-after": retry } : {});
94
+ }
95
+ // Passed through as it streams, so the visitor sees the reply being written.
96
+ return new Response(res.body, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no" } });
97
+ }
98
+
99
+ if (body.type === "lead") {
100
+ const attribution = body.attribution && typeof body.attribution === "object" ? (body.attribution as Record<string, unknown>) : {};
101
+ const res = await upstream("/chat/lead", {
102
+ method: "POST",
103
+ body: JSON.stringify({
104
+ ...common,
105
+ name: str(body.name, 200) ?? "",
106
+ email: str(body.email, 320) ?? "",
107
+ phone: str(body.phone, 40) ?? "",
108
+ message: str(body.message, 2000) ?? "",
109
+ slot: str(body.slot, 40),
110
+ // A bot gets the usual thanks and nothing is stored, as with forms.
111
+ ...((await isBot()) ? { hp: "kit:bot" } : {}),
112
+ attribution: { landingPath: str(attribution.landingPath, 2000), referrer: str(attribution.referrer, 2000) },
113
+ }),
114
+ });
115
+ const out = (await res.json().catch(() => null)) as { data?: { successMessage: string }; error?: { fields?: Record<string, string> } } | null;
116
+ if (res.status === 201 && out?.data) return json({ ok: true, successMessage: out.data.successMessage });
117
+ if (res.status === 422) return json({ ok: false, error: "invalid", fields: out?.error?.fields ?? {} });
118
+ if (res.status === 409) return json({ ok: false, error: "slot_taken", fields: out?.error?.fields ?? {} });
119
+ if (res.status === 429) return json({ ok: false, error: "rate_limited" });
120
+ return json({ ok: false, error: "unavailable" });
121
+ }
122
+
123
+ return json({ error: "bad_request" }, 400);
124
+ }
125
+
126
+ export async function GET(req: Request): Promise<Response> {
127
+ if (!sameOrigin(req)) return json({ error: "forbidden" }, 403);
128
+ const url = new URL(req.url);
129
+ if (!url.searchParams.has("slots")) return json({ error: "bad_request" }, 400);
130
+ const q = new URLSearchParams({ days: "7" });
131
+ const from = url.searchParams.get("from");
132
+ if (from && /^\d{4}-\d{2}-\d{2}$/.test(from)) q.set("from", from);
133
+ const res = await upstream(`/chat/slots?${q}`, { method: "GET" });
134
+ const out = (await res.json().catch(() => null)) as { data?: unknown } | null;
135
+ return res.ok && out?.data ? json(out.data) : json({ error: "unavailable" }, 404);
136
+ }