1. How it works
GeoVector pushes generated articles to your site over HTTPS. You own storage, rendering, routing, and caching; GeoVector owns generation and measurement. The only coupling is one JSON contract on one path.
GeoVector Your website
───────── ────────────
generate article
│
│ POST /api/publish (upsert by slug) ──► store + return public URL
│ GET /api/publish?slug= (exists?) ──► { exists }
│ GET /api/publish?slug=&include=content ──► stored HTML (drift check)
│ DELETE /api/publish (unpublish) ──► { action }
│ GET /api/publish/meta (capabilities) ──► categories, default, path shape
▼
measure AI visibility of the published URL
The essentials:
- The slug is the primary key.
POSTis an upsert. Publishing the same slug must update in place, never create a duplicate. - Your
POSTresponse defines the canonical URL. Whatever returnedurlis what GeoVector monitors and attributes citations to. Return the final URL a human reaches after redirects. - You are the source of truth for content. GeoVector reads your stored HTML back to detect edits made on your side.
- Storage is entirely yours. A database row, a Markdown file plus a git commit, or a headless-CMS API call all work.
All operations live at {siteUrl}/api/publish and
{siteUrl}/api/publish/meta. These paths are fixed in v1; use a rewrite if needed.
2. Authentication
Every request — including /meta — carries a static bearer token:
Authorization: Bearer <your-token>
- You generate the token, not us. Use ≥32 bytes of CSPRNG output:
openssl rand -hex 32. - Store it in your deployment's environment (e.g.
CMS_PUBLISH_TOKEN). Never commit it, never expose it to the browser bundle. - Compare it in constant time. A naive
===on a secret is a timing oracle. - Paste it into GeoVector at connection time. We encrypt it at rest (AES-256-GCM) and send it only to the configured host.
- Reject anything unauthenticated with
401and a body of{"error":"unauthorized"}. Do not reveal whether the slug exists to an unauthenticated caller.
Accept CMS_PUBLISH_TOKEN and CMS_PUBLISH_TOKEN_PREVIOUS. Rotate by
adding the new token, updating GeoVector, then removing the old one.
Configure the canonical host. GeoVector re-attaches the bearer after a redirect only
within the same registrable domain and never after an HTTPS downgrade. Other redirects fail as
redirect_offsite.
3. Endpoint reference
POST /api/publish — create or update
Request body (application/json):
{
"title": "How AI Assistants Choose Sources",
"slug": "how-ai-assistants-choose-sources",
"category": "guide",
"date": "2026-08-06",
"featured": false,
"content": "<article>…</article>",
"format": "html",
"excerpt": "One-paragraph summary used for cards and meta description.",
"author": "Acme Research",
"readTime": "7 min read",
"tags": ["ai-search", "citations"],
"dateModified": "2026-08-06",
"thumbnail": { "gradientFrom": "#10b981", "gradientTo": "#0284c7" },
"imageBase64": "iVBORw0KGgo…"
}
Success response — 200:
{ "action": "created", "url": "https://acme.com/articles/how-ai-assistants-choose-sources" }
Return an absolute url. It becomes the address GeoVector
monitors for AI citations. A relative path, or a URL that then 301s somewhere else, degrades
every downstream measurement.
Set the publication date once. On update, keep the original
date and write dateModified instead. Re-dating an article to "today"
on every edit damages its search and AI-retrieval standing.
GET /api/publish?slug=… — existence probe
Response 200: { "exists": true } or { "exists": false }.
Missing slug → 400 {"error":"slug_required"}.
This doubles as GeoVector's connection test. At setup we call it with the reserved slug
__verify__; answering {"exists": false} is the correct, successful
response.
GET /api/publish?slug=…&include=content — read back
{ "exists": true, "content": "<article>…</article>", "format": "html" }
{ "exists": false }
Return the HTML exactly as stored. GeoVector compares a whitespace-normalised SHA-256 hash with the HTML sent at publish time to detect outside edits. Rewriting on ingest makes every article look modified, so validate then store byte-for-byte (see §9). If rewriting is unavoidable, tell us so we can hash your stored form.
DELETE /api/publish — unpublish
Body { "slug": "…" }. Response
{ "action": "deleted" } or { "action": "not_found" }. Both are
200 — a delete of something already gone is a success, not an error. Removing it from
your index and serving 410 (or 404) at the URL is preferable to leaving
an orphan page.
GET /api/publish/meta — advertise your options
{
"protocol": "geovector-cms",
"version": "1",
"categories": ["guide", "comparison", "industry", "research"],
"defaultCategory": "guide",
"articlePathTemplate": "/articles/{slug}",
"capabilities": {
"protocolVersions": ["1"],
"supportsDelete": true,
"maxBodyBytes": 10485760,
"imageMimeTypes": ["image/png", "image/webp"],
"locales": ["en"],
"fields": ["excerpt", "tags", "metaDescription", "canonicalUrl"]
}
}
GeoVector fetches and caches this at connection time. Categories become a dropdown;
articlePathTemplate is display-only, while the POST response remains the
URL authority. A 404 falls back to free-text categories.
capabilities — say what you cannot do
This object and all its fields are optional. Declare limits at setup so they are not discovered only when publishing.
| Field | Type | Meaning when absent |
|---|---|---|
protocolVersions | string[] | ["1"] |
supportsDelete | boolean | Yes — DELETE is in the v1 baseline. Send false if you are append-only. |
maxBodyBytes | integer | Unknown. Declares the ceiling behind your 413. |
imageMimeTypes | string[] | Unknown. An explicit [] means you accept no images — say that instead of dropping them quietly. |
locales | string[] | Unknown. Names the locales you can actually route. |
fields | string[] | Unknown. Names the optional payload fields you actually persist, so a sender can tell which parts of an article survive the trip. |
A missing field or object means “assume the v1 baseline.” Omit the object rather than sending
{}, and never advertise fields you silently discard.
GeoVector currently changes behaviour only for categories,
defaultCategory, and articlePathTemplate. Fields inside
capabilities are validated but advisory: for example,
supportsDelete: false does not yet hide Unpublish. Declare them accurately for
future enforcement.
Status codes
| Code | When | Body |
|---|---|---|
200 | Success, including not_found on delete | operation-specific |
400 | Body is not JSON at all | {"error":"invalid_json"} |
400 | JSON that violates the field schema | {"error":"invalid_payload","issues":{…}} |
400 | GET without a slug query parameter | {"error":"slug_required"} |
400 | HTML outside your content policy | {"error":"unsafe_content","violations":[…]} |
401 | Missing or wrong bearer | {"error":"unauthorized"} |
404 | /api/publish/meta only, when you advertise nothing | {"error":"meta_not_advertised"} |
413 | Body over your size limit | {"error":"payload_too_large"} |
429 | Rate limited — include Retry-After | {"error":"rate_limited"} |
500 | Your storage layer threw | {"error":"storage_error"} |
Keep 500 bodies generic. Log the exception on your side; do not echo database
errors back over the wire.
Status codes determine the integration-health verdict shown in the customer's dashboard:
400— the article's fault. The publish fails; integration health is unchanged.401/403— the credentials' fault. The connection is marked failing.5xx, timeout, or unreachable host — your site's fault. The connection is marked failing and your response body is shown, trimmed to 500 characters.
The next successful request clears the mark. Return 400, not 500,
for rejected payloads.
Each publish gets one request, no retry, with a 30-second deadline. A failure requires a
person to publish again, so move slow rebuilds and image work out of the request. GeoVector
does not yet honor Retry-After; 429 fails the publish without marking
the site unhealthy.
4. Article fields
| Field | Type | Req. | Notes |
|---|---|---|---|
title | string | MUST | Plain text, not HTML. The body already contains its own <h1>. |
slug | string | MUST | Primary key; matches ^[a-z0-9][a-z0-9-]*$. Accept up to 200 characters and validate on every verb. |
category | string | MUST | One of the values you advertise in /meta. Fall back to your default rather than rejecting an unknown one. |
date | string | MUST | ISO-8601: 2026-08-06 or 2026-08-06T09:30:00Z. Reject long-form dates and impossible days such as 2026-02-30. |
content | string | MUST | The article as an HTML fragment. When the article has a Q&A section, a trailing <script type="application/ld+json"> FAQPage block follows the closing </article> — render it into the page as-is. See §10. |
format | "html" | MUST | Literal. Reject other values so a future format cannot be silently mis-stored. |
featured | boolean | MAY | Defaults false. Honour it or ignore it. |
excerpt | string | SHOULD | Use for card summaries and <meta name="description">. Derive from the first paragraph if absent. |
author | string | SHOULD | Display name. Default to your brand. |
readTime | string | MAY | Pre-formatted ("7 min read"). Compute at ~200 wpm if absent. |
tags | string[] | MAY | Free-form. |
dateModified | string | SHOULD | ISO-8601, same rules as date. Emit in your Article JSON-LD. Stamp now on update if absent. |
metaDescription | string | SHOULD | <meta name="description">, up to 500 characters. Fall back to excerpt when absent. Plain text, like title — HTML-escape it when you render it into an attribute. |
canonicalUrl | string | SHOULD | Absolute http(s) URL for syndicated content. Absence means self-canonical. See the URL rules below. |
ogImageUrl | string | MAY | Absolute http(s) hosted image URL. A sender supplies this or imageBase64, not both. |
locale | string | MAY | BCP-47 tag (en, en-GB, zh-Hans) for <html lang>. Declare supported values in capabilities.locales. |
noindex | boolean | MAY | <meta name="robots" content="noindex">, and leave the page out of your sitemap. Absence means index. Treating absence as noindex would bury every article published by a sender that predates this field. |
thumbnail | object | MAY | {gradientFrom, gradientTo} hex pair for card art on sites without hero images. |
imageBase64 | string | MAY | Raw base64 (no data-URI prefix), no MIME type declared. Sniff the magic bytes, cap the decoded size, store it in object storage, and use it as the OG image. Dropping it is acceptable in v1. |
Ignore unknown fields; optional fields are added without changing protocol v1.
The one field you will not see is jsonLd. Structured data travels inline
in content, as a <script type="application/ld+json"> block the
content policy already validates. A parallel field would mean you merging two sources and us
choosing between them.
For canonicalUrl and ogImageUrl, require an absolute
http or https URL. A scheme check alone is unsafe: many parsers accept
raw characters that can escape an HTML attribute. Reject any character below before storing
the value.
| Rejected anywhere in a URL field | Why |
|---|---|
" ' ` | Closes the quote around href="…", so the rest of the value is rendered as markup rather than as part of the URL. |
< > | Opens a tag directly, which is enough on its own if the attribute was written unquoted. |
Any whitespace, including U+00A0 | Splits one attribute into two and hands the second half to whoever supplied the URL. Your language's \s class already covers the full set. |
Any control character, U+0000–U+001F and U+007F | Never appears unescaped in a real URL, and truncates or reframes the value in some parsers, log sinks and terminals. |
Reject rather than rewrite; rewriting hides invalid input.
5. Building it in another language
For non-Node stacks, implement the four handlers from these stable, language-neutral files:
| File | What it is |
|---|---|
geovector-cms.openapi.yaml | OpenAPI 3.1 description of every endpoint, status code and error body. Normative — where this guide and the spec disagree, the spec is right. Most languages can generate server stubs and request validators from it. |
article-html-policy.v1.json | The HTML allowlist and structural rules, as data. Generated from the same constants our own receiver enforces, so it cannot fall behind them. |
html-policy-conformance.v1.json | Test vectors for that policy: {id, why, html, expect}. Run every one through your content check. |
verify-receiver.sh | POSIX shell and curl. Exercises the whole contract against your live endpoint and tells you what is wrong. |
curl -sO https://www.geovector.ai/docs/cms/geovector-cms.openapi.yaml
curl -sO https://www.geovector.ai/docs/cms/article-html-policy.v1.json
curl -sO https://www.geovector.ai/docs/cms/html-policy-conformance.v1.json
curl -sO https://www.geovector.ai/docs/cms/verify-receiver.sh && chmod +x verify-receiver.sh
Re-fetch before releases, especially the generated policy file.
What you actually have to write
Most of the contract maps directly to framework features. The HTML policy needs care:
| Piece | Effort | Notes |
|---|---|---|
| Routing, JSON parsing, status codes | Trivial | Generate it from the OpenAPI spec. |
| Slug validation | Trivial | One regex, applied on POST, GET and DELETE. |
| Constant-time token compare | Trivial | Your standard library already has it — see below. |
| Body size cap | Small | Most frameworks have a native request-size limit. Make sure it fires before your JSON parser runs. |
| Date validation | Small | Match the ISO-8601 shape, then check the day exists. Most standard-library parsers accept 2026-02-30 and hand you 2 March; the format check alone will not catch it. |
| URL field validation | Trivial | Require scheme http/https on canonicalUrl and ogImageUrl, and reject every character in the §4 table from the raw value — the scheme match is case-insensitive, the character rules are not optional. Parsing alone is not validation — see the callout under §4. |
| The HTML content policy | The real work | A scanner over the allowlist table. Perhaps a hundred lines. This is the one to take seriously, and the one the conformance vectors exist for. |
You render remotely generated HTML on your domain. Without the allowlist, a leaked API key becomes stored XSS.
Writing the scanner
Read article-html-policy.v1.json and enforce it directly. The shape:
elements maps each allowed element to the attributes it may carry beyond
globalAttributes; urlAttributes lists the ones whose value must pass the
url rules; jsonLd describes the single permitted <script>
form; structure covers markup shape.
Four non-obvious rules have matching test vectors:
- Every
<must begin a well-formed allowed tag. Reject bare less-than characters. - Decode entities and strip control characters before checking URL schemes.
Browsers resolve forms such as
javascript:. - Refuse unquoted attribute values. Tokenizers disagree about ambiguous
markup such as
href=x onclick=y. - Do not feed script content to the tag scanner. For permitted JSON-LD,
forbid
<in the body to prevent an early</script>breakout.
Use own-key lookups for allowed elements; inherited keys such as toString and
constructor must fail. Two conformance vectors cover this.
Reject content outside the policy. Do not strip the offending markup and store the rest.
Rewriting breaks byte-based drift detection, and hand-written stripping can create unsafe leftovers. Reject the whole payload instead.
Constant-time token comparison
Compare digests, not the raw strings — a comparison that returns early on a length mismatch leaks the token's length. Your standard library has this:
PHP hash_equals(hash('sha256', $expected), hash('sha256', $given))
Python hmac.compare_digest(sha256(expected).digest(), sha256(given).digest())
Go subtle.ConstantTimeCompare(e[:], g[:]) // e, g := sha256.Sum256(...)
Ruby OpenSSL.secure_compare(Digest::SHA256.digest(expected), ...)
Java MessageDigest.isEqual(sha256(expected), sha256(given))
Node crypto.timingSafeEqual(sha256(expected), sha256(given))
Order of checks
This order is not cosmetic. Each step exists to keep the next one from running on input it cannot handle safely:
1. Bearer auth -> 401
2. Body size, before parsing -> 413
3. JSON parse -> 400 invalid_json
4. Schema, including slug pattern -> 400 invalid_payload
5. Content policy -> 400 unsafe_content
6. Store
Then check your work
chmod +x verify-receiver.sh
./verify-receiver.sh https://your-site.com "$YOUR_TOKEN"
It publishes and deletes geovector-conformance-check and verifies unsafe HTML is
refused. Use --read-only only when writes are impossible; run the full test before
launch.
Sections 8–16 are language-neutral. Sections 6–7 are worked JavaScript examples.
6. Reference implementation — Next.js App Router
Complete: paste into app/api/publish/route.ts, implement
storage (§8), set CMS_PUBLISH_TOKEN. It includes
the hardening from §9 — do not strip it out. The only dependency is the
HTML allowlist check: checkContent is defined in
§9 and is the one place we recommend a library rather than
hand-written code, because writing your own HTML tokenizer is how sanitizers get broken.
// app/api/publish/route.ts
import { timingSafeEqual, createHash } from 'node:crypto';
import { storage } from '@/lib/cms-storage';
export const dynamic = 'force-dynamic'; // never cache a mutation endpoint
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
const MAX_SLUG = 200; // senders cap at 100; leave headroom
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB, matching our reference receiver
const CATEGORIES = ['guide', 'comparison', 'industry', 'research'] as const;
const DEFAULT_CATEGORY = 'guide';
const SITE = 'https://acme.com';
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
});
/** Constant-time bearer check over fixed-length digests (no length leak). */
function authorized(req: Request): boolean {
const header = req.headers.get('authorization') ?? '';
const m = header.match(/^Bearer\s+(.+)$/);
if (!m) return false;
const tokens = [process.env.CMS_PUBLISH_TOKEN, process.env.CMS_PUBLISH_TOKEN_PREVIOUS]
.filter((t): t is string => !!t && t.length >= 32);
const given = createHash('sha256').update(m[1]).digest();
return tokens.some((t) => timingSafeEqual(given, createHash('sha256').update(t).digest()));
}
function validSlug(slug: unknown): slug is string {
return typeof slug === 'string' && slug.length <= MAX_SLUG && SLUG_RE.test(slug);
}
/** Reads the body with a hard byte ceiling, metered as the bytes arrive. */
async function readJson(req: Request): Promise<{ ok: true; data: any } | { ok: false; res: Response }> {
const tooLarge = { ok: false as const, res: json(413, { error: 'payload_too_large' }) };
const declared = Number(req.headers.get('content-length'));
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return tooLarge;
// Content-Length is a hint: absent on a chunked request, and not binding on
// any request. Meter the stream and abandon an oversized body mid-flight
// rather than buffering it first and checking afterwards.
const reader = req.body?.getReader();
if (!reader) return { ok: false, res: json(400, { error: 'invalid_json' }) };
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_BODY_BYTES) { await reader.cancel(); return tooLarge; }
chunks.push(value);
}
try {
// Decode over the joined buffer: a multi-byte character can straddle chunks.
return { ok: true, data: JSON.parse(Buffer.concat(chunks).toString('utf8')) };
} catch {
return { ok: false, res: json(400, { error: 'invalid_json' }) };
}
}
export async function POST(req: Request) {
if (!authorized(req)) return json(401, { error: 'unauthorized' });
const body = await readJson(req);
if (!body.ok) return body.res;
const b = body.data;
const issues: string[] = [];
if (typeof b?.title !== 'string' || !b.title.trim()) issues.push('title');
if (!validSlug(b?.slug)) issues.push('slug');
if (typeof b?.content !== 'string' || !b.content.trim()) issues.push('content');
if (b?.format !== 'html') issues.push('format');
if (typeof b?.date !== 'string' || Number.isNaN(Date.parse(b.date))) issues.push('date');
if (issues.length) return json(400, { error: 'invalid_payload', issues });
const category = CATEGORIES.includes(b.category) ? b.category : DEFAULT_CATEGORY;
// REQUIRED — see §9. You render this HTML on your own domain, so a leaked
// token is stored XSS unless you decide what markup you are willing to keep.
const verdict = checkContent(b.content);
if (!verdict.ok) return json(400, { error: 'unsafe_content', violations: verdict.violations });
try {
const { action } = await storage.upsert({
title: b.title,
slug: b.slug,
category,
date: new Date(b.date),
dateModified: b.dateModified ? new Date(b.dateModified) : new Date(),
featured: b.featured === true,
// Stored exactly as received. Nothing was rewritten, so the hash we
// return from GET ?include=content still matches what the sender sent.
content: b.content,
excerpt: typeof b.excerpt === 'string' ? b.excerpt : undefined,
author: typeof b.author === 'string' ? b.author : 'Acme',
readTime: typeof b.readTime === 'string' ? b.readTime : undefined,
tags: Array.isArray(b.tags) ? b.tags.filter((t: unknown) => typeof t === 'string') : [],
thumbnail: b.thumbnail ?? null,
});
return json(200, { action, url: `${SITE}/articles/${b.slug}` });
} catch (err) {
console.error('[cms] upsert failed', err); // details stay server-side
return json(500, { error: 'storage_error' });
}
}
export async function GET(req: Request) {
if (!authorized(req)) return json(401, { error: 'unauthorized' });
const url = new URL(req.url);
const slug = url.searchParams.get('slug');
if (!slug) return json(400, { error: 'slug_required' });
// Validate here too: an unvalidated slug is path traversal on a file-backed store.
if (!validSlug(slug)) return json(200, { exists: false });
if (url.searchParams.get('include') === 'content') {
const remote = await storage.fetch(slug);
return remote
? json(200, { exists: true, content: remote.content, format: 'html' })
: json(200, { exists: false });
}
return json(200, { exists: await storage.exists(slug) });
}
export async function DELETE(req: Request) {
if (!authorized(req)) return json(401, { error: 'unauthorized' });
const body = await readJson(req);
if (!body.ok) return body.res;
if (!validSlug(body.data?.slug)) return json(400, { error: 'invalid_payload', issues: ['slug'] });
const { action } = await storage.delete(body.data.slug);
return json(200, { action });
}
And the metadata sub-route:
// app/api/publish/meta/route.ts
export const dynamic = 'force-dynamic';
export async function GET(req: Request) {
// Same bearer check as /api/publish — extract it into a shared module.
if (!authorized(req)) {
return new Response(JSON.stringify({ error: 'unauthorized' }), {
status: 401,
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
});
}
return Response.json({
protocol: 'geovector-cms',
version: '1',
categories: ['guide', 'comparison', 'industry', 'research'],
defaultCategory: 'guide',
articlePathTemplate: '/articles/{slug}',
}, { headers: { 'cache-control': 'no-store' } });
}
7. Reference implementation — Express
Same contract, same rules. Note the explicit body limit and that DELETE carries a
JSON body.
import express from 'express';
import { timingSafeEqual, createHash } from 'node:crypto';
import { storage } from './cms-storage.js';
const router = express.Router();
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
router.use(express.json({ limit: '10mb' })); // rejects past the ceiling
// body-parser throws before any route runs. Without this, Express answers with
// its default HTML error page instead of the JSON envelope this API promises.
router.use((err, _req, res, next) => {
if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'payload_too_large' });
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'invalid_json' });
return next(err);
});
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
const validSlug = (s) => typeof s === 'string' && s.length <= 200 && SLUG_RE.test(s);
function auth(req, res, next) {
const m = (req.get('authorization') || '').match(/^Bearer\s+(.+)$/);
const expected = process.env.CMS_PUBLISH_TOKEN;
if (!m || !expected) return res.status(401).json({ error: 'unauthorized' });
const a = createHash('sha256').update(m[1]).digest();
const b = createHash('sha256').update(expected).digest();
if (!timingSafeEqual(a, b)) return res.status(401).json({ error: 'unauthorized' });
next();
}
router.post('/api/publish', auth, async (req, res) => {
const b = req.body;
if (!validSlug(b?.slug) || b?.format !== 'html' || !b?.title || !b?.content) {
return res.status(400).json({ error: 'invalid_payload' });
}
// REQUIRED — see §9. Store the HTML exactly as received, or not at all.
const verdict = checkContent(b.content);
if (!verdict.ok) return res.status(400).json({ error: 'unsafe_content', violations: verdict.violations });
try {
const { action } = await storage.upsert(b);
res.json({ action, url: `https://acme.com/articles/${b.slug}` });
} catch (err) {
req.log?.error(err);
res.status(500).json({ error: 'storage_error' });
}
});
router.get('/api/publish', auth, async (req, res) => {
const { slug, include } = req.query;
if (!slug) return res.status(400).json({ error: 'slug_required' });
if (!validSlug(slug)) return res.json({ exists: false });
if (include === 'content') {
const remote = await storage.fetch(slug);
return res.json(remote ? { exists: true, content: remote.content, format: 'html' }
: { exists: false });
}
res.json({ exists: await storage.exists(slug) });
});
router.delete('/api/publish', auth, async (req, res) => {
if (!validSlug(req.body?.slug)) return res.status(400).json({ error: 'invalid_payload' });
res.json(await storage.delete(req.body.slug));
});
router.get('/api/publish/meta', auth, (_req, res) => res.json({
protocol: 'geovector-cms',
version: '1',
categories: ['guide', 'comparison', 'industry', 'research'],
defaultCategory: 'guide',
articlePathTemplate: '/articles/{slug}',
}));
export default router;
8. The storage adapter
Four methods. Everything above is generic; this is the only part specific to your stack.
interface CmsStorage {
upsert(input: ArticleInput): Promise<{ action: 'created' | 'updated' }>;
delete(slug: string): Promise<{ action: 'deleted' | 'not_found' }>;
exists(slug: string): Promise<boolean>;
fetch(slug: string): Promise<{ content: string; format: string } | null>;
}
Requirements
- Upsert on
slug, and report which happened. Determinecreatedvsupdatedby checking existence before writing — GeoVector surfaces the distinction to the user. - Never re-date on update. Set
dateon create only; writedateModifiedon update. - Invalidate caches inside the write. A publish that returns
200while the page still serves the old body for ten minutes reads as a broken integration. On Next.js:revalidateTag()orrevalidatePath()for the article, the index, and the sitemap. On a CDN: issue the purge before responding. - Slug must not reach the filesystem unvalidated. If you write files, build the path from an already-regex-validated slug and resolve it against your content root, and verify the result is still inside that root.
- Make writes idempotent. Repeated or replayed payloads must update one article, never create duplicates.
Common shapes
| Backing store | Approach | Watch out for |
|---|---|---|
| SQL row | UPSERT … ON CONFLICT (slug); HTML in a text column | Simplest and recommended. Cache-tag your reads. |
| Markdown + git | Write file, commit, push, let CI deploy | POST must not block on the build. Return 200 once committed; the URL goes live shortly after. |
| Headless CMS | Proxy to the vendor API | Map their id to your slug, and keep their rate limits away from our 30 s deadline. |
| Object storage | Key by articles/{slug}.html | Purge the CDN inside the write, not after. |
9. Hardening — required before you go live
This endpoint renders remote HTML on your domain. Treat it as a privileged write API.
- Apply the HTML policy on receive. Refuse everything outside the exact allowlist below; otherwise a leaked token becomes stored XSS.
- Validate
slugagainst the regex on POST, GET and DELETE. The read and delete paths are the ones people forget, and they are the traversal risk. - Cap the body before parsing and answer
413. Use 5 MB withoutimageBase64or 10 MB with it. Meter the stream;Content-Lengthmay be absent. - Constant-time token comparison over fixed-length digests.
- HTTPS only, with HSTS. Never accept the token over plain HTTP.
- No caching on the endpoint.
Cache-Control: no-store, and make sure no CDN rule caches/api/publish.
Strongly recommended beyond that:
- Rate limit — 60 requests/minute per token is far above real publishing
volume. Return
429withRetry-After. - Log every mutation — timestamp, verb, slug, action, response code, and a request id. When a page is wrong, this is the first thing both sides will ask for.
- Echo a request id — return
X-Request-Id(reflecting the inbound one when present) so a support thread can be correlated across both systems. - Alert on
500s. A 5xx marks the connection failing and exposes your generic response message in the dashboard; details belong only in your logs. - Keep it fast. Our client aborts at 30 s. Long work (image processing, site rebuilds) belongs in a background job kicked off after you respond.
The content policy — what to allow, and reject vs. strip
The table is a readable view of our receiver's allowlist. Implement from the generated
article-html-policy.v1.json and test with
html-policy-conformance.v1.json; they include rules the table cannot express.
| Category | Allowed |
|---|---|
| Structure | article, section, header, footer, main, aside, div, figure, figcaption |
| Headings | h1–h6 |
| Text | p, span, strong, b, em, i, u, s, small, sub, sup, mark, abbr, br, hr, wbr, time (datetime) |
| Lists | ul, ol (start, reversed, type), li (value), dl, dt, dd |
| Quotes & code | blockquote/q (cite), cite, pre, code, kbd, samp, var |
| Tables | table, caption, colgroup/col (span), thead, tbody, tfoot, tr, th (scope, colspan, rowspan, abbr, headers), td (colspan, rowspan, headers) |
| Links & media | a (href, rel, hreflang), img (src, alt, width, height, loading, decoding) |
| Disclosure | details (open), summary |
| Global attributes | class, id, title, lang, dir — on any element above |
| URLs | href/src/cite must be relative or use http, https, mailto |
The sole script exception is <script type="application/ld+json">: require that
exact type, no other attributes, valid JSON, and no < in the body. When embedding
other untrusted JSON-LD, escape < as \u003c.
Answer 400 {"error":"unsafe_content","violations":[…]} and store nothing:
- Drift detection depends on byte equality. Rewriting makes every article appear modified immediately.
- A rejection is a signal. Unexpected HTML indicates a pipeline change or token misuse and should remain visible in logs.
If rewriting is unavoidable, use sanitize-html or DOMPurify with
jsdom, store the cleaned form, and tell us so drift checks use that form.
The §6 sample uses sanitize-html as a validator: any rewrite
is a rejection. JSON-LD is checked separately because the library cannot express its exact
permitted shape.
import sanitizeHtml from 'sanitize-html';
const POLICY = {
allowedTags: ['article','section','header','footer','main','aside','div','figure','figcaption',
'h1','h2','h3','h4','h5','h6','p','span','strong','b','em','i','u','s','small','sub','sup',
'mark','abbr','br','hr','wbr','time','ul','ol','li','dl','dt','dd','blockquote','q','cite',
'pre','code','kbd','samp','var','table','caption','colgroup','col','thead','tbody','tfoot',
'tr','th','td','a','img','details','summary'],
allowedAttributes: {
'*': ['class', 'id', 'title', 'lang', 'dir'],
a: ['href', 'rel', 'hreflang'],
img: ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
th: ['scope', 'colspan', 'rowspan', 'abbr', 'headers'],
td: ['colspan', 'rowspan', 'headers'],
ol: ['start', 'reversed', 'type'],
li: ['value'],
col: ['span'], colgroup: ['span'], time: ['datetime'],
blockquote: ['cite'], q: ['cite'], details: ['open'],
},
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
disallowedTagsMode: 'discard',
} as const;
// The one script we accept. Every part of this shape is load-bearing: that
// exact type, no other attributes, and a body containing no `<` at all — so
// there is nothing in it a browser can read as the end of the element.
const JSON_LD_BLOCK = /<script type="application\/ld\+json">([^<]*)<\/script>/g;
function checkContent(html: string): { ok: true } | { ok: false; violations: string[] } {
const violations: string[] = [];
// Lift the JSON-LD out before sanitizing. `sanitize-html` drops script bodies
// by design (`nonTextTags`), so handing it one would make every article that
// carries structured data look modified. Validate it on its own terms
// instead; anything script-shaped that this regex does *not* match is not a
// well-formed inert block and is caught immediately below.
const withoutJsonLd = html.replace(JSON_LD_BLOCK, (_match, body: string) => {
try {
JSON.parse(body);
} catch {
violations.push('invalid-json-ld');
}
return '';
});
if (/<script/i.test(withoutJsonLd)) violations.push('script');
const cleaned = sanitizeHtml(withoutJsonLd, POLICY);
if (cleaned !== withoutJsonLd) {
// Any mismatch is a rejection, named or not. Being unable to name what was
// removed is not a reason to accept it: a stripped `onerror` leaves the
// element name in the allowlist, so an unnamed violation is exactly the
// case you least want to let through.
const unknown = [...withoutJsonLd.matchAll(/<([a-zA-Z][a-zA-Z0-9]*)/g)]
.map((m) => m[1].toLowerCase())
.filter((t) => !POLICY.allowedTags.includes(t as never));
violations.push(...(unknown.length > 0 ? unknown : ['sanitizer-mismatch']));
}
if (violations.length === 0) return { ok: true };
return { ok: false, violations: [...new Set(violations)] };
}
withoutJsonLd
The lifted copy is only for validation. Persist the original content, including
JSON-LD, exactly as received.
Caveat: sanitize-html normalizes some safe markup, so comparison
can produce sanitizer-mismatch. Test real articles. If necessary, normalize before
comparing or store the cleaned form and accept the drift consequence. Our receiver scans
directly to remain byte-exact.
10. Rendering the HTML we send
content is a fragment, not a document: no <html>,
<head>, or <body>. Its shape:
<article>
<h1>How AI Assistants Choose Sources</h1>
<main>
<p>Opening paragraph…</p>
<h2>Section heading</h2>
<p>Body copy with a <a href="https://example.com/study">citation</a>.<sup id="fnref-1"><a href="#fn-1">1</a></sup></p>
<ul><li>List item</li></ul>
<table><thead>…</thead><tbody>…</tbody></table>
<blockquote><p>Pull quote</p></blockquote>
<h2>Sources</h2>
<ol><li id="fn-1">Source title — publisher, 2026. <a href="…">link</a></li></ol>
</main>
</article>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [{ "@type": "Question", "name": "…", "acceptedAnswer": { "@type": "Answer", "text": "…" } }]
}
</script>
- Render server-side. The article HTML must be present in the initial response. Content injected by client-side JavaScript is invisible to most AI crawlers — which defeats the purpose of publishing it.
- The fragment carries its own
<h1>. Do not add a second one fromtitle; usetitlefor<title>, OG tags, and listing cards. - The trailing
FAQPageblock is part of the fragment. Render it unchanged. Before converting HTML to text for search, excerpts, or/llms.txt, remove script blocks with their bodies so raw JSON does not leak into prose. - Do not strip
id/hrefanchors. Footnote refs pair#fnref-Nwith#fn-N; an over-eager sanitizer breaks every citation jump link. - Style tables properly. Comparison tables are among the most-quoted blocks
in AI answers. Wrap them in an
overflow-x: autocontainer for mobile. - Keep outbound links real. Do not add
nofollowto cited sources or rewrite them through a redirector — citation graphs are part of what gets measured.
11. Page requirements for AI visibility
These properties let AI assistants find, fetch, and cite the published page.
| Requirement | Why |
|---|---|
| Server-rendered HTML (SSR/SSG), no JS-only body | Most AI fetchers do not execute JavaScript. No HTML, no citation. |
<link rel="canonical"> matching the url you returned | Splitting the same article across two addresses splits its authority and breaks attribution. |
Article JSON-LD with headline, datePublished, dateModified, author, publisher | Machine-readable provenance; strongly correlated with being cited rather than paraphrased. |
FAQPage JSON-LD when the body has a Q&A section — we ship this inside content, so you only need to render it through unchanged | Question-shaped blocks are what assistants lift verbatim. |
<meta name="description"> from excerpt, plus OG/Twitter tags | Used for snippets and link previews across surfaces. |
Article appears in sitemap.xml within minutes of publish | Regenerate the sitemap in the same write path as the upsert. |
robots.txt allows AI crawlers you want citations from | GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Google-Extended, Bingbot. Blocking them makes the whole exercise moot. |
| No bot-challenge wall on article URLs | An aggressive WAF or interstitial serves a challenge page to crawlers instead of the article. Allowlist the article path. |
Stable URLs; 301 when they must change | Citations accumulate against an address. Moving one without a redirect discards that history. |
| Fast TTFB (<800 ms) and no login wall | Crawlers time out; gated content is never quoted. |
Optional: /llms.txt listing your published articles | An emerging convention for pointing assistants at your best content. |
12. Verify with curl
Run these against your deployment before connecting. All ten must behave exactly as shown.
./verify-receiver.sh https://acme.com "$TOKEN" also checks the size cap, content
policy, traversal slugs, and byte-identical read-back.
curl -sO https://www.geovector.ai/docs/cms/verify-receiver.sh
chmod +x verify-receiver.sh
./verify-receiver.sh https://acme.com "$TOKEN"
It publishes a probe article and deletes it again. Add --read-only to skip
every write and check the read paths alone.
SITE=https://acme.com
TOKEN=your-token
# 1 — auth is enforced (expect 401)
curl -s -o /dev/null -w '%{http_code}\n' "$SITE/api/publish?slug=__verify__"
# 2 — the exact probe GeoVector runs at connect (expect {"exists":false})
curl -s -H "Authorization: Bearer $TOKEN" "$SITE/api/publish?slug=__verify__"
# 3 — capabilities (expect the protocol envelope)
curl -s -H "Authorization: Bearer $TOKEN" "$SITE/api/publish/meta"
# 4 — publish (expect {"action":"created","url":"https://acme.com/articles/..."})
curl -s -X POST "$SITE/api/publish" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Integration Test","slug":"geovector-integration-test",
"category":"guide","date":"2026-08-06","format":"html","featured":false,
"excerpt":"Verifying the GeoVector CMS protocol.",
"content":"<article><h1>Integration Test</h1><main><p>Hello.</p></main></article>"}'
# 5 — the returned URL actually serves the article, server-rendered
curl -s "$SITE/articles/geovector-integration-test" | grep -c 'Integration Test'
# 6 — read back (content must match byte-for-byte what you stored)
curl -s -H "Authorization: Bearer $TOKEN" \
"$SITE/api/publish?slug=geovector-integration-test&include=content"
# 7 — re-publish is an update, not a duplicate (expect "updated")
# repeat step 4 verbatim
# 8 — unpublish (expect {"action":"deleted"}), then again (expect "not_found")
curl -s -X DELETE "$SITE/api/publish" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"slug":"geovector-integration-test"}'
# 9 — traversal is refused, not executed
curl -s -X DELETE "$SITE/api/publish" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"slug":"../../etc/passwd"}' # expect 400, and nothing deleted
# 10 — a non-ISO date is refused (expect 400 invalid_payload)
curl -s -X POST "$SITE/api/publish" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"x","slug":"geovector-integration-test","category":"guide",
"date":"August 06, 2026","format":"html","featured":false,
"content":"<article><h1>x</h1><main><p>x</p></main></article>"}'
After step 4, the article shows up in your index page and sitemap.xml without a
manual rebuild. Cache invalidation is the most common thing that "works locally" and fails in
production.
13. Connecting in GeoVector
- Open Settings → Integrations → GeoVector CMS.
- Enter Site URL — scheme and host only, canonical form, no trailing slash
(
https://www.acme.com). Not the/api/publishpath. - Enter API key — your bearer token. Stored encrypted; never displayed again.
- Click Verify. We probe
GET /api/publish?slug=__verify__, then fetch/api/publish/meta, and save only if the probe succeeds. - Pick your default category from the dropdown populated by your
/meta.
Publishing records and begins monitoring the returned URL for AI citations.
14. Troubleshooting
| What GeoVector reports | Cause | Fix |
|---|---|---|
unauthorized | Token mismatch (401/403) | Compare byte-for-byte — trailing newline from echo is the classic culprit. Confirm the env var is set in the deployed environment, not only locally. |
not_geovector_cms | 404, or 200 with a non-JSON body | /api/publish isn't deployed, or a catch-all route/CDN is answering first. Verify with curl step 2. |
invalid_response | 200 JSON without a boolean exists | The GET handler must return {"exists": <boolean>} — no wrapper envelope. |
unreachable | DNS failure, TLS error, or >8 s to respond | Check the certificate chain and cold-start latency. A WAF challenging our probe also looks like this. |
blocked_private_host | The host resolves to a private/loopback address | We refuse non-public targets. Use the public URL; staging must be publicly reachable to be connected. |
redirect_offsite | Your site redirects to a different registrable domain | Enter the destination host instead — we surface it as a one-click suggestion. |
server_error | Your handler threw | Check your logs for the request. Most often a missing CMS_PUBLISH_TOKEN or an unmigrated database. |
| Publish succeeds, page 404s | url doesn't match your real route, or the cache wasn't purged | Return the address your router actually serves; invalidate inside the write. |
| "Modified externally" on an untouched article | Your read-back HTML differs from what you stored | Sanitize/normalise once at ingest, store the result, and return it verbatim. Do not re-transform on read. |
| Every publish creates a duplicate | Insert instead of upsert | Key on slug with a unique constraint. |
| Publish times out at 30 s | Synchronous rebuild or image processing in the request | Respond first; do the slow work in a background job. |
| Settings shows "Publishing failing" | The last request that reached you returned 5xx, timed out, or was refused with 401/403 | The message on the banner is your own response body. Fix the cause, then publish anything and the mark clears itself; re-verifying and saving the connection clears it too. Nothing was disabled. |
| "Publishing failing" after one bad draft | Your receiver answers 500 where it means 400 | Return 400 for anything you refuse about the payload. A 5xx tells us your site is down (§3). |
15. Forward compatibility
- Ignore unknown request fields. v1.x adds optional fields only; a strict receiver breaks on the day we ship them.
- Return unknown-to-us fields freely in your responses — we ignore what we don't recognise.
- Advertise honestly in
/meta. Thecapabilitiesobject is where you declare max body size, image support, locales, and which optional fields you persist. Absence of any of it means "unknown, assume the baseline" — so a receiver that stays silent is conforming, and one that overstates is the only real failure mode. - Breaking changes get a new version and are announced before rollout; v1 receivers keep working.
- Reserved slug:
__verify__is used by the connection probe. Never store an article under it.
Additive v1 fields already shipped: metaDescription, canonicalUrl,
ogImageUrl, locale, noindex, and capabilities.
ISO-8601 validation for date and dateModified is now enforced.
Document revisions
Document revisions do not change the protocol version.
| Rev. | Date | What changed |
|---|---|---|
| 1 | 2026-08-06 | First public revision with companion files. Added status-code effects; corrected retry
and capabilities behaviour. |
| 2 | 2026-08-07 | Added missing error responses and the complete URL-character rejection table. No wire change. |
| 3 | 2026-08-07 | Condensed repeated guidance while preserving the contract, examples, and security requirements. No wire change. |
16. Launch checklist
POST /api/publishupserts by slug and returns an absoluteurlGET /api/publish?slug=returns{ exists }GET /api/publish?slug=&include=contentreturns the stored HTML unchangedDELETE /api/publishremoves the article and returnsdeleted/not_foundGET /api/publish/metaadvertises protocol, version, categories, default, path template- Bearer required on all five; constant-time comparison;
401otherwise - Slug regex enforced on POST, GET and DELETE
- HTML checked against an allowlist before storage, with
400 unsafe_contentfor anything outside it - Accepted HTML stored byte-for-byte, so drift detection stays meaningful
- Body size capped with a
413response - Payload rejections answer
400, never500— a 5xx reports your site as down to the customer - Endpoint served over HTTPS with
no-store, never cached by a CDN - Rate limit and mutation logging in place
dateanddateModifiedvalidated as ISO-8601, including that the day existsdateset on create only;dateModifiedwritten on updatecanonicalUrlandogImageUrlrejected unless absolutehttp(s)noindexhonoured in both the meta tag and the sitemap; absence means index- Caches, index page, and
sitemap.xmlinvalidated inside the write - Article page is server-rendered with canonical link and
ArticleJSON-LD robots.txtallows the AI crawlers you want citations from- All ten curl checks in §12 pass against production
- Token stored in the deployment environment; rotation procedure written down