// Castro Custom Integration: conformance check.
// node castro-conformance.mjs <baseUrl> <apiKey>
// Node 18+. No dependencies.
import crypto from "node:crypto";
const BASE = (process.argv[2] || "").replace(/\/+$/, "");
const KEY = process.argv[3] || process.env.CASTRO_API_KEY;
if (!BASE || !KEY) {
console.error("usage: node castro-conformance.mjs <baseUrl> <apiKey>");
process.exit(2);
}
let pass = 0;
let fail = 0;
const skipped = [];
const sign = (ts, raw, key = KEY) =>
crypto.createHmac("sha256", key).update(`${ts}.${raw}`).digest("hex");
async function call(method, path, body, opts = {}) {
// opts.raw lets a test send exact bytes (used by the raw-body check below).
const raw = opts.raw ?? (body == null ? "" : JSON.stringify(body));
const ts = opts.ts ?? String(Date.now());
const key = opts.key ?? KEY;
let res;
try {
res = await fetch(`${BASE}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"X-API-Key": key,
"X-Castro-Timestamp": ts,
"X-Castro-Signature": opts.sig ?? sign(ts, raw, key),
},
body: method === "GET" || method === "DELETE" ? undefined : raw || undefined,
});
} catch (e) {
return { status: 0, data: { error: `network: ${e.message}` } };
}
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return { status: res.status, ok: res.ok, data };
}
function check(name, ok, detail = "") {
if (ok) {
pass++;
console.log(` \x1b[32mPASS\x1b[0m ${name}`);
} else {
fail++;
console.log(` \x1b[31mFAIL\x1b[0m ${name}`);
if (detail) console.log(` ${detail}`);
}
}
console.log(`\nCastro conformance → ${BASE}\n`);
// ── Handshake ───────────────────────────────────────────────────────────────
console.log("Handshake");
const challenge = crypto.randomBytes(16).toString("hex");
const hs = await call("POST", "/handshake", { challenge });
check("POST /handshake responds 200", hs.status === 200, JSON.stringify(hs.data));
const expected = crypto.createHmac("sha256", KEY).update(challenge).digest("hex");
check(
"challenge_response is HMAC(challenge, your key)",
hs.data?.challenge_response === expected,
"Sign the challenge with YOUR stored key, never with the key from the request header.",
);
const caps = Array.isArray(hs.data?.capabilities) ? hs.data.capabilities : [];
check(
"declares the two required capabilities",
caps.includes("posts.create") && caps.includes("posts.update"),
`got: [${caps.join(", ")}]`,
);
const can = (c) => caps.includes(c);
// ── Posts ───────────────────────────────────────────────────────────────────
console.log("\nPosts");
const created = await call("POST", "/posts", {
title: "Castro conformance check",
content: "<p>Original body.</p>",
excerpt: "Delete me.",
status: "publish",
categories: ["Conformance"],
tags: ["castro"],
author: "Castro",
seo: { title: "Conformance SEO title", description: "Conformance SEO description" },
source_id: `conformance-${Date.now()}`,
});
check("POST /posts responds 201", created.status === 201, JSON.stringify(created.data));
const id = created.data?.id;
check("create returns { id, url }", Boolean(id && created.data?.url), JSON.stringify(created.data));
if (!id) {
console.log("\nNo id returned, cannot continue.\n");
process.exit(1);
}
// The one that costs real content: a body-only update must not erase anything.
const updated = await call("PUT", `/posts/${id}`, { content: "<p>Updated body only.</p>" });
check("PUT /posts/{id} responds 200", updated.status === 200, JSON.stringify(updated.data));
if (can("pages.list")) {
const pages = await call("GET", "/pages?per_page=100&page=1");
check("GET /pages responds 200", pages.status === 200);
check("GET /pages returns a bare JSON array", Array.isArray(pages.data), JSON.stringify(pages.data)?.slice(0, 120));
const mine = (pages.data || []).find((p) => String(p.id) === String(id));
check("the post appears in GET /pages", Boolean(mine), "A published post must be listed.");
check(
"PARTIAL UPDATE preserved the title",
mine?.title === "Castro conformance check",
`title is now: ${JSON.stringify(mine?.title)}, a body-only PUT must not overwrite other fields.`,
);
} else {
skipped.push("partial-update verification (needs pages.list to read the post back)");
}
// ── Optional surfaces ───────────────────────────────────────────────────────
console.log("\nOptional endpoints");
if (can("blog_categories")) {
const r = await call("POST", "/blog-categories", { name: "Conformance", description: "temp" });
check("POST /blog-categories responds 2xx", r.status >= 200 && r.status < 300, JSON.stringify(r.data));
const list = await call("GET", "/blog-categories");
check("GET /blog-categories returns an array", Array.isArray(list.data));
} else skipped.push("blog_categories");
if (can("authors")) {
// Authors are named with display_name / username: NOT `name`. This is the
// exact body Castro's author picker sends.
const r = await call("POST", "/authors", {
display_name: "Castro Conformance",
username: "castro-conformance",
email: "conformance@example.com",
description: "Temporary author created by the conformance check.",
});
check("POST /authors responds 2xx", r.status >= 200 && r.status < 300, JSON.stringify(r.data));
} else skipped.push("authors");
if (can("products")) {
// Products are named with `name`, and the body is HTML in `description`.
const r = await call("POST", "/products", {
name: "Conformance product",
type: "simple",
description: "<p>Temporary product created by the conformance check.</p>",
short_description: "Temporary.",
categories: ["Conformance"],
tags: [],
images: [],
status: "publish",
source_id: `conformance-product-${Date.now()}`,
});
check("POST /products responds 2xx", r.status >= 200 && r.status < 300, JSON.stringify(r.data));
} else skipped.push("products");
if (can("product_categories")) {
const r = await call("POST", "/product-categories", {
name: "Conformance",
description: "<p>Temporary category.</p>",
image: "",
source_id: `conformance-cat-${Date.now()}`,
});
check("POST /product-categories responds 2xx", r.status >= 200 && r.status < 300, JSON.stringify(r.data));
} else skipped.push("product_categories");
if (can("seo")) {
const r = await call("PUT", "/seo", {
id: String(id),
seo: { title: "SEO-only push", description: "Pushed via PUT /seo" },
});
check("PUT /seo responds 200", r.status === 200, JSON.stringify(r.data));
} else skipped.push("seo");
// ── Authentication: these MUST be rejected ──────────────────────────────────
console.log("\nAuthentication (these must be REJECTED)");
const badSig = await call("GET", can("pages.list") ? "/pages" : "/handshake", null, {
sig: "0".repeat(64),
});
check("wrong signature → 401", badSig.status === 401, `got ${badSig.status}: your endpoints are unprotected.`);
const wrongKey = "not-the-real-key";
const decoy = crypto.randomBytes(8).toString("hex");
const badKey = await call("POST", "/handshake", { challenge: decoy }, { key: wrongKey });
check("wrong API key → 401", badKey.status === 401, `got ${badKey.status}`);
// If the server answered anyway, check WHY: a handshake that signs with the
// caller's key hands a valid response to anyone, which is the worst version of
// this bug and invisible while you're testing with the correct key.
const echoed = crypto.createHmac("sha256", wrongKey).update(decoy).digest("hex");
check(
"handshake signs with YOUR key, not the caller's",
badKey.data?.challenge_response !== echoed,
"Your /handshake echoed a challenge_response computed with the key from the request header. That authenticates ANYONE. Sign with your own stored key.",
);
const seconds = String(Math.floor(Date.now() / 1000));
const oldTs = await call("POST", "/handshake", { challenge: "x" }, {
ts: seconds,
sig: sign(seconds, JSON.stringify({ challenge: "x" })),
});
check(
"seconds-instead-of-milliseconds timestamp → 401",
oldTs.status === 401,
`got ${oldTs.status}: reject timestamps more than a few minutes old. Castro signs with Date.now() (ms).`,
);
// A pretty-printed body. The signature covers these exact bytes, whitespace and
// all. A server that re-serializes the parsed JSON to verify will compute the
// HMAC over compact bytes instead, and reject a request that is perfectly valid.
const prettyRaw = JSON.stringify({ content: "<p>Raw-body check.</p>" }, null, 2);
const rawTs = String(Date.now());
const rawCheck = await call("PUT", `/posts/${id}`, null, {
raw: prettyRaw,
ts: rawTs,
sig: sign(rawTs, prettyRaw),
});
check(
"signature verified over the RAW body",
rawCheck.status === 200,
`got ${rawCheck.status}: you are hashing a re-serialized copy of the body, not the bytes received. Capture the raw body before parsing.`,
);
// ── Cleanup ─────────────────────────────────────────────────────────────────
if (can("posts.delete")) {
console.log("\nCleanup");
const del = await call("DELETE", `/posts/${id}`);
check("DELETE /posts/{id} responds 200", del.status === 200, JSON.stringify(del.data));
} else {
skipped.push("posts.delete");
console.log(`\nNote: test post ${id} was left behind (no posts.delete capability).`);
}
// ── Summary ─────────────────────────────────────────────────────────────────
console.log(`\n${pass} passed, ${fail} failed`);
if (skipped.length) console.log(`skipped (not declared): ${skipped.join(", ")}`);
console.log("");
process.exit(fail ? 1 : 0);