diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b6c468a..2e66907 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,32 @@ jobs:
- name: Smoke test the serverless routes
run: pnpm test:smoke
+ production-links:
+ name: Production Links
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm test:links
+ - run: pnpm build
+ - run: pnpm exec playwright install --with-deps chromium
+ - run: pnpm check:links
+ - name: Upload link check report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: production-links
+ path: playwright-report/link-check.json
+ if-no-files-found: ignore
+ retention-days: 14
+
playwright-a11y:
name: Playwright A11y Gate
needs: build
diff --git a/README.md b/README.md
index dbcac74..57bfcc2 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,47 @@ the OG image endpoint (rendering, query escaping, cache headers and the failure
response). Buttondown is stubbed, so no mail is sent and no test touches the network.
The suite runs on every pull request in CI.
+## Production link checks
+
+```bash
+pnpm build
+pnpm exec playwright install chromium
+pnpm check:links
+pnpm test:links
+```
+
+The pull request `Production Links` job runs the same checker against `dist/`.
+It discovers all built HTML routes and sitemap URLs, then uses Chromium to crawl
+rendered links and validate same-page and cross-page anchors (including MDX headings).
+Production-origin URLs resolve against the local build. Missing files and rendered
+not-found pages fail even when the preview server returns an SPA fallback with HTTP 200.
+The checker parses the sitemap, checks built-route coverage, and validates the main
+RSS feed, every generated tag feed, and feeds expected by sitemap tag archives.
+RSS channel, item, permalink GUID, and self URLs are checked too.
+
+External HTTP(S) destinations use GET with a ten-second timeout, at most two attempts,
+and six concurrent requests. External fragments are not checked. `mailto:` and `tel:`
+links are skipped. For an offline internal check, use `pnpm check:links --internal-only`;
+CI always runs the full check. Failures include source and destination in the console
+and `playwright-report/link-check.json`, uploaded as a CI artifact.
+
+Intentional external failures must be recorded in `scripts/link-check-allowlist.json`
+as exact normalized URLs (no fragment or wildcard), with a reason and the specific
+allowed HTTP statuses or `"network"` for a known unavailable host. For example:
+
+```json
+[
+ {
+ "url": "https://example.org/protected",
+ "reason": "Intentionally requires authentication",
+ "failures": [401]
+ }
+]
+```
+
+The allowlist starts empty. It cannot suppress internal links, anchors, or XML failures.
+Allowed failures remain visible in the report; unexpected failure statuses still fail CI.
+
## SEO & Metadata
- **`robots.txt`**: Located in `public/robots.txt` and copied to the build root. It allows search engine crawling while excluding staging, preview, admin, and 404 routes, and points crawlers to the sitemap location.
diff --git a/package.json b/package.json
index 4989ee2..979fbd3 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"lint:i18n": "node scripts/lint-i18n.mjs",
"generate:og": "node scripts/generate-og.mjs",
"validate:jsonld": "tsx scripts/validate-jsonld.mjs",
+ "check:links": "node scripts/check-links.mjs",
+ "test:links": "vitest run tests/link-checker.test.mjs",
"validate:content": "tsx scripts/validate-content.ts",
"prepare": "husky"
},
diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt
index a4fa2dd..2780fec 100644
--- a/public/.well-known/security.txt
+++ b/public/.well-known/security.txt
@@ -5,18 +5,9 @@ Canonical: https://usewraith.xyz/.well-known/security.txt
Contact: mailto:security@usewraith.xyz
Contact: https://usewraith.xyz/security
-# PGP key for encrypted communications
-Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/ABCD123456789...
-
# Preferred languages
Preferred-Languages: en
-# Disclosure policy
-Policy: https://docs.usewraith.xyz/security/disclosure-policy
-
-# Acknowledgments page
-Acknowledgments: https://docs.usewraith.xyz/security/disclosures
-
# Hiring security researchers
Hiring: https://usewraith.xyz/careers
diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs
new file mode 100644
index 0000000..16c896e
--- /dev/null
+++ b/scripts/check-links.mjs
@@ -0,0 +1,94 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { join } from 'node:path';
+import { chromium } from '@playwright/test';
+import { preview } from 'vite';
+import { checkBuild } from './link-checker.mjs';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+const internalOnly = process.argv.includes('--internal-only');
+if (process.argv.slice(2).some((arg) => arg !== '--internal-only')) {
+ throw new Error('Usage: pnpm check:links [--internal-only]');
+}
+let server;
+let browser;
+try {
+ const allowlist = JSON.parse(
+ await readFile(join(root, 'scripts/link-check-allowlist.json'), 'utf8'),
+ );
+ server = await preview({
+ root,
+ configFile: false,
+ preview: { host: '127.0.0.1', port: 0, open: false },
+ });
+ const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
+ browser = await chromium.launch();
+ const context = await browser.newContext({ locale: 'en-US', serviceWorkers: 'block' });
+ // Only page content comes from the local production build. External destinations
+ // are checked separately, with bounded retries, rather than loading third-party JS.
+ await context.route('**/*', (route) =>
+ new URL(route.request().url()).origin === origin ? route.continue() : route.abort(),
+ );
+ const page = await context.newPage();
+ page.setDefaultTimeout(15_000);
+ let pageErrors = [];
+ page.on('pageerror', (error) => pageErrors.push(error.message));
+ page.on('requestfailed', (request) => {
+ if (request.url().startsWith(origin + '/') && request.resourceType() === 'script') {
+ pageErrors.push(`Script failed: ${request.url()}`);
+ }
+ });
+ page.on('response', (response) => {
+ if (
+ response.url().startsWith(origin + '/') &&
+ response.status() >= 400 &&
+ response.request().resourceType() === 'script'
+ ) {
+ pageErrors.push(`Script HTTP ${response.status()}: ${response.url()}`);
+ }
+ });
+ const report = await checkBuild({
+ distDir: join(root, 'dist'),
+ allowlist,
+ internalOnly,
+ progress: console.log,
+ renderPage: async (path) => {
+ pageErrors = [];
+ const response = await page.goto(origin + path, { waitUntil: 'networkidle' });
+ await page.locator('h1').first().waitFor({ state: 'attached' });
+ if (pageErrors.length) throw new Error(pageErrors.join('; '));
+ const snapshot = await page.evaluate(() => ({
+ heading: document.querySelector('h1')?.textContent.trim(),
+ links: [
+ ...document.querySelectorAll('a[href], area[href], link[rel="alternate"][href]'),
+ ].map((node) => node.getAttribute('href')),
+ anchors: [...document.querySelectorAll('[id], a[name]')].flatMap((node) =>
+ [node.id, node.getAttribute('name')].filter(Boolean),
+ ),
+ }));
+ return { ...snapshot, status: response.status() };
+ },
+ });
+ await mkdir(join(root, 'playwright-report'), { recursive: true });
+ await writeFile(
+ join(root, 'playwright-report/link-check.json'),
+ JSON.stringify(report, null, 2) + '\n',
+ );
+ for (const finding of report.errors)
+ console.error(`${finding.source} -> ${finding.target}: ${finding.message}`);
+ for (const finding of report.allowed)
+ console.log(`Allowed: ${finding.target}: ${finding.reason}`);
+ console.log(
+ `Checked ${report.pages} pages, ${report.feeds} feeds, ${report.links} links; ${report.errors.length} failures, ${report.allowed.length} allowed external failures.${internalOnly ? ' External checks skipped.' : ''}`,
+ );
+ if (report.errors.length) process.exitCode = 1;
+} catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+} finally {
+ await browser?.close();
+ if (server)
+ await new Promise((resolve, reject) =>
+ server.httpServer.close((error) => (error ? reject(error) : resolve())),
+ );
+}
diff --git a/scripts/link-check-allowlist.json b/scripts/link-check-allowlist.json
new file mode 100644
index 0000000..a3beeec
--- /dev/null
+++ b/scripts/link-check-allowlist.json
@@ -0,0 +1,7 @@
+[
+ {
+ "url": "https://www.npmjs.com/package/@wraith-protocol/sdk",
+ "reason": "npm returns a Cloudflare challenge to automated link-check requests",
+ "failures": [403]
+ }
+]
diff --git a/scripts/link-checker.mjs b/scripts/link-checker.mjs
new file mode 100644
index 0000000..a9ccf9c
--- /dev/null
+++ b/scripts/link-checker.mjs
@@ -0,0 +1,370 @@
+import { lookup } from 'node:dns/promises';
+import { isIP } from 'node:net';
+import { readdir, readFile } from 'node:fs/promises';
+import { join, posix } from 'node:path';
+import { JSDOM } from 'jsdom';
+
+export const SITE_URL = 'https://usewraith.xyz';
+function isPrivateOrLocalIp(address) {
+ const version = isIP(address);
+
+ if (version === 4) {
+ const parts = address.split('.').map(Number);
+ const [a, b] = parts;
+
+ return (
+ a === 0 ||
+ a === 10 ||
+ a === 127 ||
+ (a === 169 && b === 254) ||
+ (a === 172 && b >= 16 && b <= 31) ||
+ (a === 192 && b === 168)
+ );
+ }
+
+ if (version === 6) {
+ const normalized = address.toLowerCase();
+
+ return (
+ normalized === '::' ||
+ normalized === '::1' ||
+ normalized.startsWith('fc') ||
+ normalized.startsWith('fd') ||
+ /^fe[89ab]/.test(normalized)
+ );
+ }
+
+ return false;
+}
+
+async function assertSafeExternalDestination(url, resolveHost = lookup) {
+ const parsed = new URL(url);
+
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ throw new Error(`Unsafe external protocol: ${parsed.protocol}`);
+ }
+
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
+
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
+ throw new Error(`Blocked private or link-local destination: ${hostname}`);
+ }
+
+ if (isIP(hostname)) {
+ if (isPrivateOrLocalIp(hostname)) {
+ throw new Error(`Blocked private or link-local destination: ${hostname}`);
+ }
+ return;
+ }
+
+ const addresses = await resolveHost(hostname, { all: true, verbatim: true });
+
+ if (!addresses.length || addresses.some(({ address }) => isPrivateOrLocalIp(address))) {
+ throw new Error(`Blocked private or link-local destination: ${hostname}`);
+ }
+}
+export async function listFiles(directory, prefix = '') {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const files = await Promise.all(
+ entries.map((entry) => {
+ const name = posix.join(prefix, entry.name);
+ return entry.isDirectory() ? listFiles(join(directory, entry.name), name) : [name];
+ }),
+ );
+ return files.flat().sort();
+}
+
+export function routePath(pathname) {
+ return pathname.replace(/\/index\.html$/, '/').replace(/\/$/, '') || '/';
+}
+
+export function validateAllowlist(entries, siteUrl = SITE_URL) {
+ if (!Array.isArray(entries)) throw new Error('External allowlist must be an array');
+ const seen = new Set();
+ for (const entry of entries) {
+ const url = new URL(entry.url);
+ if (
+ !['http:', 'https:'].includes(url.protocol) ||
+ url.origin === new URL(siteUrl).origin ||
+ url.hash ||
+ entry.url.includes('*') ||
+ url.href !== entry.url ||
+ seen.has(entry.url) ||
+ typeof entry.reason !== 'string' ||
+ !entry.reason.trim() ||
+ !Array.isArray(entry.failures) ||
+ !entry.failures.length ||
+ entry.failures.some(
+ (value) => value !== 'network' && (!Number.isInteger(value) || value < 400 || value > 599),
+ )
+ ) {
+ throw new Error(`Invalid external allowlist entry: ${entry.url}`);
+ }
+ seen.add(entry.url);
+ }
+ return entries;
+}
+
+// GET avoids false failures from sites that reject HEAD. Retry transient failures once.
+export async function checkExternal(url, fetchUrl = fetch, resolveHost = lookup) {
+ const maxRedirects = 5;
+
+ for (let attempt = 0; attempt < 2; attempt++) {
+ let currentUrl = url;
+
+ try {
+ for (let redirects = 0; redirects <= maxRedirects; redirects++) {
+ await assertSafeExternalDestination(currentUrl, resolveHost);
+
+ const response = await fetchUrl(currentUrl, {
+ redirect: 'manual',
+ signal: AbortSignal.timeout(10_000),
+ headers: { 'User-Agent': 'Wraith-Link-Checker/1.0' },
+ });
+
+ if (response.status >= 300 && response.status < 400) {
+ const location = response.headers.get('location');
+ await response.body?.cancel();
+
+ if (!location) {
+ return { status: response.status, ok: false };
+ }
+
+ if (redirects === maxRedirects) {
+ throw new Error('Too many redirects');
+ }
+
+ currentUrl = new URL(location, currentUrl).href;
+
+ // Validate the redirect destination before the next request.
+ await assertSafeExternalDestination(currentUrl, resolveHost);
+ continue;
+ }
+
+ const result = { status: response.status, ok: response.ok };
+ await response.body?.cancel();
+
+ if (result.ok || (result.status !== 429 && result.status < 500)) {
+ return result;
+ }
+
+ break;
+ }
+ } catch (error) {
+ if (
+ error.message.startsWith('Blocked private or link-local destination:') ||
+ error.message.startsWith('Unsafe external protocol:')
+ ) {
+ return {
+ status: 'network',
+ ok: false,
+ detail: error.message,
+ };
+ }
+
+ if (attempt === 1) {
+ return {
+ status: 'network',
+ ok: false,
+ detail: error.message,
+ };
+ }
+ }
+ }
+
+ return { status: 'network', ok: false };
+}
+
+export async function checkBuild({
+ distDir,
+ renderPage,
+ externalCheck = checkExternal,
+ allowlist = [],
+ internalOnly = false,
+ siteUrl = SITE_URL,
+ progress = () => {},
+}) {
+ validateAllowlist(allowlist, siteUrl);
+ const files = new Set(await listFiles(distDir));
+ if (!files.has('index.html')) throw new Error('dist/index.html missing; run pnpm build first');
+ const errors = [];
+ const allowed = [];
+ const links = new Map();
+ const pages = new Map();
+ const routes = new Set();
+ const feeds = new Set(['feed.xml']);
+ const fail = (source, target, message) => errors.push({ source, target, message });
+ const addLink = (raw, source, absolute = false) => {
+ try {
+ const url = absolute ? new URL(raw) : new URL(raw, new URL(source, siteUrl));
+ if (!absolute && ['mailto:', 'tel:'].includes(url.protocol)) return;
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Unsupported link protocol');
+ if (!links.has(url.href)) links.set(url.href, new Set());
+ links.get(url.href).add(source);
+ return url;
+ } catch (error) {
+ fail(source, raw, `Invalid URL: ${error.message}`);
+ }
+ };
+ const readXml = async (file, root) => {
+ try {
+ const dom = new JSDOM(await readFile(join(distDir, file), 'utf8'), {
+ contentType: 'text/xml',
+ });
+ if (dom.window.document.documentElement.localName !== root) {
+ dom.window.close();
+ throw new Error(`Expected <${root}>`);
+ }
+ return dom;
+ } catch (error) {
+ fail(file, file, `Invalid or missing XML: ${error.message}`);
+ }
+ };
+
+ for (const file of files) {
+ if (file.endsWith('.html') && file !== '404.html' && !file.startsWith('404/')) {
+ routes.add(routePath(`/${file}`));
+ }
+ if (file === 'feed.xml' || (file.startsWith('feed/') && file.endsWith('.xml'))) feeds.add(file);
+ }
+ const sitemap = await readXml('sitemap.xml', 'urlset');
+ const sitemapRoutes = new Set();
+ if (sitemap) {
+ const entries = [...sitemap.window.document.querySelectorAll('urlset > url')];
+ if (!entries.length) fail('sitemap.xml', '', 'Sitemap has no URLs');
+ for (const entry of entries) {
+ const raw = entry.querySelector('loc')?.textContent.trim() || '';
+ const url = addLink(raw, 'sitemap.xml', true);
+ if (!url) continue;
+ if (url.origin !== new URL(siteUrl).origin || url.search || url.hash) {
+ fail('sitemap.xml', raw, 'Sitemap URL must be a canonical production URL');
+ continue;
+ }
+ const route = routePath(url.pathname);
+ if (sitemapRoutes.has(route)) fail('sitemap.xml', raw, 'Duplicate sitemap route');
+ sitemapRoutes.add(route);
+ routes.add(route);
+ if (route.startsWith('/blog/tag/'))
+ feeds.add(`feed/tag/${route.slice('/blog/tag/'.length)}.xml`);
+ }
+ sitemap.window.close();
+ for (const route of routes) {
+ if (!sitemapRoutes.has(route)) fail('sitemap.xml', route, 'Built route missing from sitemap');
+ }
+ }
+
+ for (const file of feeds) {
+ const dom = await readXml(file, 'rss');
+ if (!dom) continue;
+ const document = dom.window.document;
+ const channel = document.querySelector('rss > channel');
+ if (!channel) fail(file, file, 'RSS channel missing');
+ const self = [...document.getElementsByTagNameNS('http://www.w3.org/2005/Atom', 'link')]
+ .find((node) => node.getAttribute('rel') === 'self')
+ ?.getAttribute('href');
+ if (self !== new URL(`/${file}`, siteUrl).href)
+ fail(file, self, 'RSS self URL does not match feed file');
+ if (self) addLink(self, file, true);
+ for (const entry of document.querySelectorAll('channel, item')) {
+ const link = entry.querySelector(':scope > link')?.textContent.trim() || '';
+ addLink(link, file, true);
+ if (!entry.querySelector(':scope > title')?.textContent.trim())
+ fail(file, link, 'RSS title missing');
+ }
+ for (const guid of document.querySelectorAll('guid')) {
+ if (guid.getAttribute('isPermaLink') !== 'false')
+ addLink(guid.textContent.trim(), file, true);
+ }
+ dom.window.close();
+ }
+
+ // Seed every built/sitemap route, including routes with no incoming links.
+ for (const route of routes) addLink(route, 'route matrix');
+ const external = new Map();
+ for (const [href, sources] of links) {
+ const url = new URL(href);
+ const source = [...sources].join(', ');
+ if (url.origin !== new URL(siteUrl).origin) {
+ url.hash = '';
+ if (!external.has(url.href)) external.set(url.href, new Set());
+ for (const item of sources) external.get(url.href).add(item);
+ continue;
+ }
+ let pathname;
+ try {
+ pathname = decodeURIComponent(url.pathname);
+ } catch {
+ fail(source, href, 'Malformed URL encoding');
+ continue;
+ }
+ const file = pathname.slice(1);
+ const isHtml = pathname.endsWith('.html');
+ if (files.has(file) && !isHtml) continue;
+ if (posix.extname(pathname) && !isHtml) {
+ fail(source, href, 'Missing built file (SPA fallback is not a file)');
+ continue;
+ }
+ // Preserve the requested path: /blog/index.html can render differently from
+ // /blog in a client router, and trailing slashes affect relative links.
+ const key = url.pathname + url.search;
+ if (!pages.has(key)) {
+ progress(`Crawling ${key}`);
+ try {
+ const page = await renderPage(key);
+ if (page.status >= 400) throw new Error(`HTTP ${page.status}`);
+ if (!page.heading || /not found|too stealth for us/i.test(page.heading))
+ throw new Error('Rendered page is missing or not found');
+ pages.set(key, page);
+ for (const link of page.links) addLink(link, key);
+ } catch (error) {
+ pages.set(key, null);
+ fail(source, href, `Page failed: ${error.message}`);
+ }
+ }
+ const page = pages.get(key);
+ if (page && url.hash) {
+ try {
+ const anchor = decodeURIComponent(url.hash.slice(1)).split(':~:text=')[0];
+ if (anchor && !page.anchors.includes(anchor) && anchor.toLowerCase() !== 'top') {
+ fail(source, href, `Missing anchor #${anchor}`);
+ }
+ } catch {
+ fail(source, href, 'Malformed anchor encoding');
+ }
+ }
+ }
+
+ if (!internalOnly) {
+ const queue = [...external];
+ await Promise.all(
+ Array.from({ length: Math.min(6, queue.length) }, async () => {
+ while (queue.length) {
+ const [url, sources] = queue.shift();
+ progress(`Checking ${url}`);
+ const result = await externalCheck(url);
+ if (result.ok) continue;
+ const exception = allowlist.find(
+ (entry) => entry.url === url && entry.failures.includes(result.status),
+ );
+ const finding = {
+ source: [...sources].join(', '),
+ target: url,
+ message: `External ${result.status}${result.detail ? `: ${result.detail}` : ''}`,
+ };
+ if (exception) allowed.push({ ...finding, reason: exception.reason });
+ else errors.push(finding);
+ }
+ }),
+ );
+ }
+ return {
+ routes: routes.size,
+ pages: pages.size,
+ feeds: feeds.size,
+ links: links.size,
+ external: external.size,
+ internalOnly,
+ allowed,
+ errors,
+ };
+}
diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts
index e68ecd1..8ee4caa 100644
--- a/scripts/validate-content.ts
+++ b/scripts/validate-content.ts
@@ -114,6 +114,17 @@ function requireUrl(ctx: Ctx, obj: Record