@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 +12 -0
- package/package.json +23 -22
- package/src/cms-blocks.ts +3 -2
- package/src/lib/client.ts +29 -1
- package/src/lib/cms-client.ts +65 -2
- package/src/lib/delivery-notification-v2.ts +238 -0
- package/src/lib/delivery-v2.ts +20 -0
- package/src/lib/llms-txt-route.ts +19 -5
- package/src/lib/published-v2.ts +91 -0
- package/src/lib/report-v2.ts +69 -0
- package/src/lib/robots.ts +22 -6
- package/src/lib/sitemap.ts +29 -5
- package/src/lib/version.ts +1 -1
- package/src/lib/webhook-route.ts +5 -0
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": "^
|
|
79
|
+
"@testing-library/jest-dom": "^7.0.0",
|
|
74
80
|
"@testing-library/react": "^16.3.2",
|
|
75
|
-
"@tiptap/core": "^3.
|
|
76
|
-
"@tiptap/extension-image": "^3.
|
|
77
|
-
"@tiptap/extension-link": "^3.
|
|
78
|
-
"@tiptap/starter-kit": "^3.
|
|
79
|
-
"@tiptap/static-renderer": "^3.
|
|
80
|
-
"@types/react": "^19.
|
|
81
|
-
"@types/react-dom": "^19",
|
|
82
|
-
"@vitejs/plugin-react": "^6.0.
|
|
83
|
-
"jsdom": "^
|
|
84
|
-
"next": "^16.2.
|
|
85
|
-
"react": "^19.
|
|
86
|
-
"react-dom": "19.2.
|
|
87
|
-
"typescript": "^
|
|
88
|
-
"vitest": "^4.1.
|
|
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
|
|
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" },
|
package/src/lib/cms-client.ts
CHANGED
|
@@ -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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
213
|
-
|
|
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
|
|
235
|
+
return usesPublishedV2()
|
|
236
|
+
? new Response("Unavailable", { status: 503, headers: { "Cache-Control": "no-store" } })
|
|
237
|
+
: permissiveFallback();
|
|
222
238
|
}
|
|
223
239
|
}
|
package/src/lib/sitemap.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
51
|
-
|
|
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
|
|
package/src/lib/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = "0.22.2";
|
|
1
|
+
export const SDK_VERSION = "0.22.3-beta.2";
|
package/src/lib/webhook-route.ts
CHANGED
|
@@ -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
|