@soloworks/smking-next 0.10.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.12.0 — 2026-05-15
4
+
5
+ **AI crawler + AI referral telemetry — `smkingProxy` Next.js middleware.**
6
+
7
+ ### Added
8
+
9
+ - **`@soloworks/smking-next/proxy` subpath export.** New `smkingProxy({ apiKey, baseUrl? })` middleware factory + `composeProxy([...handlers])` helper. Detects AI bot UAs (17 patterns covering GPTBot / ClaudeBot / PerplexityBot / Google-Extended / Applebot / CCBot / Bytespider / meta-externalagent / Amazonbot / Cohere / Diffbot) and AI-referrer hostnames (chatgpt.com / perplexity.ai / claude.ai / gemini.google.com / copilot.microsoft.com / bing.com), then fire-and-forgets a `POST /api/v1/crawler-hit` ingest via Vercel `after()` so the customer's response is never delayed.
10
+
11
+ Install:
12
+
13
+ ```ts
14
+ // proxy.ts
15
+ import { smkingProxy } from "@soloworks/smking-next/proxy";
16
+
17
+ export const proxy = smkingProxy({ apiKey: process.env.SMKING_API_KEY! });
18
+
19
+ export const config = {
20
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
21
+ };
22
+ ```
23
+
24
+ Composing with an existing proxy:
25
+
26
+ ```ts
27
+ import { composeProxy, smkingProxy } from "@soloworks/smking-next/proxy";
28
+ import { customerProxy } from "./their-proxy";
29
+
30
+ export const proxy = composeProxy([
31
+ smkingProxy({ apiKey: process.env.SMKING_API_KEY! }),
32
+ customerProxy,
33
+ ]);
34
+ ```
35
+
36
+ Fail-open: missing `SMKING_API_KEY` / `SMKING_BASE_URL` → no-op (matches `getAeoContent` convention). Ingestion endpoint down / 4xx / 5xx → silent skip. Customer site never breaks because of telemetry.
37
+
38
+ ### Why
39
+
40
+ Crawler telemetry feeds the new 4-pillar AEO Scorecard (visibility / bot engagement / content readiness / traffic impact). Without per-customer-site detection, the scorecard's Bot Engagement and Traffic Impact pillars stay at zero forever. SDK ships the detection so customers don't need to roll their own — `pnpm update @soloworks/smking-next` + add 5-line `proxy.ts` = done.
41
+
42
+ ### Customer migration
43
+
44
+ - **Caret rule reminder**: `^0.11` resolves to `>=0.11.0 <0.12.0`, so `pnpm update` will NOT pull 0.12 automatically. Bump constraint to `^0.12` in `package.json` to receive this release.
45
+ - No other breaking changes vs 0.11. AEO / CMS / webhook surfaces unchanged.
46
+
47
+ ## 0.11.0 — 2026-05-15
48
+
49
+ **Substrate pivot: unified webhook channel.**
50
+
51
+ ### BREAKING
52
+
53
+ - **Unified webhook endpoint.** `@soloworks/smking-next/webhook` replaces the previous split: `/route` (AEO Bearer-authed) AND `/cms-webhook` (CMS HMAC). Single HMAC-signed endpoint, payload `.kind` (`"aeo" | "cms_page" | ...`) dispatches which revalidate tag namespace to use.
54
+
55
+ Customer migration:
56
+ 1. **Env**: `SMKING_WEBHOOK_TOKEN` → `SMKING_WEBHOOK_SECRET` (HMAC key — same secret you got from the install prompt for CMS, now used for everything).
57
+ 2. **Route shim**: replace both `app/api/smking-revalidate/route.ts` (AEO) AND `app/api/smking/webhook/route.ts` (CMS) with a single `app/api/smking/webhook/route.ts` exporting `POST` from `@soloworks/smking-next/webhook`.
58
+ 3. **Dashboard webhook URL** field should point at the single `/api/smking/webhook` path.
59
+
60
+ - **Revalidate tag namespaces changed.** Customer code calling `revalidateTag` directly (rare — most customers use `<SmkingAEO />` / `<SmkingCms />` which handle this internally) must update:
61
+ - `smking:path:*` → `smking:aeo:*`
62
+ - `smking:cms:*` → `smking:cms_page:*`
63
+
64
+ - **`/route` and `/cms-webhook` exports removed.** Importing either at v0.11 fails with a "Module not found" error pointing customers at the migration. Old source files deleted from the package (git history preserves them).
65
+
66
+ ### Why
67
+
68
+ PostHog architectural pattern study (`docs/cms-tech-suggestions-posthog.md`) flagged the split webhook as substrate-discipline violation. One channel for the substrate; payload `kind` distinguishes projection. Future product surfaces (widget config, crawler analytics, Shopify connector) extend the discriminator without growing the SDK.
69
+
3
70
  ## 0.10.0 — 2026-05-14
4
71
 
5
72
  **`SmkingCms` typing fix + CMS revalidate default aligned to 5 min.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "AI-native SEO (AEO) for Next.js — auto-inject JSON-LD, FAQ, AI summary, and SEO metadata so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your pages.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/sillyleo/smking/tree/main/packages/smking-next",
@@ -19,13 +19,9 @@
19
19
  "types": "./src/components/smking-cms.tsx",
20
20
  "default": "./src/components/smking-cms.tsx"
21
21
  },
22
- "./cms-webhook": {
23
- "types": "./src/lib/cms-webhook-route.ts",
24
- "default": "./src/lib/cms-webhook-route.ts"
25
- },
26
- "./route": {
27
- "types": "./src/route.ts",
28
- "default": "./src/route.ts"
22
+ "./webhook": {
23
+ "types": "./src/lib/webhook-route.ts",
24
+ "default": "./src/lib/webhook-route.ts"
29
25
  },
30
26
  "./robots": {
31
27
  "types": "./src/lib/robots.ts",
@@ -38,6 +34,10 @@
38
34
  "./llms-txt": {
39
35
  "types": "./src/lib/llms-txt-route.ts",
40
36
  "default": "./src/lib/llms-txt-route.ts"
37
+ },
38
+ "./proxy": {
39
+ "types": "./src/lib/proxy.ts",
40
+ "default": "./src/lib/proxy.ts"
41
41
  }
42
42
  },
43
43
  "bin": {
@@ -63,29 +63,7 @@
63
63
  ],
64
64
  "peerDependencies": {
65
65
  "next": "^15.0.0 || ^16.0.0",
66
- "react": "^18.0.0 || ^19.0.0",
67
- "@tiptap/static-renderer": "^3.0.0",
68
- "@tiptap/core": "^3.0.0",
69
- "@tiptap/starter-kit": "^3.0.0",
70
- "@tiptap/extension-image": "^3.0.0",
71
- "@tiptap/extension-link": "^3.0.0"
72
- },
73
- "peerDependenciesMeta": {
74
- "@tiptap/static-renderer": {
75
- "optional": true
76
- },
77
- "@tiptap/core": {
78
- "optional": true
79
- },
80
- "@tiptap/starter-kit": {
81
- "optional": true
82
- },
83
- "@tiptap/extension-image": {
84
- "optional": true
85
- },
86
- "@tiptap/extension-link": {
87
- "optional": true
88
- }
66
+ "react": "^18.0.0 || ^19.0.0"
89
67
  },
90
68
  "devDependencies": {
91
69
  "@testing-library/jest-dom": "^6.9.1",
@@ -1,11 +1,5 @@
1
- import { renderToReactElement } from "@tiptap/static-renderer/pm/react";
2
- import StarterKit from "@tiptap/starter-kit";
3
- import Image from "@tiptap/extension-image";
4
- import Link from "@tiptap/extension-link";
5
- import { Gallery } from "./cms-nodes/gallery-extension";
6
- import { GalleryNodeView } from "./cms-nodes/gallery-node";
7
1
  import { getCmsPage } from "../lib/cms-client";
8
- import type { CmsParams } from "../types";
2
+ import type { Block, CmsParams } from "../types";
9
3
 
10
4
  /**
11
5
  * Server Component that renders a published smking CMS page.
@@ -24,19 +18,19 @@ import type { CmsParams } from "../types";
24
18
  * }
25
19
  * ```
26
20
  *
27
- * Fetches the page server-side, walks the Tiptap ProseMirror JSON via
28
- * `@tiptap/static-renderer/pm/react`, and renders it as a React tree.
29
- * Standard nodes (paragraph / heading / image / list / link / blockquote
30
- * / code-block) come from StarterKit. The custom Gallery node is
31
- * mapped to GalleryNodeView whose markup is byte-equal to the SaaS
32
- * preview side and the Laravel SDK PHP renderer.
21
+ * v0.11+ substrate pivot: fetches a Block[] array and dispatches each
22
+ * block by `component`. Article block carries pre-rendered HTML
23
+ * (server-rendered at write time via tiptap-html, so the customer SDK
24
+ * needs zero Tiptap dependencies). Hero / nav-* blocks render inline;
25
+ * full server-side data fetch for nav-recent-posts / nav-taxonomy-list
26
+ * / nav-search arrives in M2+ — they emit pending markers for now so
27
+ * customer pages don't 500 if the page contains them.
33
28
  *
34
29
  * Returns null when the response isn't ready (pending / not_found /
35
30
  * unreachable / mis-configured) — fail-open by design.
36
31
  *
37
- * Customer install requires four Tiptap peer deps:
38
- * pnpm add @tiptap/static-renderer @tiptap/starter-kit \\
39
- * @tiptap/extension-image @tiptap/extension-link
32
+ * Emits `<title>` / `<meta>` / `og:*` / canonical inline; React 19
33
+ * hoists them into `<head>` automatically.
40
34
  */
41
35
  export async function SmkingCms(props: CmsParams) {
42
36
  const data = await getCmsPage(props);
@@ -47,6 +41,7 @@ export async function SmkingCms(props: CmsParams) {
47
41
  // (last write wins on duplicate tags), so a deeper layout / page that
48
42
  // also sets these still overrides ours where present.
49
43
  const seo = data.seo;
44
+ const blocks = data.page.blocks ?? [];
50
45
  return (
51
46
  <>
52
47
  {seo?.title && <title data-smking="cms">{seo.title}</title>}
@@ -74,23 +69,95 @@ export async function SmkingCms(props: CmsParams) {
74
69
  <link rel="canonical" href={seo.canonicalUrl} data-smking="cms" />
75
70
  )}
76
71
 
77
- <article className="smk-cms" data-smking="cms">
72
+ <article
73
+ className="smk-cms"
74
+ data-smking="cms"
75
+ data-content-type={data.page.contentType}
76
+ >
78
77
  {data.page.title && (
79
78
  <h1 className="smk-cms__title">{data.page.title}</h1>
80
79
  )}
81
- {renderToReactElement({
82
- extensions: [StarterKit, Image, Link, Gallery],
83
- content: data.page.body,
84
- options: {
85
- nodeMapping: {
86
- // Custom node renderer for our gallery; standard nodes
87
- // (paragraph, heading, list, etc.) auto-render from
88
- // StarterKit's schema.
89
- gallery: GalleryNodeView,
90
- },
91
- },
92
- })}
80
+ {blocks.map((block) => renderBlock(block))}
93
81
  </article>
94
82
  </>
95
83
  );
96
84
  }
85
+
86
+ /**
87
+ * Dispatch a single block to its rendered React element.
88
+ *
89
+ * Trust boundary for `article` blocks: HTML is rendered server-side at
90
+ * write time by `@tiptap/html/server` inside the SaaS (Tiptap's renderer
91
+ * only emits HTML for known nodes per the configured schema, so author
92
+ * input can't smuggle arbitrary tags through). Customer site renders
93
+ * that HTML as-is via React's raw-HTML escape hatch. If customer wants
94
+ * defence-in-depth beyond the SaaS sanitisation tier, layer CSP on
95
+ * their host page (smking SDK does not ship a customer-side sanitiser
96
+ * to keep zero-dep customer install).
97
+ */
98
+ function renderBlock(block: Block) {
99
+ switch (block.component) {
100
+ case "article": {
101
+ const articleHtml: { __html: string } = { __html: block.props.html };
102
+ return (
103
+ <div
104
+ key={block.id}
105
+ className="smk-block smk-block--article"
106
+ data-block-id={block.id}
107
+ // eslint-disable-next-line react/no-danger -- HTML pre-rendered server-side via @tiptap/html/server; trust boundary = SaaS schema. See function-level doc-comment above.
108
+ dangerouslySetInnerHTML={articleHtml}
109
+ />
110
+ );
111
+ }
112
+ case "hero":
113
+ return (
114
+ <section
115
+ key={block.id}
116
+ className="smk-block smk-block--hero smk-hero"
117
+ data-block-id={block.id}
118
+ >
119
+ {block.props.image && (
120
+ <img
121
+ src={block.props.image.url}
122
+ alt={block.props.image.alt}
123
+ className="smk-hero__image"
124
+ />
125
+ )}
126
+ <h2 className="smk-hero__title">{block.props.title}</h2>
127
+ {block.props.subtitle && (
128
+ <p className="smk-hero__subtitle">{block.props.subtitle}</p>
129
+ )}
130
+ {block.props.cta && (
131
+ <a className="smk-hero__cta" href={block.props.cta.href}>
132
+ {block.props.cta.label}
133
+ </a>
134
+ )}
135
+ </section>
136
+ );
137
+ case "nav-recent-posts":
138
+ case "nav-taxonomy-list":
139
+ case "nav-search":
140
+ // Server-side data fetch + render lands in M2+ alongside the
141
+ // public posts / taxonomy / search endpoints. Emit a marker so
142
+ // customer pages don't 500 if an editor publishes a page with
143
+ // one of these blocks before M2 ships.
144
+ return (
145
+ <section
146
+ key={block.id}
147
+ className={`smk-block smk-block--${block.component} smk-nav-pending`}
148
+ data-block-id={block.id}
149
+ data-block-component={block.component}
150
+ />
151
+ );
152
+ default:
153
+ // Forward-compat: unknown block component — emit empty marker
154
+ // so the page renders. Better than throwing.
155
+ return (
156
+ <section
157
+ key={(block as { id: string }).id}
158
+ className="smk-block smk-block--unknown"
159
+ data-block-component={(block as { component: string }).component}
160
+ />
161
+ );
162
+ }
163
+ }
package/src/lib/client.ts CHANGED
@@ -26,8 +26,11 @@ function warnOnce(key: string, message: string): void {
26
26
  * - JSON parse failure
27
27
  *
28
28
  * On success, cached by Next.js data cache for 1h (ISR backstop). Tagged
29
- * with `smking:path:<path>` so the webhook handler at `@soloworks/smking-next/route`
30
- * can `revalidateTag` to invalidate instantly when SaaS pushes an update.
29
+ * with `smking:aeo:<path>` so the unified webhook handler at
30
+ * `@soloworks/smking-next/webhook` can `revalidateTag` to invalidate
31
+ * instantly when SaaS pushes an update. Namespace changed from
32
+ * `smking:path:*` in v0.11 — unified channel uses payload.kind to
33
+ * scope tag prefix.
31
34
  *
32
35
  * Sends `{ key, path, url }` — `url` is required for first-sight
33
36
  * registration: when the SaaS hasn't seen this path before it queues a
@@ -82,7 +85,7 @@ export async function getAeoContent(
82
85
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
83
86
  next: {
84
87
  revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
85
- tags: [`smking:path:${path}`],
88
+ tags: [`smking:aeo:${path}`],
86
89
  },
87
90
  });
88
91
  if (!res.ok) return null;
@@ -33,9 +33,10 @@ function warnOnce(key: string, message: string): void {
33
33
  *
34
34
  * On success, cached by Next.js data cache for 5min by default
35
35
  * (ISR backstop — see DEFAULT_REVALIDATE_SECONDS rationale).
36
- * Tagged with `smking:cms:<slug>` so the `cms-webhook` handler can
37
- * `revalidateTag` to invalidate instantly when SaaS publishes an
38
- * update.
36
+ * Tagged with `smking:cms_page:<slug>` so the unified `/webhook` handler
37
+ * can `revalidateTag` to invalidate instantly when SaaS publishes an
38
+ * update. Namespace changed from `smking:cms:*` in v0.11 — unified
39
+ * channel uses payload.kind to scope tag prefix.
39
40
  */
40
41
  export async function getCmsPage(
41
42
  params: CmsParams,
@@ -76,7 +77,7 @@ export async function getCmsPage(
76
77
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
77
78
  next: {
78
79
  revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
79
- tags: [`smking:cms:${params.slug}`],
80
+ tags: [`smking:cms_page:${params.slug}`],
80
81
  },
81
82
  });
82
83
  if (!res.ok) return null;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * AI bot + referral detection. Shared by `smkingProxy` (Next.js middleware)
3
+ * and any caller that wants to classify a request before forwarding it to
4
+ * the smking ingestion endpoint.
5
+ *
6
+ * Patterns are 2026-04 active list — see
7
+ * docs/ai-tracking-implementation.md A.2 for sourcing notes. Quarterly
8
+ * refresh from Cloudflare Radar / Dark Visitors / each vendor's docs.
9
+ *
10
+ * Detection precedence:
11
+ * 1. Bot UA match → `purpose` derived from bot category
12
+ * 2. No bot, but referer is an AI search surface → `purpose = ai_referral`
13
+ * 3. Otherwise → no record (we don't store noise)
14
+ */
15
+
16
+ export type AiBotCategory = "training" | "search" | "user_triggered";
17
+
18
+ export type AiHitPurpose =
19
+ | "training"
20
+ | "realtime_citation"
21
+ | "ai_referral"
22
+ | "unknown";
23
+
24
+ interface CrawlerPattern {
25
+ readonly name: string;
26
+ readonly regex: RegExp;
27
+ readonly category: AiBotCategory;
28
+ }
29
+
30
+ const PATTERNS: readonly CrawlerPattern[] = [
31
+ // OpenAI
32
+ { name: "GPTBot", regex: /GPTBot\/[\d.]+/, category: "training" },
33
+ { name: "OAI-SearchBot", regex: /OAI-SearchBot\/[\d.]+/, category: "search" },
34
+ { name: "ChatGPT-User", regex: /ChatGPT-User\/[\d.]+/, category: "user_triggered" },
35
+ // Anthropic
36
+ { name: "ClaudeBot", regex: /ClaudeBot\/[\d.]+/, category: "training" },
37
+ { name: "Claude-Web", regex: /Claude-Web\/[\d.]+/, category: "user_triggered" },
38
+ { name: "anthropic-ai", regex: /anthropic-ai/, category: "training" },
39
+ // Perplexity
40
+ { name: "PerplexityBot", regex: /PerplexityBot\/[\d.]+/, category: "training" },
41
+ { name: "Perplexity-User", regex: /Perplexity-User\/[\d.]+/, category: "user_triggered" },
42
+ // Google
43
+ { name: "Google-Extended", regex: /Google-Extended/, category: "training" },
44
+ { name: "GoogleOther", regex: /GoogleOther/, category: "training" },
45
+ // Apple
46
+ { name: "Applebot-Extended", regex: /Applebot-Extended\/[\d.]+/, category: "training" },
47
+ // Common Crawl (shared corpus for many LLMs)
48
+ { name: "CCBot", regex: /CCBot\/[\d.]+/, category: "training" },
49
+ // ByteDance / Doubao
50
+ { name: "Bytespider", regex: /Bytespider/, category: "training" },
51
+ // Meta AI
52
+ { name: "meta-externalagent", regex: /meta-externalagent\/[\d.]+/, category: "training" },
53
+ // Amazon Alexa+
54
+ { name: "Amazonbot", regex: /Amazonbot\/[\d.]+/, category: "training" },
55
+ // Cohere
56
+ { name: "cohere-ai", regex: /cohere-ai/, category: "training" },
57
+ // Diffbot
58
+ { name: "Diffbot", regex: /Diffbot\/[\d.]+/, category: "training" },
59
+ ];
60
+
61
+ /**
62
+ * Hostnames that indicate the user came from an AI answer surface. Used to
63
+ * tag `ai_referral` traffic (no bot UA, real human session bounced from
64
+ * ChatGPT et al.).
65
+ */
66
+ const AI_REFERRER_HOSTS: ReadonlySet<string> = new Set([
67
+ "chatgpt.com",
68
+ "chat.openai.com",
69
+ "perplexity.ai",
70
+ "www.perplexity.ai",
71
+ "claude.ai",
72
+ "gemini.google.com",
73
+ "copilot.microsoft.com",
74
+ "bing.com",
75
+ "www.bing.com",
76
+ ]);
77
+
78
+ export interface AiBotInfo {
79
+ readonly name: string;
80
+ readonly category: AiBotCategory;
81
+ }
82
+
83
+ /**
84
+ * Returns bot info if the UA matches a known AI crawler pattern. Linear
85
+ * scan of ~17 regexes — sub-millisecond per call, fine for a hot path.
86
+ */
87
+ export function detectAiBot(userAgent: string | null | undefined): AiBotInfo | null {
88
+ if (!userAgent) return null;
89
+ for (const p of PATTERNS) {
90
+ if (p.regex.test(userAgent)) {
91
+ return { name: p.name, category: p.category };
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Returns true when the referer header points to an AI answer surface.
99
+ * NULL-safe and tolerant of malformed URLs.
100
+ */
101
+ export function detectAiReferral(referer: string | null | undefined): boolean {
102
+ if (!referer) return false;
103
+ try {
104
+ const host = new URL(referer).hostname.toLowerCase();
105
+ return AI_REFERRER_HOSTS.has(host);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ export interface AiHitClassification {
112
+ readonly purpose: AiHitPurpose;
113
+ readonly bot: string | null;
114
+ readonly botCategory: AiBotCategory | null;
115
+ }
116
+
117
+ /**
118
+ * Classify a request from its UA + referer. Returns `null` when it's
119
+ * neither a known AI bot nor an AI-referred human session — the caller
120
+ * should skip recording these to keep ingestion focused.
121
+ */
122
+ export function classifyAiHit(
123
+ userAgent: string | null | undefined,
124
+ referer: string | null | undefined,
125
+ ): AiHitClassification | null {
126
+ const bot = detectAiBot(userAgent);
127
+ if (bot) {
128
+ const purpose: AiHitPurpose =
129
+ bot.category === "training" ? "training" : "realtime_citation";
130
+ return { purpose, bot: bot.name, botCategory: bot.category };
131
+ }
132
+ if (detectAiReferral(referer)) {
133
+ return { purpose: "ai_referral", bot: null, botCategory: null };
134
+ }
135
+ return null;
136
+ }
package/src/lib/path.ts CHANGED
@@ -43,7 +43,7 @@ export async function resolveRequestPath(): Promise<{
43
43
  * Normalize a path so cache tags align across hand-written and
44
44
  * auto-resolved callers. `<SmkingAEO path="products/abc" />` and
45
45
  * `<SmkingAEO path="/products/abc" />` must hit the same Next.js cache
46
- * entry — otherwise the webhook handler's `revalidateTag('smking:path:/products/abc')`
46
+ * entry — otherwise the webhook handler's `revalidateTag('smking:aeo:/products/abc')`
47
47
  * misses the version of the entry that lacks the leading slash.
48
48
  */
49
49
  export function normalizePath(path: string): string {
@@ -0,0 +1,147 @@
1
+ import { NextResponse, type NextRequest, after } from "next/server";
2
+ import { classifyAiHit } from "./crawlers";
3
+
4
+ /**
5
+ * SmKing AI traffic ingestion proxy (v0.12+, Next.js 16 `proxy.ts`).
6
+ *
7
+ * Drop-in install:
8
+ *
9
+ * ```ts
10
+ * // proxy.ts (project root)
11
+ * import { smkingProxy } from "@soloworks/smking-next/proxy";
12
+ *
13
+ * export const proxy = smkingProxy({
14
+ * apiKey: process.env.SMKING_API_KEY!,
15
+ * });
16
+ *
17
+ * export const config = {
18
+ * matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
19
+ * };
20
+ * ```
21
+ *
22
+ * What it does:
23
+ * - Inspects each request's UA + referer
24
+ * - If it's an AI bot or AI-referred human session, batches one
25
+ * ingestion call via Vercel `after()` (post-response background)
26
+ * - Otherwise: passes through with zero overhead
27
+ *
28
+ * Composing with an existing proxy:
29
+ *
30
+ * ```ts
31
+ * import { composeProxy, smkingProxy } from "@soloworks/smking-next/proxy";
32
+ * import { customerProxy } from "./their-proxy";
33
+ *
34
+ * export const proxy = composeProxy([
35
+ * smkingProxy({ apiKey: process.env.SMKING_API_KEY! }),
36
+ * customerProxy,
37
+ * ]);
38
+ * ```
39
+ *
40
+ * Fail-open posture (matches `getAeoContent`):
41
+ * - No `apiKey` → no-op, customer site keeps running
42
+ * - No `baseUrl` / `SMKING_BASE_URL` → no-op
43
+ * - Ingestion endpoint down / 4xx / 5xx → silent skip, next request retries
44
+ *
45
+ * Why `after()` (not fire-and-forget `fetch`):
46
+ * - Vercel serverless kills the function once the response flushes,
47
+ * which can drop a naked fetch before it's sent
48
+ * - `after()` keeps the function alive within the existing lifetime
49
+ * for background work — designed exactly for this case
50
+ */
51
+ export interface SmkingProxyConfig {
52
+ /** Site's public API key (`pk_*`). Falls back to `SMKING_API_KEY` env. */
53
+ apiKey?: string;
54
+ /** Ingestion base URL. Falls back to `SMKING_BASE_URL` env. */
55
+ baseUrl?: string;
56
+ }
57
+
58
+ export type ProxyMiddleware = (
59
+ request: NextRequest,
60
+ ) => NextResponse | Promise<NextResponse>;
61
+
62
+ const INGEST_TIMEOUT_MS = 2000;
63
+
64
+ /**
65
+ * Build the proxy middleware. Returns a no-op middleware when the
66
+ * required env / config is missing so dev / staging installs without
67
+ * env never crash.
68
+ */
69
+ export function smkingProxy(config: SmkingProxyConfig = {}): ProxyMiddleware {
70
+ return (request: NextRequest) => {
71
+ const apiKey = config.apiKey ?? process.env.SMKING_API_KEY;
72
+ const baseUrl = (config.baseUrl ?? process.env.SMKING_BASE_URL)?.replace(
73
+ /\/$/,
74
+ "",
75
+ );
76
+ if (!apiKey || !baseUrl) {
77
+ return NextResponse.next();
78
+ }
79
+
80
+ const ua = request.headers.get("user-agent");
81
+ const referer = request.headers.get("referer");
82
+ const hit = classifyAiHit(ua, referer);
83
+ if (!hit) return NextResponse.next();
84
+
85
+ const path = request.nextUrl.pathname;
86
+ const pageUrl = request.nextUrl.toString();
87
+
88
+ after(async () => {
89
+ try {
90
+ await fetch(`${baseUrl}/api/v1/crawler-hit`, {
91
+ method: "POST",
92
+ headers: {
93
+ "content-type": "application/json",
94
+ "x-public-key": apiKey,
95
+ },
96
+ body: JSON.stringify({
97
+ hits: [
98
+ {
99
+ bot: hit.bot,
100
+ bot_category: hit.botCategory,
101
+ purpose: hit.purpose,
102
+ path,
103
+ page_url: pageUrl,
104
+ user_agent: ua,
105
+ referer,
106
+ timestamp: new Date().toISOString(),
107
+ },
108
+ ],
109
+ }),
110
+ signal: AbortSignal.timeout(INGEST_TIMEOUT_MS),
111
+ });
112
+ } catch {
113
+ // Ingestion failure must NEVER bubble to the customer site.
114
+ // Next request will retry — losing a hit is acceptable.
115
+ }
116
+ });
117
+
118
+ return NextResponse.next();
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Run middlewares in sequence. The first one to return a non-`next()`
124
+ * response (redirect / rewrite / abort) short-circuits the chain.
125
+ *
126
+ * NOTE: this assumes each middleware in the chain only adds headers or
127
+ * passes through — it does NOT compose response bodies. Use cases:
128
+ * - smkingProxy (telemetry only) + customer auth proxy
129
+ * - smkingProxy + customer feature flag proxy
130
+ */
131
+ export function composeProxy(handlers: ProxyMiddleware[]): ProxyMiddleware {
132
+ return async (request: NextRequest) => {
133
+ for (const h of handlers) {
134
+ const result = await h(request);
135
+ if (!isPassThrough(result)) return result;
136
+ }
137
+ return NextResponse.next();
138
+ };
139
+ }
140
+
141
+ function isPassThrough(res: NextResponse): boolean {
142
+ // `NextResponse.next()` carries `x-middleware-next: 1` internally.
143
+ // Public surface: `res.headers.get('x-middleware-next')` returns "1"
144
+ // for a pass-through. Anything else (redirect, rewrite, body) is a
145
+ // terminal response.
146
+ return res.headers.get("x-middleware-next") === "1";
147
+ }
@@ -0,0 +1,126 @@
1
+ import { revalidateTag } from "next/cache";
2
+ import { Buffer } from "node:buffer";
3
+ import crypto from "node:crypto";
4
+
5
+ interface WebhookPayload {
6
+ kind?: string;
7
+ paths?: string[];
8
+ slugs?: string[];
9
+ deliveredAt?: string;
10
+ }
11
+
12
+ /**
13
+ * SmKing unified webhook handler (v0.11+).
14
+ *
15
+ * Drop-in install:
16
+ *
17
+ * ```ts
18
+ * // app/api/smking/webhook/route.ts
19
+ * export { POST } from "@soloworks/smking-next/webhook";
20
+ * ```
21
+ *
22
+ * Replaces v0.10's split handlers:
23
+ * - `/route` AEO, Bearer-authed (SMKING_WEBHOOK_TOKEN) — GONE
24
+ * - `/cms-webhook` CMS, HMAC-signed (SMKING_WEBHOOK_SECRET) — GONE
25
+ *
26
+ * Single HMAC-SHA256 endpoint. Payload `.kind` (`"aeo" | "cms_page" |
27
+ * future kinds`) dispatches which revalidate tag namespace to use:
28
+ *
29
+ * smking:{kind}:{identifier}
30
+ *
31
+ * Where identifier is either:
32
+ * - path (AEO: `["/products/foo"]`) — `smking:aeo:/products/foo`
33
+ * - slug (CMS: `["hello"]`) — `smking:cms_page:hello`
34
+ *
35
+ * Customer migration from v0.10:
36
+ * 1. env: SMKING_WEBHOOK_TOKEN → SMKING_WEBHOOK_SECRET (HMAC key)
37
+ * 2. route path: /api/smking-revalidate + /api/smking/webhook
38
+ * → /api/smking/webhook (single)
39
+ * 3. revalidateTag callers update tag prefix
40
+ *
41
+ * Auth: HMAC-SHA256 only. Bearer dropped — single auth model means one
42
+ * env var, one shim, fewer customer mis-config paths (the disambiguation
43
+ * problem the v0.10 dual-handler shipped with).
44
+ */
45
+ export async function POST(request: Request): Promise<Response> {
46
+ const secret = process.env.SMKING_WEBHOOK_SECRET;
47
+ if (!secret) {
48
+ return Response.json(
49
+ { error: "webhook_secret_missing" },
50
+ { status: 503 },
51
+ );
52
+ }
53
+
54
+ // Raw body for HMAC verify — re-encoding via JSON.parse + stringify
55
+ // would change byte order / spacing and invalidate the signature.
56
+ const rawBody = await request.text();
57
+ const providedSig = request.headers.get("x-smking-signature");
58
+ if (!verifySignature(rawBody, providedSig, secret)) {
59
+ return Response.json({ error: "invalid_signature" }, { status: 401 });
60
+ }
61
+
62
+ let payload: WebhookPayload;
63
+ try {
64
+ payload = JSON.parse(rawBody) as WebhookPayload;
65
+ } catch {
66
+ return Response.json({ error: "invalid_payload" }, { status: 400 });
67
+ }
68
+
69
+ const kind = payload.kind;
70
+ if (typeof kind !== "string" || kind.length === 0) {
71
+ // Forward-compat: SaaS may emit kinds we haven't taught the SDK
72
+ // yet — acknowledge (200) without action so SaaS doesn't retry.
73
+ return Response.json({ ok: true, note: "no_action_taken" });
74
+ }
75
+
76
+ let revalidated = 0;
77
+ let errors = 0;
78
+ for (const path of payload.paths ?? []) {
79
+ try {
80
+ revalidateTag(`smking:${kind}:${path}`, "default");
81
+ revalidated++;
82
+ } catch (err) {
83
+ errors++;
84
+ console.warn(
85
+ `[@soloworks/smking-next/webhook] revalidateTag failed for ${kind}/${path}:`,
86
+ err,
87
+ );
88
+ }
89
+ }
90
+ for (const slug of payload.slugs ?? []) {
91
+ try {
92
+ revalidateTag(`smking:${kind}:${slug}`, "default");
93
+ revalidated++;
94
+ } catch (err) {
95
+ errors++;
96
+ console.warn(
97
+ `[@soloworks/smking-next/webhook] revalidateTag failed for ${kind}/${slug}:`,
98
+ err,
99
+ );
100
+ }
101
+ }
102
+
103
+ return Response.json({ ok: true, kind, revalidated, errors });
104
+ }
105
+
106
+ function verifySignature(
107
+ rawBody: string,
108
+ signatureHeader: string | null,
109
+ secret: string,
110
+ ): boolean {
111
+ if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
112
+ const provided = signatureHeader.slice("sha256=".length);
113
+ const expected = crypto
114
+ .createHmac("sha256", secret)
115
+ .update(rawBody)
116
+ .digest("hex");
117
+ if (expected.length !== provided.length) return false;
118
+ try {
119
+ return crypto.timingSafeEqual(
120
+ Buffer.from(expected, "hex"),
121
+ Buffer.from(provided, "hex"),
122
+ );
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
package/src/types.ts CHANGED
@@ -56,16 +56,73 @@ export interface AeoResponse {
56
56
 
57
57
  export type CmsStatus = "ready" | "pending" | "not_found";
58
58
 
59
+ export type CmsContentType = "article" | "landing" | "listing";
60
+
61
+ /**
62
+ * v2 substrate block primitives. Mirrors `apps/web/src/features/cms/types.ts`
63
+ * on the SaaS side (kept in sync manually — when the v1 block catalog
64
+ * grows past 5 primitives, a shared workspace package replaces this
65
+ * duplication per `docs/superpowers/specs/2026-05-15-cms-dashboard-editor-design.md`
66
+ * section §11 + ai-cms-v2 §"擴充性").
67
+ */
68
+ export interface HeroProps {
69
+ title: string;
70
+ subtitle?: string;
71
+ image?: { url: string; alt: string };
72
+ cta?: { label: string; href: string };
73
+ }
74
+ export interface ArticleProps {
75
+ html: string;
76
+ }
77
+ export interface ModuleHeader {
78
+ heading?: string;
79
+ viewAll?: { label: string; href: string };
80
+ }
81
+ export type NavLayout = "grid" | "list" | "carousel";
82
+ export interface NavRecentPostsProps extends ModuleHeader {
83
+ limit: number;
84
+ layout: NavLayout;
85
+ contentType?: "article" | "all";
86
+ }
87
+ export interface NavTaxonomyListProps extends ModuleHeader {
88
+ /** category-by-path: slug prefix; tag: taxonomies.slug */
89
+ source: "category-by-path" | "tag";
90
+ path: string;
91
+ limit: number;
92
+ layout: NavLayout;
93
+ }
94
+ export interface NavSearchProps {
95
+ placeholder: string;
96
+ contentType?: "article" | "all";
97
+ }
98
+
99
+ export type Block =
100
+ | { component: "hero"; id: string; props: HeroProps }
101
+ | { component: "article"; id: string; props: ArticleProps }
102
+ | {
103
+ component: "nav-recent-posts";
104
+ id: string;
105
+ props: NavRecentPostsProps;
106
+ }
107
+ | {
108
+ component: "nav-taxonomy-list";
109
+ id: string;
110
+ props: NavTaxonomyListProps;
111
+ }
112
+ | { component: "nav-search"; id: string; props: NavSearchProps };
113
+
59
114
  /**
60
- * A single published CMS page returned by the smking public API.
61
- * `body` is the raw Tiptap ProseMirror JSON document the user authored
62
- * in /write — render it via the SmkingCms server component (which
63
- * wraps @tiptap/static-renderer/pm/react with our extension list).
115
+ * A single published CMS page returned by the smking public API
116
+ * (substrate v2). Render via `<SmkingCms>` server component which
117
+ * dispatches each block by component type.
64
118
  */
65
119
  export interface CmsPage {
66
120
  slug: string;
67
121
  title: string;
68
- body: Record<string, unknown>;
122
+ contentType: CmsContentType;
123
+ blocks: Block[];
124
+ excerpt?: string | null;
125
+ featuredImageUrl?: string | null;
69
126
  publishedAt: string | null;
70
127
  }
71
128
 
@@ -1,32 +0,0 @@
1
- import { Node } from "@tiptap/core";
2
-
3
- /**
4
- * Schema-only Gallery extension for the static renderer.
5
- *
6
- * The full SaaS-side `GalleryNode` (apps/web/src/components/tiptap-node/
7
- * gallery-node/gallery-node-extension.ts) ships with addNodeView,
8
- * addCommands, parseHTML — everything Tiptap needs in the editor. For
9
- * read-only static rendering we only need the SCHEMA (name, group,
10
- * atom, attrs) so `@tiptap/static-renderer` recognises the "gallery"
11
- * node type when walking the JSON; the actual rendering is handled by
12
- * the GalleryNodeView passed via `nodeMapping`.
13
- *
14
- * Keep attrs in sync with the SaaS schema and the Laravel PHP node
15
- * (packages/smking-laravel/src/Tiptap/Nodes/Gallery.php). All three
16
- * must agree on field names + defaults.
17
- */
18
- export const Gallery = Node.create({
19
- name: "gallery",
20
- group: "block",
21
- atom: true,
22
- draggable: true,
23
- selectable: true,
24
-
25
- addAttributes() {
26
- return {
27
- images: { default: [] },
28
- layout: { default: "grid" },
29
- columns: { default: 3 },
30
- };
31
- },
32
- });
@@ -1,68 +0,0 @@
1
- /**
2
- * SmKing Gallery — read-only React render for static-renderer's
3
- * nodeMapping.
4
- *
5
- * Markup MUST stay byte-equal to:
6
- * - apps/web/src/components/tiptap-node/gallery-node/gallery-node-
7
- * extension.ts (SaaS authoring → preview)
8
- * - packages/smking-laravel/src/Tiptap/Nodes/Gallery.php (Laravel
9
- * SDK PHP renderer)
10
- *
11
- * If you change ANY attribute / class / nesting here, change it in the
12
- * other two and bump version on all three SDKs together. Customer site
13
- * CSS targets `.smk-gallery`, `.smk-gallery--{layout}`,
14
- * `.smk-gallery__item` — those names are public API.
15
- */
16
-
17
- interface GalleryImage {
18
- url: string;
19
- alt?: string;
20
- caption?: string;
21
- }
22
-
23
- interface GalleryNodeAttrs {
24
- images?: GalleryImage[];
25
- layout?: string;
26
- columns?: number;
27
- }
28
-
29
- /**
30
- * Shape matches what `@tiptap/static-renderer/pm/react`'s nodeMapping
31
- * passes: `{ node: ProseMirror Node }` whose `attrs` are typed via the
32
- * Gallery extension. We narrow loosely here — the SaaS sometimes ships
33
- * stringly-typed attrs (jsonb round-trips lose number type for columns).
34
- */
35
- export function GalleryNodeView({
36
- node,
37
- }: {
38
- node: { attrs?: GalleryNodeAttrs };
39
- }) {
40
- const attrs = node.attrs ?? {};
41
- const images = Array.isArray(attrs.images) ? attrs.images : [];
42
- const layout = typeof attrs.layout === "string" ? attrs.layout : "grid";
43
- const columnsRaw = attrs.columns;
44
- const columns =
45
- typeof columnsRaw === "number" && columnsRaw > 0
46
- ? columnsRaw
47
- : typeof columnsRaw === "string" && Number(columnsRaw) > 0
48
- ? Number(columnsRaw)
49
- : 3;
50
-
51
- return (
52
- <div
53
- data-type="gallery"
54
- data-layout={layout}
55
- data-columns={String(columns)}
56
- className={`smk-gallery smk-gallery--${layout}`}
57
- style={{ "--smk-gallery-cols": columns } as React.CSSProperties}
58
- >
59
- {images.map((img, i) => (
60
- <figure key={i} className="smk-gallery__item">
61
- {/* eslint-disable-next-line @next/next/no-img-element */}
62
- <img src={img.url} alt={img.alt ?? ""} loading="lazy" />
63
- {img.caption && <figcaption>{img.caption}</figcaption>}
64
- </figure>
65
- ))}
66
- </div>
67
- );
68
- }
@@ -1,107 +0,0 @@
1
- import { revalidateTag } from "next/cache";
2
- import { Buffer } from "node:buffer";
3
- import crypto from "node:crypto";
4
-
5
- /**
6
- * SmKing CMS publish webhook handler for Next.js.
7
- *
8
- * Drop-in install:
9
- *
10
- * ```ts
11
- * // app/api/smking/webhook/route.ts
12
- * export { POST } from "@soloworks/smking-next/cms-webhook";
13
- * ```
14
- *
15
- * Then set `SMKING_WEBHOOK_SECRET` in your env and paste this route's
16
- * full URL (e.g. `https://your-site.com/api/smking/webhook`) into the
17
- * SmKing dashboard's site settings webhook field.
18
- *
19
- * On a verified `cms.page.published` event the handler calls
20
- * `revalidateTag("smking:cms:<slug>", "default")` — invalidates the
21
- * cached `getCmsPage` fetch tagged with that slug so the next page
22
- * render reads fresh content from SaaS.
23
- *
24
- * Wire shape matches @smking-saas/features/cms/lib/webhook.ts and the
25
- * Laravel SDK WebhookController:
26
- *
27
- * POST /api/smking/webhook
28
- * X-Smking-Signature: sha256=<hex>
29
- * X-Smking-Event: cms.page.published
30
- * { event, siteId, slug, publishedAt, deliveredAt }
31
- *
32
- * HMAC-SHA256 sig verification runs constant-time via `timingSafeEqual`
33
- * to prevent secret-extraction via response latency. Unknown event
34
- * types accept (200) without action so SaaS doesn't retry — forward-
35
- * compat for future event types (cms.page.unpublished / deleted).
36
- */
37
- export async function POST(request: Request): Promise<Response> {
38
- const secret = process.env.SMKING_WEBHOOK_SECRET;
39
- if (!secret) {
40
- return Response.json(
41
- { error: "webhook_secret_missing" },
42
- { status: 503 },
43
- );
44
- }
45
-
46
- // Raw body for HMAC verify — re-encoding via JSON.parse + stringify
47
- // would change byte order / spacing and invalidate the signature.
48
- const rawBody = await request.text();
49
- const providedSig = request.headers.get("x-smking-signature");
50
- if (!verifySignature(rawBody, providedSig, secret)) {
51
- return Response.json({ error: "invalid_signature" }, { status: 401 });
52
- }
53
-
54
- let payload: { event?: string; slug?: string };
55
- try {
56
- payload = JSON.parse(rawBody) as { event?: string; slug?: string };
57
- } catch {
58
- return Response.json({ error: "invalid_payload" }, { status: 400 });
59
- }
60
-
61
- if (
62
- payload.event !== "cms.page.published" ||
63
- typeof payload.slug !== "string" ||
64
- payload.slug.length === 0
65
- ) {
66
- // Forward-compat: unknown event types ack-without-action so SaaS
67
- // doesn't retry. Future event types (cms.page.unpublished) branch
68
- // here without breaking older customer SDKs.
69
- return Response.json({ ok: true, note: "no_action_taken" });
70
- }
71
-
72
- try {
73
- // Next.js 16 requires explicit cache profile; "default" matches the
74
- // profile a normal `'use cache'` block uses.
75
- revalidateTag(`smking:cms:${payload.slug}`, "default");
76
- } catch (err) {
77
- console.warn(
78
- `[@soloworks/smking-next/cms-webhook] revalidateTag failed for slug "${payload.slug}":`,
79
- err,
80
- );
81
- return Response.json({ error: "revalidate_failed" }, { status: 500 });
82
- }
83
-
84
- return Response.json({ ok: true, evicted: payload.slug });
85
- }
86
-
87
- function verifySignature(
88
- rawBody: string,
89
- signatureHeader: string | null,
90
- secret: string,
91
- ): boolean {
92
- if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
93
- const provided = signatureHeader.slice("sha256=".length);
94
- const expected = crypto
95
- .createHmac("sha256", secret)
96
- .update(rawBody)
97
- .digest("hex");
98
- if (expected.length !== provided.length) return false;
99
- try {
100
- return crypto.timingSafeEqual(
101
- Buffer.from(expected, "hex"),
102
- Buffer.from(provided, "hex"),
103
- );
104
- } catch {
105
- return false;
106
- }
107
- }
package/src/route.ts DELETED
@@ -1,112 +0,0 @@
1
- import { revalidateTag } from "next/cache";
2
- import { Buffer } from "node:buffer";
3
- import crypto from "node:crypto";
4
-
5
- interface WebhookPayload {
6
- paths?: string[];
7
- }
8
-
9
- const BEARER_PREFIX = "Bearer ";
10
-
11
- /**
12
- * Webhook handler for SaaS-pushed cache invalidation. Drop-in install:
13
- *
14
- * ```ts
15
- * // app/api/smking-revalidate/route.ts
16
- * export { POST, GET } from '@soloworks/smking-next/route';
17
- * ```
18
- *
19
- * Then set `SMKING_WEBHOOK_TOKEN` in your environment and configure the
20
- * SaaS to POST `{ paths: [...] }` with `Authorization: Bearer <token>`.
21
- *
22
- * Bearer-token auth (not HMAC body signing) — sufficient for v1.
23
- * HMAC-signed body verification can layer on later if attack surface
24
- * justifies it. Token compare runs through `crypto.timingSafeEqual` to
25
- * avoid leaking length / prefix info via response time.
26
- */
27
- export async function POST(request: Request): Promise<Response> {
28
- const expected = process.env.SMKING_WEBHOOK_TOKEN;
29
- if (!expected) {
30
- return Response.json(
31
- { error: "webhook_not_configured" },
32
- { status: 503 },
33
- );
34
- }
35
-
36
- const auth = request.headers.get("authorization");
37
- if (!auth || !verifyBearer(auth, expected)) {
38
- return Response.json({ error: "unauthorized" }, { status: 401 });
39
- }
40
-
41
- let payload: WebhookPayload;
42
- try {
43
- payload = (await request.json()) as WebhookPayload;
44
- } catch {
45
- return Response.json({ error: "invalid_json" }, { status: 400 });
46
- }
47
-
48
- let revalidated = 0;
49
- let errors = 0;
50
- for (const p of payload.paths ?? []) {
51
- try {
52
- // Next.js 16 requires a cache profile; 'default' matches the
53
- // implicit profile a normal `'use cache'` block uses.
54
- revalidateTag(`smking:path:${p}`, "default");
55
- revalidated++;
56
- } catch (err) {
57
- errors++;
58
- console.warn(
59
- `[@soloworks/smking-next] revalidateTag failed for path "${p}":`,
60
- err,
61
- );
62
- }
63
- }
64
-
65
- return Response.json({ revalidated, errors });
66
- }
67
-
68
- /**
69
- * Liveness probe — POST-only public endpoint, but `GET` returns 401 with
70
- * a `WWW-Authenticate` header so customers can verify the route is wired
71
- * up by sending a token. Discourages anonymous probes from confirming
72
- * the endpoint exists.
73
- */
74
- export async function GET(request: Request): Promise<Response> {
75
- const expected = process.env.SMKING_WEBHOOK_TOKEN;
76
- if (!expected) {
77
- return Response.json(
78
- { error: "webhook_not_configured" },
79
- { status: 503 },
80
- );
81
- }
82
- const auth = request.headers.get("authorization");
83
- if (auth && verifyBearer(auth, expected)) {
84
- return new Response("OK", { status: 200 });
85
- }
86
- return new Response("Unauthorized", {
87
- status: 401,
88
- headers: { "WWW-Authenticate": 'Bearer realm="smking-webhook"' },
89
- });
90
- }
91
-
92
- /**
93
- * Constant-time bearer-token compare. Returns false on:
94
- * - missing or non-Bearer header
95
- * - presented token has different length than expected (early-out;
96
- * the lengths themselves leak nothing because the secret is set by
97
- * the customer with a known length)
98
- * - byte-different but same-length token
99
- */
100
- function verifyBearer(authHeader: string, expected: string): boolean {
101
- if (!authHeader.startsWith(BEARER_PREFIX)) return false;
102
- const presented = authHeader.slice(BEARER_PREFIX.length);
103
- if (presented.length !== expected.length) return false;
104
- try {
105
- return crypto.timingSafeEqual(
106
- Buffer.from(presented),
107
- Buffer.from(expected),
108
- );
109
- } catch {
110
- return false;
111
- }
112
- }