@soloworks/smking-next 0.22.2 → 0.22.3-beta.2

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,17 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.22.3-beta.2 — 2026-10-01
4
+
5
+ - Optional CMS v2 reads now use the Next.js data cache. A verified page remains
6
+ visible when source revalidation fails; a confirmed signed withdrawal expires
7
+ the data tag immediately. Strict publication evidence and explicit source
8
+ denial prevent proxy errors from masquerading as withdrawal or revocation.
9
+ Never-published slugs with a valid v2 source envelope still return 404.
10
+ - The default remains v1. A fresh deployment has no previous cache, and AEO
11
+ and site files do not yet have the same v2 failure protection. Do not enable
12
+ whole-site v2 solely because this beta is installed.
13
+ - Preserve the `smking-next` command-line installer in the npm package.
14
+
3
15
  ## 0.22.2 — 2026-07-26
4
16
 
5
17
  - `<SmkingCms>` now calls Next.js `notFound()` when Page Zero explicitly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.22.2",
3
+ "version": "0.22.3-beta.2",
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",
@@ -65,31 +65,32 @@
65
65
  "perplexity",
66
66
  "smking"
67
67
  ],
68
+ "scripts": {
69
+ "typecheck": "tsc --noEmit",
70
+ "test": "vitest run",
71
+ "test:cms-v2-runtime": "node scripts/test-cms-v2-runtime.mjs",
72
+ "test:watch": "vitest"
73
+ },
68
74
  "peerDependencies": {
69
75
  "next": "^15.0.0 || ^16.0.0",
70
76
  "react": "^18.0.0 || ^19.0.0"
71
77
  },
72
78
  "devDependencies": {
73
- "@testing-library/jest-dom": "^6.9.1",
79
+ "@testing-library/jest-dom": "^7.0.0",
74
80
  "@testing-library/react": "^16.3.2",
75
- "@tiptap/core": "^3.23.4",
76
- "@tiptap/extension-image": "^3.23.4",
77
- "@tiptap/extension-link": "^3.23.4",
78
- "@tiptap/starter-kit": "^3.23.4",
79
- "@tiptap/static-renderer": "^3.23.4",
80
- "@types/react": "^19.0.0",
81
- "@types/react-dom": "^19",
82
- "@vitejs/plugin-react": "^6.0.1",
83
- "jsdom": "^29.1.0",
84
- "next": "^16.2.2",
85
- "react": "^19.0.0",
86
- "react-dom": "19.2.4",
87
- "typescript": "^5",
88
- "vitest": "^4.1.5"
89
- },
90
- "scripts": {
91
- "typecheck": "tsc --noEmit",
92
- "test": "vitest run",
93
- "test:watch": "vitest"
81
+ "@tiptap/core": "^3.29.2",
82
+ "@tiptap/extension-image": "^3.29.2",
83
+ "@tiptap/extension-link": "^3.29.2",
84
+ "@tiptap/starter-kit": "^3.29.2",
85
+ "@tiptap/static-renderer": "^3.29.2",
86
+ "@types/react": "^19.2.18",
87
+ "@types/react-dom": "^19.2.4",
88
+ "@vitejs/plugin-react": "^6.0.5",
89
+ "jsdom": "^30.0.1",
90
+ "next": "^16.2.12",
91
+ "react": "^19.2.8",
92
+ "react-dom": "19.2.8",
93
+ "typescript": "^6.0.3",
94
+ "vitest": "^4.1.10"
94
95
  }
95
- }
96
+ }
package/src/cms-blocks.ts CHANGED
@@ -365,6 +365,7 @@ export interface CollageProps {
365
365
  }
366
366
 
367
367
  export type SocialSharePlatform =
368
+ | "x"
368
369
  | "threads"
369
370
  | "instagram"
370
371
  | "facebook"
@@ -372,8 +373,8 @@ export type SocialSharePlatform =
372
373
  | "copy";
373
374
 
374
375
  /**
375
- * `social-share` block — Threads / Instagram / Facebook / LINE share links +
376
- * copy-link.
376
+ * `social-share` block — X / Threads / Instagram / Facebook / LINE share
377
+ * links + copy-link.
377
378
  * Platform visibility is site-wide; `shareUrl` is the optional no-JS fallback
378
379
  * target (the `<smking-share>` web component overrides every link from
379
380
  * window.location at runtime, so the live share always points at the real
package/src/lib/client.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  // Type-only import loads Next.js's RequestInit augmentation so the
2
2
  // `next: { revalidate, tags }` property on fetch options typechecks.
3
3
  import type {} from "next";
4
+ import { connection } from "next/server";
4
5
 
5
6
  import type { AeoResponse, DiscoverParams } from "../types";
7
+ import { usesPublishedV2 } from "./delivery-v2";
8
+ import { reportObservedPath } from "./report-v2";
9
+ import { isPublishedReady } from "./published-v2";
6
10
  import { isPathWithinPrefix, normalizePath, resolveRequestPath } from "./path";
7
11
  import { SDK_VERSION } from "./version";
8
12
 
@@ -81,13 +85,14 @@ export async function getAeoContent(
81
85
 
82
86
  let path = params.path;
83
87
  let url = params.url;
88
+ const publishedV2 = usesPublishedV2();
84
89
  if (!path || !url) {
85
90
  try {
86
91
  const resolved = await resolveRequestPath();
87
92
  path = path ?? resolved.path;
88
93
  url = url ?? resolved.url;
89
94
  } catch {
90
- return null;
95
+ if (!publishedV2 || !path) return null;
91
96
  }
92
97
  }
93
98
  path = normalizePath(path);
@@ -95,7 +100,30 @@ export async function getAeoContent(
95
100
  return null;
96
101
  }
97
102
 
103
+ // A v2 withdrawal cannot wait for a pre-rendered page or an ISR refresh.
104
+ // If the host cannot render this path dynamically, fail visibly at build
105
+ // instead of producing a success-shaped empty or stale page.
106
+ if (publishedV2) await connection();
107
+
98
108
  try {
109
+ if (publishedV2) {
110
+ const query = new URLSearchParams({ key: params.apiKey, path });
111
+ // A supplied URL can also be used while prerendering. Only a real
112
+ // request-scoped path is an observation of a visitor request.
113
+ try {
114
+ const current = await resolveRequestPath();
115
+ if (normalizePath(current.path) === path) {
116
+ reportObservedPath({ apiKey: params.apiKey, baseUrl, path, url: current.url });
117
+ }
118
+ } catch { /* Static rendering has no request observation. */ }
119
+ const res = await fetch(`${baseUrl}/api/v2/public/aeo?${query}`, {
120
+ cache: "no-store",
121
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
122
+ });
123
+ if (!res.ok) return null;
124
+ const body = (await res.json()) as AeoResponse;
125
+ return isPublishedReady(body, "aeo", `path:${path}`) ? body : null;
126
+ }
99
127
  const res = await fetch(`${baseUrl}/api/v1/public/aeo`, {
100
128
  method: "POST",
101
129
  headers: { "content-type": "application/json" },
@@ -1,8 +1,13 @@
1
1
  // Type-only import loads Next.js's RequestInit augmentation so the
2
2
  // `next: { revalidate, tags }` property on fetch options typechecks.
3
3
  import type {} from "next";
4
+ import { unstable_cache } from "next/cache";
5
+ import { connection } from "next/server";
4
6
 
5
7
  import type { CmsParams, CmsResponse } from "../types";
8
+ import { cmsV2CacheTag, usesPublishedV2 } from "./delivery-v2";
9
+ import { reportObservedPath } from "./report-v2";
10
+ import { isPublishedAbsent, isPublishedReady, isPublishedWithdrawal, publishedCmsDenial } from "./published-v2";
6
11
  import { resolveRequestPath } from "./path";
7
12
  import { SDK_VERSION } from "./version";
8
13
 
@@ -15,6 +20,36 @@ import { SDK_VERSION } from "./version";
15
20
  const DEFAULT_REVALIDATE_SECONDS = 300;
16
21
  const FETCH_TIMEOUT_MS = 2000;
17
22
 
23
+ type PublishedCmsResult = CmsResponse | { status: "access_denied"; scope: "site" | "cms" };
24
+
25
+ async function fetchPublishedCms(baseUrl: string, apiKey: string, slug: string): Promise<PublishedCmsResult> {
26
+ const query = new URLSearchParams({ key: apiKey, slug });
27
+ const res = await fetch(`${baseUrl}/api/v2/public/cms-page?${query}`, {
28
+ cache: "no-store",
29
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
30
+ });
31
+ if (res.status === 401 || res.status === 403) {
32
+ const type = res.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
33
+ const length = Number(res.headers.get("content-length") ?? 0);
34
+ if (type === "application/json" && length <= 8192) {
35
+ const raw = await res.text();
36
+ if (raw.length <= 8192) {
37
+ try {
38
+ const scope = publishedCmsDenial(JSON.parse(raw), res.status);
39
+ if (scope) return { status: "access_denied", scope };
40
+ } catch { /* A proxy failure is not proof of lost authority. */ }
41
+ }
42
+ }
43
+ throw new Error("smking_v2_cms_unavailable");
44
+ }
45
+ if (res.status !== 200 && res.status !== 404) throw new Error("smking_v2_cms_unavailable");
46
+ const body = (await res.json()) as CmsResponse;
47
+ const identifier = `slug:${slug}`;
48
+ if (res.status === 404 && (isPublishedWithdrawal(body, "cms-page", identifier) || isPublishedAbsent(body))) return body;
49
+ if (res.status === 200 && isPublishedReady(body, "cms-page", identifier)) return body;
50
+ throw new Error("smking_v2_cms_unavailable");
51
+ }
52
+
18
53
  const _warnedKeys = new Set<string>();
19
54
  function warnOnce(key: string, message: string): void {
20
55
  if (process.env.NODE_ENV === "production") return;
@@ -26,8 +61,10 @@ function warnOnce(key: string, message: string): void {
26
61
  /**
27
62
  * Fetch a published CMS page from the smking public API.
28
63
  *
29
- * Mirrors `getAeoContent` shape — same fail-open posture, same Next.js
30
- * data-cache integration. Returns null when:
64
+ * v1 mirrors `getAeoContent`'s fail-open posture and data-cache integration.
65
+ * v2 preserves explicit 404 and throws on source failure, so a Blog page
66
+ * never renders an empty successful response for a delivery failure.
67
+ * v1 returns null when:
31
68
  * - apiKey or baseUrl missing (one-time dev warning)
32
69
  * - network failure / 2s timeout
33
70
  * - 4xx / 5xx response
@@ -43,7 +80,9 @@ function warnOnce(key: string, message: string): void {
43
80
  export async function getCmsPage(
44
81
  params: CmsParams,
45
82
  ): Promise<CmsResponse | null> {
83
+ const publishedV2 = usesPublishedV2() && !params.previewToken;
46
84
  if (!params.apiKey) {
85
+ if (publishedV2) throw new Error("smking_v2_cms_unavailable");
47
86
  warnOnce(
48
87
  "missing-api-key",
49
88
  "[@soloworks/smking-next/cms] apiKey is empty — skipping CMS render. Set SMKING_API_KEY or pass apiKey prop.",
@@ -56,6 +95,7 @@ export async function getCmsPage(
56
95
  "",
57
96
  );
58
97
  if (!baseUrl) {
98
+ if (publishedV2) throw new Error("smking_v2_cms_unavailable");
59
99
  warnOnce(
60
100
  "missing-base-url",
61
101
  "[@soloworks/smking-next/cms] SMKING_BASE_URL is not configured — skipping CMS render. Set the env var or pass baseUrl prop.",
@@ -67,6 +107,7 @@ export async function getCmsPage(
67
107
  // `/blog`. Only reject `undefined` / `null` — empty string is valid
68
108
  // and routes to the site's root page row in `productContent`.
69
109
  if (params.slug === undefined || params.slug === null) {
110
+ if (publishedV2) throw new Error("smking_v2_cms_unavailable");
70
111
  warnOnce(
71
112
  "missing-slug",
72
113
  "[@soloworks/smking-next/cms] slug prop is required (use \"\" for the root page).",
@@ -74,6 +115,28 @@ export async function getCmsPage(
74
115
  return null;
75
116
  }
76
117
 
118
+ // Public v2 is intentionally read-only. Draft preview keeps its separate,
119
+ // uncached v1 contract; neither request reports page URLs as a side effect.
120
+ if (publishedV2) {
121
+ await connection();
122
+ try {
123
+ try {
124
+ const current = await resolveRequestPath();
125
+ if (!params.path || params.path === current.path) {
126
+ reportObservedPath({ apiKey: params.apiKey, baseUrl, path: current.path, url: current.url });
127
+ }
128
+ } catch { /* Static rendering has no request observation. */ }
129
+ const result = await unstable_cache(fetchPublishedCms, ["smking:cms-page:v2"], {
130
+ revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
131
+ tags: [cmsV2CacheTag(params.slug)],
132
+ })(baseUrl, params.apiKey, params.slug);
133
+ if (result.status === "access_denied") throw new Error("smking_v2_cms_unavailable");
134
+ return result;
135
+ } catch {
136
+ throw new Error("smking_v2_cms_unavailable");
137
+ }
138
+ }
139
+
77
140
  let requestPath = params.path;
78
141
  let requestUrl = params.url;
79
142
  if (!requestPath || !requestUrl) {
@@ -0,0 +1,238 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { revalidatePath, revalidateTag } from "next/cache";
3
+ import { cmsV2CacheTag, usesPublishedV2 } from "./delivery-v2";
4
+
5
+ type Resource = "aeo" | "cms-page" | "site-file";
6
+ type Action = "update" | "withdraw";
7
+ type Target = {
8
+ resource: Resource;
9
+ identifier: string;
10
+ action: Action;
11
+ revision: number;
12
+ generation: number;
13
+ withdrawalRevision: number;
14
+ contentVersion: string | null;
15
+ };
16
+ type Envelope = {
17
+ kind: "content_delivery_probe_v2" | "content_delivery_v2";
18
+ contract: "2";
19
+ deliveryId: string;
20
+ deliveredAt: string;
21
+ sourceUrl: string;
22
+ scope: string;
23
+ keyFingerprint: string;
24
+ };
25
+ type Probe = Envelope & { kind: "content_delivery_probe_v2"; resources: Resource[] };
26
+ type Notification = Envelope & { kind: "content_delivery_v2"; targets: Target[] };
27
+ type Configuration = {
28
+ sourceUrl: string;
29
+ apiKey: string;
30
+ scope: string;
31
+ keyFingerprint: string;
32
+ resources: Set<Resource>;
33
+ cmsPath: string | null;
34
+ };
35
+
36
+ const MAX_BODY_BYTES = 65_536;
37
+ const MAX_SOURCE_BYTES = 2_097_152;
38
+ const VERSION = /^sha256:[a-f0-9]{64}$/;
39
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
40
+ const RESOURCES = new Set<Resource>(["aeo", "cms-page", "site-file"]);
41
+
42
+ function record(value: unknown): value is Record<string, unknown> {
43
+ return value !== null && typeof value === "object" && !Array.isArray(value);
44
+ }
45
+
46
+ function keys(value: Record<string, unknown>, expected: string[]): boolean {
47
+ return Object.keys(value).sort().join("\n") === expected.sort().join("\n");
48
+ }
49
+
50
+ function safePath(path: string): boolean {
51
+ if (!path.startsWith("/") || path.startsWith("//") || path.length > 500
52
+ || /[?#\\\x00-\x1f\x7f]/.test(path) || /%(2f|5c)/i.test(path)) return false;
53
+ try {
54
+ let decoded = path;
55
+ for (let i = 0; i < 3; i++) {
56
+ const next = decodeURIComponent(decoded);
57
+ if (next === decoded) break;
58
+ decoded = next;
59
+ }
60
+ return !/%[a-f0-9]{2}/i.test(decoded) && !decoded.startsWith("//")
61
+ && !/[?#\\\x00-\x1f\x7f]/.test(decoded)
62
+ && !decoded.split("/").some((part) => part === "." || part === "..");
63
+ } catch { return false; }
64
+ }
65
+
66
+ function validTarget(value: unknown): value is Target {
67
+ if (!record(value) || !keys(value, ["resource", "identifier", "action", "revision", "generation", "withdrawalRevision", "contentVersion"])) return false;
68
+ if (!RESOURCES.has(value.resource as Resource) || typeof value.identifier !== "string" || value.identifier.length > 512) return false;
69
+ const identifier = value.identifier;
70
+ if (value.resource === "aeo" && (!identifier.startsWith("path:") || !safePath(identifier.slice(5)))) return false;
71
+ if (value.resource === "cms-page") {
72
+ const slug = identifier.slice(5);
73
+ if (!identifier.startsWith("slug:") || slug.length > 200 || slug.startsWith("/")
74
+ || slug.endsWith("/") || !safePath(`/${slug}`)) return false;
75
+ }
76
+ if (value.resource === "site-file" && !["kind:sitemap", "kind:robots", "kind:llms_txt"].includes(identifier)) return false;
77
+ if (value.action !== "update" && value.action !== "withdraw") return false;
78
+ for (const field of ["revision", "generation", "withdrawalRevision"] as const) {
79
+ if (!Number.isSafeInteger(value[field]) || Number(value[field]) < 0 || Number(value[field]) > 2_147_483_647) return false;
80
+ }
81
+ if (Number(value.revision) < 1 || value.generation !== value.revision) return false;
82
+ return value.action === "withdraw"
83
+ ? value.withdrawalRevision === value.revision && value.contentVersion === null
84
+ : Number(value.withdrawalRevision) <= Number(value.revision)
85
+ && typeof value.contentVersion === "string" && VERSION.test(value.contentVersion);
86
+ }
87
+
88
+ function parse(value: unknown, bodyBytes: number): Probe | Notification | null {
89
+ if (bodyBytes < 1 || bodyBytes > MAX_BODY_BYTES || !record(value)) return null;
90
+ const probe = value.kind === "content_delivery_probe_v2";
91
+ if (!probe && value.kind !== "content_delivery_v2") return null;
92
+ if (!keys(value, ["kind", "contract", "deliveryId", "deliveredAt", "sourceUrl", "scope", "keyFingerprint", probe ? "resources" : "targets"])) return null;
93
+ if (value.contract !== "2" || typeof value.deliveryId !== "string" || !UUID.test(value.deliveryId)
94
+ || typeof value.deliveredAt !== "string" || typeof value.sourceUrl !== "string"
95
+ || typeof value.scope !== "string" || typeof value.keyFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(value.keyFingerprint)) return null;
96
+ if (probe) {
97
+ if (!Array.isArray(value.resources) || value.resources.length < 1 || value.resources.length > 3
98
+ || new Set(value.resources).size !== value.resources.length
99
+ || !value.resources.every((item) => RESOURCES.has(item))) return null;
100
+ return value as Probe;
101
+ }
102
+ if (!Array.isArray(value.targets) || value.targets.length < 1 || value.targets.length > 16
103
+ || !value.targets.every(validTarget)
104
+ || new Set(value.targets.map((item: Target) => `${item.resource}\n${item.identifier}`)).size !== value.targets.length) return null;
105
+ return value as Notification;
106
+ }
107
+
108
+ function strictSource(input: string | undefined): string | null {
109
+ if (!input) return null;
110
+ try {
111
+ const url = new URL(input);
112
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !["", "/"].includes(url.pathname)) return null;
113
+ return url.origin;
114
+ } catch { return null; }
115
+ }
116
+
117
+ function configuration(): Configuration | null {
118
+ if (!usesPublishedV2()) return null;
119
+ const sourceUrl = strictSource(process.env.SMKING_BASE_URL);
120
+ const apiKey = process.env.SMKING_API_KEY;
121
+ const scope = process.env.SMKING_DELIVERY_NOTIFICATIONS_SCOPE;
122
+ const selected = process.env.SMKING_DELIVERY_NOTIFICATION_RESOURCES?.split(",");
123
+ if (!sourceUrl || !apiKey || !/^pk_[A-Za-z0-9_-]+$/.test(apiKey) || !scope
124
+ || !selected || selected.length < 1 || new Set(selected).size !== selected.length
125
+ || !selected.every((item) => RESOURCES.has(item as Resource))) return null;
126
+ const cmsPath = process.env.SMKING_CMS_PATH === "/"
127
+ ? "/" : process.env.SMKING_CMS_PATH?.replace(/\/+$/, "") || null;
128
+ if (selected.includes("cms-page") && (!cmsPath || !safePath(cmsPath))) return null;
129
+ return { sourceUrl, apiKey, scope, resources: new Set(selected as Resource[]), cmsPath,
130
+ keyFingerprint: createHash("sha256").update(apiKey).digest("hex") };
131
+ }
132
+
133
+ function equalDigest(left: string, right: string): boolean {
134
+ return left.length === right.length && timingSafeEqual(Buffer.from(left), Buffer.from(right));
135
+ }
136
+
137
+ function targetPath(target: Target, config: Configuration): string | null {
138
+ if (target.resource === "aeo") return target.identifier.slice(5);
139
+ if (target.resource === "cms-page") {
140
+ if (!config.cmsPath) return null;
141
+ const slug = target.identifier.slice(5);
142
+ return slug ? `${config.cmsPath === "/" ? "" : config.cmsPath}/${slug}` : config.cmsPath;
143
+ }
144
+ return ({ "kind:sitemap": "/sitemap.xml", "kind:robots": "/robots.txt", "kind:llms_txt": "/llms.txt" } as Record<string, string>)[target.identifier] ?? null;
145
+ }
146
+
147
+ function sourceUrl(target: Target, config: Configuration): string {
148
+ const query = new URLSearchParams({ key: config.apiKey });
149
+ if (target.resource === "aeo") query.set("path", target.identifier.slice(5));
150
+ if (target.resource === "cms-page") query.set("slug", target.identifier.slice(5));
151
+ if (target.resource === "site-file") query.set("kind", target.identifier.slice(5));
152
+ return `${config.sourceUrl}/api/v2/public/${target.resource}?${query}`;
153
+ }
154
+
155
+ async function boundedJson(response: Response): Promise<unknown> {
156
+ const declared = response.headers.get("content-length");
157
+ if (declared && (!/^\d+$/.test(declared) || Number(declared) > MAX_SOURCE_BYTES)) throw new Error("source_too_large");
158
+ const reader = response.body?.getReader();
159
+ if (!reader) throw new Error("source_empty");
160
+ const chunks: Uint8Array[] = [];
161
+ let bytes = 0;
162
+ try {
163
+ while (true) {
164
+ const { done, value } = await reader.read();
165
+ if (done) break;
166
+ bytes += value.byteLength;
167
+ if (bytes > MAX_SOURCE_BYTES) throw new Error("source_too_large");
168
+ chunks.push(value);
169
+ }
170
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
171
+ } finally {
172
+ await reader.cancel().catch(() => undefined);
173
+ reader.releaseLock();
174
+ }
175
+ }
176
+
177
+ /** The published source is the ordering authority; no per-instance Map is trusted. */
178
+ async function sourceState(target: Target, config: Configuration): Promise<"match" | "obsolete" | null> {
179
+ try {
180
+ const response = await fetch(sourceUrl(target, config), {
181
+ method: "GET", cache: "no-store", credentials: "omit", redirect: "error",
182
+ signal: AbortSignal.timeout(2_000),
183
+ });
184
+ if (response.status !== 200 && response.status !== 404) return null;
185
+ const body = await boundedJson(response);
186
+ if (!record(body) || !record(body.delivery) || body.delivery.contract !== "2"
187
+ || !record(body.delivery.publication)) return null;
188
+ const publication = body.delivery.publication;
189
+ if (publication.resource !== target.resource || publication.identifier !== target.identifier
190
+ || !Number.isSafeInteger(publication.revision)) return null;
191
+ if (Number(publication.revision) > target.revision) return "obsolete";
192
+ if (publication.revision !== target.revision || publication.generation !== target.generation
193
+ || publication.withdrawalRevision !== target.withdrawalRevision
194
+ || publication.action !== target.action || publication.contentVersion !== target.contentVersion) return null;
195
+ if (target.action === "withdraw") return response.status === 404 && body.status === "not_found" ? "match" : null;
196
+ return response.status === 200 && body.status === "ready" && body.delivery.content_version === target.contentVersion
197
+ ? "match" : null;
198
+ } catch { return null; }
199
+ }
200
+
201
+ function error(message: string, status: number): Response {
202
+ return Response.json({ ok: false, error: message }, { status, headers: { "Cache-Control": "no-store" } });
203
+ }
204
+
205
+ /** Called only after the common webhook has authenticated the exact raw body. */
206
+ export async function receiveV2Notification(value: unknown, bodyBytes: number): Promise<Response> {
207
+ const payload = parse(value, bodyBytes);
208
+ if (!payload) return error("invalid_notification", 400);
209
+ const config = configuration();
210
+ if (!config) return error("notifications_unavailable", 503);
211
+ if (payload.sourceUrl !== config.sourceUrl || payload.scope !== config.scope
212
+ || !equalDigest(payload.keyFingerprint, config.keyFingerprint)) return error("notification_scope_mismatch", 401);
213
+ const resources = payload.kind === "content_delivery_probe_v2"
214
+ ? payload.resources : payload.targets.map((target) => target.resource);
215
+ if (!resources.every((resource) => config.resources.has(resource))) return error("unsupported_resource", 400);
216
+ const common = { ok: true, kind: payload.kind, contract: "2", deliveryId: payload.deliveryId,
217
+ sourceUrl: config.sourceUrl, scope: config.scope, keyFingerprint: config.keyFingerprint };
218
+ if (payload.kind === "content_delivery_probe_v2") {
219
+ return Response.json({ ...common, resources: payload.resources }, { headers: { "Cache-Control": "no-store" } });
220
+ }
221
+ const results: Array<{ target: Target; status: "registered" | "withdrawn" | "obsolete" }> = [];
222
+ for (const target of payload.targets) {
223
+ const path = targetPath(target, config);
224
+ if (!path) return error("unsupported_target", 400);
225
+ const state = await sourceState(target, config);
226
+ if (!state) return error("publication_unconfirmed", 503);
227
+ if (state === "match") {
228
+ try {
229
+ if (target.resource === "cms-page") {
230
+ revalidateTag(cmsV2CacheTag(target.identifier.slice(5)), target.action === "withdraw" ? { expire: 0 } : "max");
231
+ }
232
+ revalidatePath(path);
233
+ } catch { return error("revalidation_failed", 503); }
234
+ }
235
+ results.push({ target, status: state === "obsolete" ? "obsolete" : target.action === "withdraw" ? "withdrawn" : "registered" });
236
+ }
237
+ return Response.json({ ...common, results }, { headers: { "Cache-Control": "no-store" } });
238
+ }
@@ -0,0 +1,20 @@
1
+ /** Installing the package alone never switches a site to the new source. */
2
+ export function usesPublishedV2(): boolean {
3
+ return process.env.SMKING_CONTENT_API_VERSION === "2";
4
+ }
5
+
6
+ /** The CMS data-cache tag is shared by the reader and signed notification. */
7
+ export function cmsV2CacheTag(slug: string): string {
8
+ return `smking:cms-page:v2:${slug}`;
9
+ }
10
+
11
+ export function siteFileUrl(
12
+ baseUrl: string,
13
+ apiKey: string,
14
+ kind: "sitemap" | "robots" | "llms_txt",
15
+ ): string {
16
+ // JSON includes delivery.publication; format=text does not carry the
17
+ // version or withdrawal evidence required before rendering v2 content.
18
+ const query = new URLSearchParams({ key: apiKey, kind });
19
+ return `${baseUrl.replace(/\/$/, "")}/api/v2/public/site-file?${query}`;
20
+ }
@@ -1,3 +1,6 @@
1
+ import { siteFileUrl, usesPublishedV2 } from "./delivery-v2";
2
+ import { publishedSiteFile } from "./published-v2";
3
+
1
4
  /**
2
5
  * Drop-in `app/llms.txt/route.ts` GET handler. Proxies the canonical
3
6
  * llms.txt served by Page Zero at `/api/v1/public/llms-txt?key=...`.
@@ -25,24 +28,35 @@ export async function GET(): Promise<Response> {
25
28
  const baseUrl = process.env.SMKING_BASE_URL ?? "https://getpagezero.com";
26
29
 
27
30
  try {
31
+ const v2 = usesPublishedV2();
28
32
  const res = await fetch(
29
- `${baseUrl}/api/v1/public/llms-txt?key=${encodeURIComponent(apiKey)}`,
30
- {
33
+ v2
34
+ ? siteFileUrl(baseUrl, apiKey, "llms_txt")
35
+ : `${baseUrl}/api/v1/public/llms-txt?key=${encodeURIComponent(apiKey)}`,
36
+ v2 ? { cache: "no-store", signal: AbortSignal.timeout(10_000) } : {
31
37
  next: { revalidate: 3600 },
32
38
  signal: AbortSignal.timeout(10_000),
33
39
  },
34
40
  );
35
41
  if (!res.ok) {
36
- return new Response("Not found", { status: 404 });
42
+ return v2
43
+ ? new Response("Unavailable", { status: res.status === 404 ? 404 : 503, headers: { "Cache-Control": "no-store" } })
44
+ : new Response("Not found", { status: 404 });
37
45
  }
38
- const body = await res.text();
46
+ const body = v2
47
+ ? publishedSiteFile(await res.json(), "llms_txt")?.body
48
+ : await res.text();
49
+ if (body === undefined || body === null) return new Response("Unavailable", { status: 503, headers: { "Cache-Control": "no-store" } });
39
50
  return new Response(body, {
40
51
  headers: {
41
52
  "Content-Type": "text/plain; charset=utf-8",
42
53
  "X-Smking-Takeover": "llms_txt",
54
+ ...(v2 ? { "Cache-Control": "no-store" } : {}),
43
55
  },
44
56
  });
45
57
  } catch {
46
- return new Response("Not found", { status: 404 });
58
+ return usesPublishedV2()
59
+ ? new Response("Unavailable", { status: 503, headers: { "Cache-Control": "no-store" } })
60
+ : new Response("Not found", { status: 404 });
47
61
  }
48
62
  }
@@ -0,0 +1,91 @@
1
+ type Resource = "aeo" | "cms-page" | "site-file";
2
+
3
+ type PublishedDocument = {
4
+ status: "ready";
5
+ document: { kind: "sitemap" | "robots" | "llms_txt"; body: string; content_type: string };
6
+ };
7
+
8
+ function record(value: unknown): value is Record<string, unknown> {
9
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
12
+ const VERSION = /^sha256:[a-f0-9]{64}$/;
13
+
14
+ /** Validate the source's committed version before allowing v2 content into a page. */
15
+ export function isPublishedReady(
16
+ value: unknown,
17
+ resource: Resource,
18
+ identifier: string,
19
+ now = Date.now(),
20
+ ): boolean {
21
+ if (!record(value) || value.status !== "ready" || !record(value.delivery)) return false;
22
+ const delivery = value.delivery;
23
+ if (delivery.contract !== "2" || typeof delivery.content_version !== "string"
24
+ || !VERSION.test(delivery.content_version) || !record(delivery.publication)) return false;
25
+ const validated = Date.parse(String(delivery.validated_at));
26
+ const fresh = Date.parse(String(delivery.fresh_until));
27
+ const usable = Date.parse(String(delivery.usable_until));
28
+ if (![validated, fresh, usable].every(Number.isFinite) || validated > fresh || fresh > usable || usable <= now) return false;
29
+ const publication = delivery.publication;
30
+ return publication.resource === resource && publication.identifier === identifier
31
+ && publication.action === "update" && Number.isSafeInteger(publication.revision)
32
+ && Number(publication.revision) >= 1 && publication.generation === publication.revision
33
+ && Number.isSafeInteger(publication.withdrawalRevision)
34
+ && Number(publication.withdrawalRevision) <= Number(publication.revision)
35
+ && publication.contentVersion === delivery.content_version;
36
+ }
37
+
38
+ /** A bare 404 (for example a proxy error) is not proof of withdrawal. */
39
+ export function isPublishedWithdrawal(
40
+ value: unknown,
41
+ resource: Resource,
42
+ identifier: string,
43
+ ): boolean {
44
+ if (!record(value) || value.status !== "not_found" || !record(value.delivery)
45
+ || value.delivery.contract !== "2" || !record(value.delivery.publication)) return false;
46
+ const publication = value.delivery.publication;
47
+ return publication.resource === resource && publication.identifier === identifier
48
+ && publication.action === "withdraw" && Number.isSafeInteger(publication.revision)
49
+ && Number(publication.revision) >= 1 && publication.generation === publication.revision
50
+ && publication.withdrawalRevision === publication.revision
51
+ && publication.contentVersion === null;
52
+ }
53
+
54
+ /** A missing target with no publication record is an ordinary 404, not a withdrawal. */
55
+ export function isPublishedAbsent(value: unknown, now = Date.now()): boolean {
56
+ if (!record(value) || value.status !== "not_found" || !record(value.delivery)) return false;
57
+ const delivery = value.delivery;
58
+ if (delivery.contract !== "2" || "publication" in delivery
59
+ || typeof delivery.content_version !== "string" || !VERSION.test(delivery.content_version)) return false;
60
+ const validated = Date.parse(String(delivery.validated_at));
61
+ const fresh = Date.parse(String(delivery.fresh_until));
62
+ const usable = Date.parse(String(delivery.usable_until));
63
+ return [validated, fresh, usable].every(Number.isFinite)
64
+ && validated <= fresh && fresh <= usable && usable > now;
65
+ }
66
+
67
+ /** Only Page Zero's exact JSON denial shape may revoke cached CMS content. */
68
+ export function publishedCmsDenial(value: unknown, status: number): "site" | "cms" | null {
69
+ if (!record(value) || value.status !== "unavailable") return null;
70
+ const keys = Object.keys(value).sort().join(",");
71
+ if (status === 401) {
72
+ return keys === "error,status" && value.error === "invalid_key" ? "site" : null;
73
+ }
74
+ if (status !== 403 || keys !== "denial,error,status" || !record(value.denial)) return null;
75
+ const denial = value.denial;
76
+ if (Object.keys(denial).sort().join(",") !== "contract,scope" || denial.contract !== "2") return null;
77
+ if (denial.scope === "site" && value.error === "site_disabled") return "site";
78
+ if (denial.scope === "cms" && value.error === "cms_disabled") return "cms";
79
+ return null;
80
+ }
81
+
82
+ export function publishedSiteFile(
83
+ value: unknown,
84
+ kind: "sitemap" | "robots" | "llms_txt",
85
+ ): PublishedDocument["document"] | null {
86
+ if (!isPublishedReady(value, "site-file", `kind:${kind}`) || !record(value)) return null;
87
+ const document = value.document;
88
+ if (!record(document) || document.kind !== kind || typeof document.body !== "string"
89
+ || typeof document.content_type !== "string" || document.body.length > 2_097_152) return null;
90
+ return document as PublishedDocument["document"];
91
+ }
@@ -0,0 +1,69 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ import { after } from "next/server";
3
+ import { SDK_VERSION } from "./version";
4
+
5
+ const seen = new Map<string, number>();
6
+ const MAX_SEEN = 500;
7
+ const REPORT_INTERVAL_MS = 60_000;
8
+
9
+ /** Reports are independent of the public content GET and never delay rendering. */
10
+ export function reportObservedPath(input: {
11
+ apiKey: string;
12
+ baseUrl: string;
13
+ path: string;
14
+ url?: string;
15
+ }): void {
16
+ const secret = process.env.SMKING_WEBHOOK_SECRET;
17
+ if (!secret || !input.url || !input.path.startsWith("/") || input.path.startsWith("//") || /[?#\\]/.test(input.path)) return;
18
+ let host: string;
19
+ try {
20
+ const siteUrl = new URL(input.url);
21
+ if (siteUrl.protocol !== "https:" && siteUrl.protocol !== "http:") return;
22
+ host = siteUrl.host;
23
+ } catch { return; }
24
+
25
+ const key = `${host}\n${input.path}`;
26
+ const now = Date.now();
27
+ if (now - (seen.get(key) ?? 0) < REPORT_INTERVAL_MS) return;
28
+ for (const [candidate, queuedAt] of seen) {
29
+ if (now - queuedAt > REPORT_INTERVAL_MS) seen.delete(candidate);
30
+ }
31
+ if (seen.size >= MAX_SEEN) return;
32
+ seen.set(key, now);
33
+ try {
34
+ after(async () => {
35
+ const body = JSON.stringify({
36
+ key: input.apiKey,
37
+ report_id: randomUUID(),
38
+ observed_at: new Date().toISOString(),
39
+ sdk_meta: {
40
+ sdk: "next", sdk_version: SDK_VERSION,
41
+ app_env: process.env.NODE_ENV ?? null,
42
+ host,
43
+ cms_base_path: process.env.SMKING_CMS_PATH?.startsWith("/")
44
+ ? process.env.SMKING_CMS_PATH : null,
45
+ },
46
+ paths: [input.path],
47
+ });
48
+ const signature = createHmac("sha256", secret)
49
+ .update("smking-sdk-report-v2\n").update(body).digest("hex");
50
+ try {
51
+ const result = await fetch(`${input.baseUrl}/api/v2/sdk/report`, {
52
+ method: "POST",
53
+ headers: {
54
+ "content-type": "application/json",
55
+ "x-smking-report-signature": `sha256=${signature}`,
56
+ },
57
+ body,
58
+ cache: "no-store",
59
+ signal: AbortSignal.timeout(2_000),
60
+ });
61
+ if (!result.ok) seen.delete(key);
62
+ } catch {
63
+ seen.delete(key);
64
+ }
65
+ });
66
+ } catch {
67
+ seen.delete(key); // Static export and non-request scopes cannot schedule work.
68
+ }
69
+ }
package/src/lib/robots.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { siteFileUrl, usesPublishedV2 } from "./delivery-v2";
2
+ import { publishedSiteFile } from "./published-v2";
3
+
1
4
  /**
2
5
  * AI-bot directives + Cloudflare `Content-Signal` for the customer's
3
6
  * `robots.txt`. Two surfaces because Next.js `MetadataRoute.Robots` doesn't
@@ -198,26 +201,39 @@ export default async function smkingRobotsRoute(): Promise<Response> {
198
201
  return permissiveFallback();
199
202
  }
200
203
  const apiKey = process.env.SMKING_API_KEY;
201
- if (!apiKey) return permissiveFallback();
204
+ if (!apiKey) return usesPublishedV2()
205
+ ? new Response("Unavailable", { status: 503, headers: { "Cache-Control": "no-store" } })
206
+ : permissiveFallback();
202
207
  const baseUrl = process.env.SMKING_BASE_URL ?? "https://getpagezero.com";
203
208
 
204
209
  try {
210
+ const v2 = usesPublishedV2();
205
211
  const res = await fetch(
206
- `${baseUrl}/api/v1/public/robots.txt?key=${encodeURIComponent(apiKey)}`,
207
- {
212
+ v2
213
+ ? siteFileUrl(baseUrl, apiKey, "robots")
214
+ : `${baseUrl}/api/v1/public/robots.txt?key=${encodeURIComponent(apiKey)}`,
215
+ v2 ? { cache: "no-store", signal: AbortSignal.timeout(10_000) } : {
208
216
  next: { revalidate: 86_400 },
209
217
  signal: AbortSignal.timeout(10_000),
210
218
  },
211
219
  );
212
- if (!res.ok) return permissiveFallback();
213
- const body = await res.text();
220
+ if (!res.ok) return v2
221
+ ? new Response("Unavailable", { status: res.status === 404 ? 404 : 503, headers: { "Cache-Control": "no-store" } })
222
+ : permissiveFallback();
223
+ const body = v2
224
+ ? publishedSiteFile(await res.json(), "robots")?.body
225
+ : await res.text();
226
+ if (body === undefined || body === null) return new Response("Unavailable", { status: 503, headers: { "Cache-Control": "no-store" } });
214
227
  return new Response(body, {
215
228
  headers: {
216
229
  "Content-Type": "text/plain; charset=utf-8",
217
230
  "X-Smking-Takeover": "robots",
231
+ ...(v2 ? { "Cache-Control": "no-store" } : {}),
218
232
  },
219
233
  });
220
234
  } catch {
221
- return permissiveFallback();
235
+ return usesPublishedV2()
236
+ ? new Response("Unavailable", { status: 503, headers: { "Cache-Control": "no-store" } })
237
+ : permissiveFallback();
222
238
  }
223
239
  }
@@ -1,4 +1,7 @@
1
1
  import type { MetadataRoute } from "next";
2
+ import { connection } from "next/server";
3
+ import { siteFileUrl, usesPublishedV2 } from "./delivery-v2";
4
+ import { publishedSiteFile } from "./published-v2";
2
5
 
3
6
  export const SMKING_SITEMAP_CACHE_TAG = "smking:sitemap";
4
7
 
@@ -29,14 +32,21 @@ export const SMKING_SITEMAP_CACHE_TAG = "smking:sitemap";
29
32
  export default async function smkingSitemap(): Promise<MetadataRoute.Sitemap> {
30
33
  if (process.env.SMKING_DISABLE_TAKEOVER_SITEMAP === "1") return [];
31
34
  const apiKey = process.env.SMKING_API_KEY;
32
- if (!apiKey) return [];
35
+ const v2 = usesPublishedV2();
36
+ if (!apiKey) {
37
+ if (v2) throw new Error("smking_v2_sitemap_unavailable");
38
+ return [];
39
+ }
33
40
  const baseUrl = process.env.SMKING_BASE_URL ?? "https://getpagezero.com";
41
+ if (v2) await connection();
34
42
 
35
43
  let body: string;
36
44
  try {
37
45
  const res = await fetch(
38
- `${baseUrl}/api/v1/public/sitemap.xml?key=${encodeURIComponent(apiKey)}`,
39
- {
46
+ v2
47
+ ? siteFileUrl(baseUrl, apiKey, "sitemap")
48
+ : `${baseUrl}/api/v1/public/sitemap.xml?key=${encodeURIComponent(apiKey)}`,
49
+ v2 ? { cache: "no-store", signal: AbortSignal.timeout(10_000) } : {
40
50
  // Daily revalidation — sitemap doesn't change second-to-second.
41
51
  // The signed publish webhook invalidates this tag immediately when
42
52
  // AEO or CMS content changes; the TTL remains the fail-safe.
@@ -47,9 +57,23 @@ export default async function smkingSitemap(): Promise<MetadataRoute.Sitemap> {
47
57
  signal: AbortSignal.timeout(10_000),
48
58
  },
49
59
  );
50
- if (!res.ok) return [];
51
- body = await res.text();
60
+ if (!res.ok) {
61
+ if (v2) throw new Error(`smking_v2_sitemap_${res.status}`);
62
+ return [];
63
+ }
64
+ if (v2) {
65
+ const document = publishedSiteFile(await res.json(), "sitemap");
66
+ if (!document) throw new Error("smking_v2_sitemap_unverified");
67
+ body = document.body;
68
+ if (!/<urlset\b[^>]*>[\s\S]*<\/urlset>/.test(body)) {
69
+ throw new Error("smking_v2_sitemap_malformed");
70
+ }
71
+ } else {
72
+ body = await res.text();
73
+ }
52
74
  } catch {
75
+ // A transient v2 source failure must never become a successful empty 200.
76
+ if (usesPublishedV2()) throw new Error("smking_v2_sitemap_unavailable");
53
77
  return [];
54
78
  }
55
79
 
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.22.2";
1
+ export const SDK_VERSION = "0.22.3-beta.2";
@@ -1,6 +1,7 @@
1
1
  import { revalidateTag } from "next/cache";
2
2
  import { Buffer } from "node:buffer";
3
3
  import crypto from "node:crypto";
4
+ import { receiveV2Notification } from "./delivery-notification-v2";
4
5
  import { SMKING_SITEMAP_CACHE_TAG } from "./sitemap";
5
6
 
6
7
  interface WebhookPayload {
@@ -120,6 +121,10 @@ export async function POST(request: Request): Promise<Response> {
120
121
  return Response.json({ error: "duplicate_delivery" }, { status: 401 });
121
122
  }
122
123
 
124
+ if (payload.kind === "content_delivery_probe_v2" || payload.kind === "content_delivery_v2") {
125
+ return receiveV2Notification(payload, Buffer.byteLength(rawBody));
126
+ }
127
+
123
128
  const kind = payload.kind;
124
129
  if (typeof kind !== "string" || kind.length === 0) {
125
130
  // Forward-compat: SaaS may emit kinds we haven't taught the SDK