From 931c2a8726a289a9c353c024d9bfd667013101e9 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sat, 17 Oct 2026 12:13:23 +0300 Subject: [PATCH 1/5] feat(ci): add a link checker with SSRF-safe external requests scripts/check-links.ts renders every public route from the sitemap in Chromium and checks routes, internal links and #fragments, files, canonical and hreflang URLs, RSS feeds and approved external links. It runs against the build (served by scripts/preview-server.ts) or production with --base. External links must use a host from scripts/link-check/external-links.json and are requested through safe-request.ts: http(s) on ports 80 and 443 only, every resolved address must be public (IPv4-mapped, NAT64 and 6to4 forms are judged by the IPv4 address they carry), each hop connects to the validated address through a pinned lookup so DNS rebinding cannot redirect it, and redirects are re-validated. The browser is locked to the site under test. Requests run with bounded concurrency and retry transient failures. The 404 page gets a data-page marker so the checker can detect it. --- README.md | 26 ++ package.json | 2 + scripts/check-links.ts | 489 +++++++++++++++++++++++++ scripts/link-check/external-links.json | 41 +++ scripts/link-check/external.ts | 130 +++++++ scripts/link-check/ip-policy.ts | 163 +++++++++ scripts/link-check/pool.ts | 61 +++ scripts/link-check/safe-request.ts | 200 ++++++++++ scripts/link-check/site.ts | 87 +++++ src/pages/NotFound.tsx | 2 +- tests/link-check/external.test.ts | 132 +++++++ tests/link-check/ip-policy.test.ts | 120 ++++++ tests/link-check/pool.test.ts | 54 +++ tests/link-check/safe-request.test.ts | 159 ++++++++ tests/link-check/site.test.ts | 77 ++++ 15 files changed, 1742 insertions(+), 1 deletion(-) create mode 100644 scripts/check-links.ts create mode 100644 scripts/link-check/external-links.json create mode 100644 scripts/link-check/external.ts create mode 100644 scripts/link-check/ip-policy.ts create mode 100644 scripts/link-check/pool.ts create mode 100644 scripts/link-check/safe-request.ts create mode 100644 scripts/link-check/site.ts create mode 100644 tests/link-check/external.test.ts create mode 100644 tests/link-check/ip-policy.test.ts create mode 100644 tests/link-check/pool.test.ts create mode 100644 tests/link-check/safe-request.test.ts create mode 100644 tests/link-check/site.test.ts diff --git a/README.md b/README.md index dbcac74..fd8340b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,32 @@ 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. +## Link checks + +```bash +pnpm build && pnpm check:links # the build, served by scripts/preview-server.ts +pnpm check:links --base https://usewraith.xyz # production +pnpm test:link-check # unit tests for the checker +``` + +`scripts/check-links.ts` starts from the sitemap and renders every public route in +Chromium. It fails on routes that answer with an error or the 404 page, internal links +to missing routes, files or `#fragments`, canonical and hreflang URLs that are missing, +duplicated, off-site or not the page itself, feeds that are not served as XML, and +external links. `--report ` writes the results as JSON and `--no-external` skips +external links. + +External links must point at a host listed in `scripts/link-check/external-links.json`. +Add a host there when the site starts linking to it, and give a `reason` whenever an +entry needs `acceptStatus` or `skipRequest`. Approved links are requested through +`scripts/link-check/safe-request.ts`: only public addresses are allowed (loopback, +private, link-local, IPv4-mapped forms of those, and so on are refused), each hop +connects to the address its DNS lookup validated, and redirects are re-validated. The +browser that renders pages can only load the site under test. + +CI runs the checker on every pull request against the build, and every day against +production. + ## 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..b03a8cc 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "test:e2e": "playwright test", "test:a11y": "vitest run src/__tests__/a11y.test.tsx", "test:a11y:playwright": "playwright test tests/a11y", + "test:link-check": "vitest run tests/link-check", + "check:links": "tsx scripts/check-links.ts", "format": "prettier --write .", "format:check": "prettier --check .", "lint:i18n": "node scripts/lint-i18n.mjs", diff --git a/scripts/check-links.ts b/scripts/check-links.ts new file mode 100644 index 0000000..14884d3 --- /dev/null +++ b/scripts/check-links.ts @@ -0,0 +1,489 @@ +/** + * Link checker for the built site or for production. + * + * Starting from the sitemap, it renders every public route in Chromium and checks: + * + * - routes: each page loads with HTTP 200 and does not render the 404 page; + * - internal links: every link to the site resolves to a route that renders, + * a file that exists, and an element id when it has a #fragment; + * - canonical URLs: one per page, absolute, on https://usewraith.xyz, and a + * route that renders; hreflang alternates must render too; + * - feeds: every RSS feed the pages advertise (plus /feed.xml) is served as + * XML, and the posts it links to render; + * - external links: hosts must be listed in scripts/link-check/external-links.json, + * and approved links must answer 2xx (or a documented acceptStatus). + * + * External links are requested through safe-request.ts: only public addresses, + * DNS pinned per hop, redirects re-validated. The browser itself is locked to the + * checked origin, so content cannot make it load anything else. Requests run + * with bounded concurrency and retry transient failures. + * + * Usage + * pnpm build && pnpm check:links # serve dist/ via scripts/preview-server.ts + * pnpm check:links --base https://usewraith.xyz # check production + * + * Options + * --base site to check instead of a local preview of dist/ + * --no-external skip external links + * --report also write the results as JSON + * --concurrency pages rendered at once (default 4) + * --external-concurrency external requests at once (default 6) + * --max-pages stop crawling after n pages (default 400) + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, type Browser, type BrowserContext } from '@playwright/test'; +import { + checkExternalLink, + findApprovedHost, + validateApprovedHosts, + type ExternalLinkResult, +} from './link-check/external.ts'; +import { mapLimit } from './link-check/pool.ts'; +import { + SITE_ORIGIN, + classifyLink, + isAssetPath, + normalizeRoute, + parseRssFeed, + parseSitemap, +} from './link-check/site.ts'; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const allowlistFile = join(rootDir, 'scripts', 'link-check', 'external-links.json'); + +interface Options { + base?: string; + external: boolean; + report?: string; + concurrency: number; + externalConcurrency: number; + maxPages: number; +} + +interface PageResult { + path: string; + status?: number; + notFound: boolean; + error?: string; + canonicals: string[]; + ids: Set; +} + +type Failure = { category: string; source: string; target: string; message: string }; + +function parseArgs(argv: string[]): Options { + const value = (flag: string) => { + const index = argv.indexOf(flag); + return index === -1 ? undefined : argv[index + 1]; + }; + const number = (flag: string, fallback: number) => { + const raw = value(flag); + const parsed = raw === undefined ? fallback : Number(raw); + if (!Number.isInteger(parsed) || parsed < 1) + throw new Error(`${flag} needs a positive integer`); + return parsed; + }; + return { + base: value('--base')?.replace(/\/+$/, ''), + external: !argv.includes('--no-external'), + report: value('--report'), + concurrency: number('--concurrency', 4), + externalConcurrency: number('--external-concurrency', 6), + maxPages: number('--max-pages', 400), + }; +} + +/** Serves dist/ with the Vercel rewrites, like the smoke tests do. */ +function startPreview(): Promise<{ baseUrl: string; child: ChildProcess }> { + const child = spawn( + process.execPath, + ['--import', 'tsx', 'scripts/preview-server.ts', '--port', '0'], + { cwd: rootDir, env: { ...process.env }, stdio: ['ignore', 'pipe', 'inherit'] }, + ); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('preview-server did not start within 60 s (run `pnpm build` first)')); + }, 60_000); + let output = ''; + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + output += chunk; + const baseUrl = /listening on (http:\/\/127\.0\.0\.1:\d+)/.exec(output)?.[1]; + if (baseUrl) { + clearTimeout(timer); + resolve({ baseUrl, child }); + } + }); + child.on('exit', (code) => { + clearTimeout(timer); + reject(new Error(`preview-server exited with code ${code}`)); + }); + }); +} + +async function fetchText(url: string): Promise<{ status: number; type: string; body: string }> { + const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30_000) }); + return { + status: response.status, + type: response.headers.get('content-type') ?? '', + body: await response.text(), + }; +} + +async function newLockedContext(browser: Browser, baseOrigin: string): Promise { + const context = await browser.newContext({ serviceWorkers: 'block' }); + // The browser may only talk to the site under test. Third-party scripts, + // images and API calls are aborted, so page content cannot make CI reach + // arbitrary hosts; external links are checked by safe-request.ts instead. + await context.route('**/*', (route) => { + const url = route.request().url(); + const sameOrigin = url.startsWith(`${baseOrigin}/`) || url === baseOrigin; + return sameOrigin ? route.continue() : route.abort('blockedbyclient'); + }); + return context; +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const allowlist = JSON.parse(await readFile(allowlistFile, 'utf8')) as { approvedHosts: unknown }; + const approvedHosts = validateApprovedHosts(allowlist.approvedHosts); + + const preview = options.base ? undefined : await startPreview(); + const base = options.base ?? (preview?.baseUrl as string); + const baseOrigin = new URL(base).origin; + const failures: Failure[] = []; + const fail = (category: string, source: string, target: string, message: string) => + failures.push({ category, source, target, message }); + + const pages = new Map(); + const queued = new Set(); + const queue: string[] = []; + const assets = new Map>(); + const feeds = new Map>(); + const fragments: Array<{ source: string; path: string; fragment: string }> = []; + const externalLinks = new Map>(); + const routeReferrers = new Map>(); + + const remember = (map: Map>, key: string, source: string) => { + const sources = map.get(key) ?? new Set(); + sources.add(source); + map.set(key, sources); + }; + const enqueueRoute = (path: string, source: string) => { + const route = normalizeRoute(path); + remember(routeReferrers, route, source); + if (queued.has(route)) return; + if (queued.size >= options.maxPages) { + fail('crawl', source, route, `skipped: --max-pages ${options.maxPages} reached`); + return; + } + queued.add(route); + queue.push(route); + }; + const handleTarget = (href: string, pageUrl: string, source: string) => { + const target = classifyLink(href, pageUrl, baseOrigin); + if (target.kind === 'invalid') fail('internal', source, href, target.reason); + if (target.kind === 'external') remember(externalLinks, target.url, source); + if (target.kind !== 'internal') return; + if (isAssetPath(target.path)) { + if (target.path.endsWith('.xml') && target.path.includes('feed')) { + remember(feeds, target.path, source); + } else { + remember(assets, target.path, source); + } + return; + } + enqueueRoute(target.path, source); + if (target.fragment) { + fragments.push({ source, path: normalizeRoute(target.path), fragment: target.fragment }); + } + }; + + try { + // ── Seeds: the sitemap and the main feed ───────────────────────────────── + const sitemap = await fetchText(`${base}/sitemap.xml`); + if (sitemap.status !== 200) { + fail('sitemap', '/sitemap.xml', '/sitemap.xml', `HTTP ${sitemap.status}`); + } else { + for (const loc of parseSitemap(sitemap.body)) { + const url = new URL(loc); + if (url.origin !== SITE_ORIGIN) { + fail('sitemap', '/sitemap.xml', loc, `sitemap URL is not on ${SITE_ORIGIN}`); + continue; + } + enqueueRoute(url.pathname, '/sitemap.xml'); + } + } + enqueueRoute('/', 'seed'); + remember(feeds, '/feed.xml', 'seed'); + + // ── Routes, rendered in Chromium ───────────────────────────────────────── + const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); + const context = await newLockedContext(browser, baseOrigin); + + const renderRoute = async (path: string): Promise => { + const page = await context.newPage(); + const result: PageResult = { path, notFound: false, canonicals: [], ids: new Set() }; + pages.set(path, result); + try { + const response = await page.goto(`${base}${path}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + result.status = response?.status(); + await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => undefined); + await page + .waitForFunction(() => (document.getElementById('root')?.childElementCount ?? 0) > 0, { + timeout: 15_000, + }) + .catch(() => undefined); + // Lazily rendered sections (React.lazy, intersection observers) load on scroll + await page + .evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + .catch(() => undefined); + await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined); + + const data = await page.evaluate(() => ({ + url: location.href, + notFound: document.querySelector('[data-page="not-found"]') !== null, + anchors: [...document.querySelectorAll('a[href]')].map((a) => a.getAttribute('href')!), + canonicals: [...document.querySelectorAll('link[rel="canonical"]')].map( + (link) => (link as HTMLLinkElement).href, + ), + alternates: [...document.querySelectorAll('link[rel="alternate"]')].map((link) => ({ + href: (link as HTMLLinkElement).href, + type: (link as HTMLLinkElement).type, + })), + ids: [...document.querySelectorAll('[id], a[name]')].map( + (element) => element.id || element.getAttribute('name') || '', + ), + })); + + result.notFound = data.notFound; + result.canonicals = [...new Set(data.canonicals)]; + result.ids = new Set(data.ids.filter(Boolean)); + for (const href of data.anchors) handleTarget(href, data.url, path); + for (const canonical of result.canonicals) { + const url = new URL(canonical); + if (url.origin !== SITE_ORIGIN) { + fail('canonical', path, canonical, `canonical URL must be on ${SITE_ORIGIN}`); + } else { + enqueueRoute(url.pathname, `${path} (canonical)`); + } + } + for (const alternate of data.alternates) { + if (/rss|atom/.test(alternate.type)) { + const target = classifyLink(alternate.href, data.url, baseOrigin); + if (target.kind === 'internal') remember(feeds, target.path, path); + else fail('feed', path, alternate.href, 'feed alternate is not on the site'); + } else { + const url = new URL(alternate.href); + if (url.origin !== SITE_ORIGIN) { + fail('canonical', path, alternate.href, `hreflang URL must be on ${SITE_ORIGIN}`); + } else { + enqueueRoute(url.pathname, `${path} (hreflang)`); + } + } + } + } catch (error) { + result.error = (error as Error).message.split('\n')[0]; + } finally { + await page.close(); + } + }; + + while (queue.length > 0) { + const batch = queue.splice(0, queue.length); + await mapLimit(batch, options.concurrency, renderRoute); + } + + // ── Feeds ──────────────────────────────────────────────────────────────── + for (const [path, sources] of feeds) { + const source = [...sources][0] as string; + const response = await fetchText(`${base}${path}`); + if (response.status !== 200 || !/xml/.test(response.type)) { + fail( + 'feed', + source, + path, + `expected an XML feed, got HTTP ${response.status} ${response.type}`, + ); + continue; + } + try { + const feed = parseRssFeed(response.body); + for (const link of [feed.channelLink, ...feed.itemLinks]) { + if (link) handleTarget(link, `${SITE_ORIGIN}${path}`, path); + } + } catch (error) { + fail('feed', source, path, (error as Error).message); + } + } + // Posts linked from feeds may not have been rendered yet + while (queue.length > 0) { + const batch = queue.splice(0, queue.length); + await mapLimit(batch, options.concurrency, renderRoute); + } + + // Before reporting a missing #fragment, render its page once more with a + // longer settle time, in case the section rendered late. + const missingFragment = (path: string, fragment: string) => { + const target = pages.get(path); + return target !== undefined && !target.error && !target.notFound && !target.ids.has(fragment); + }; + const recheck = [ + ...new Set(fragments.filter((f) => missingFragment(f.path, f.fragment)).map((f) => f.path)), + ]; + await mapLimit(recheck, options.concurrency, async (path) => { + const page = await context.newPage(); + try { + await page.goto(`${base}${path}`, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(3_000); + const ids = await page.evaluate(() => + [...document.querySelectorAll('[id], a[name]')].map( + (element) => element.id || element.getAttribute('name') || '', + ), + ); + for (const id of ids) if (id) pages.get(path)?.ids.add(id); + } catch { + // Keep the first render's result + } finally { + await page.close(); + } + }); + await browser.close(); + + // ── Route results ──────────────────────────────────────────────────────── + for (const result of pages.values()) { + const source = [...(routeReferrers.get(result.path) ?? new Set(['seed']))].join(', '); + if (result.error) fail('route', source, result.path, result.error); + else if (result.status !== 200) fail('route', source, result.path, `HTTP ${result.status}`); + else if (result.notFound) fail('route', source, result.path, 'renders the 404 page'); + else if (result.canonicals.length === 0) { + fail('canonical', result.path, result.path, 'no canonical URL'); + } else if (result.canonicals.length > 1) { + fail('canonical', result.path, result.canonicals.join(' '), 'more than one canonical URL'); + } else { + const canonicalPath = normalizeRoute(new URL(result.canonicals[0] as string).pathname); + if (canonicalPath !== result.path) { + fail( + 'canonical', + result.path, + result.canonicals[0] as string, + 'canonical URL is not this page', + ); + } + } + } + for (const { source, path, fragment } of fragments) { + if (missingFragment(path, fragment)) { + fail('internal', source, `${path}#${fragment}`, `no element with id "${fragment}"`); + } + } + + // ── Files ──────────────────────────────────────────────────────────────── + await mapLimit([...assets], options.concurrency, async ([path, sources]) => { + const response = await fetch(`${base}${path}`, { method: 'HEAD' }); + const type = response.headers.get('content-type') ?? ''; + // The SPA fallback answers unknown paths with index.html and HTTP 200. + if (response.status !== 200 || type.startsWith('text/html')) { + fail( + 'internal', + [...sources].join(', '), + path, + `file not found (HTTP ${response.status} ${type})`, + ); + } + }); + + // ── External links ─────────────────────────────────────────────────────── + let externalResults: ExternalLinkResult[] = []; + let skippedExternal = 0; + if (options.external) { + const approvedLinks: Array<[string, NonNullable>]> = []; + for (const [url, sources] of externalLinks) { + const approved = findApprovedHost(new URL(url).hostname, approvedHosts); + if (approved?.skipRequest) skippedExternal += 1; + else if (approved) approvedLinks.push([url, approved]); + else { + fail( + 'external', + [...sources].join(', '), + url, + 'host is not approved in scripts/link-check/external-links.json', + ); + } + } + externalResults = await mapLimit( + approvedLinks, + options.externalConcurrency, + ([url, approved]) => + checkExternalLink(url, approved, { retries: 2, baseDelayMs: 1_000, maxDelayMs: 10_000 }), + ); + for (const result of externalResults) { + if (!result.ok) { + const sources = [...(externalLinks.get(result.url) ?? [])].join(', '); + const detail = result.error ?? `HTTP ${result.status}`; + fail('external', sources, result.url, `${detail} after ${result.attempts} attempt(s)`); + } + } + } + + // ── Report ─────────────────────────────────────────────────────────────── + const summary = { + base, + routes: pages.size, + files: assets.size, + feeds: feeds.size, + fragments: fragments.length, + externalLinks: externalLinks.size, + externalChecked: externalResults.length, + failures: failures.length, + }; + if (options.report) { + const external = [...externalLinks].map(([url, sources]) => ({ url, sources: [...sources] })); + await writeFile( + options.report, + `${JSON.stringify({ summary, failures, externalResults, external }, null, 2)}\n`, + ); + } + + console.log( + [ + `Checked ${base}`, + ` routes rendered: ${summary.routes}`, + ` files: ${summary.files}`, + ` feeds: ${summary.feeds}`, + ` fragments: ${summary.fragments}`, + ` external links: ${summary.externalLinks}` + + (options.external + ? ` (${summary.externalChecked} requested, ${skippedExternal} approved without a request)` + : ' (skipped)'), + ].join('\n'), + ); + if (failures.length > 0) { + console.error(`\n${failures.length} link problem(s):`); + for (const failure of failures.sort((a, b) => a.category.localeCompare(b.category))) { + console.error( + ` [${failure.category}] ${failure.target}\n ${failure.message}\n linked from ${failure.source}`, + ); + } + process.exitCode = 1; + } else { + console.log('\nAll links are healthy.'); + } + } finally { + preview?.child.kill('SIGTERM'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/link-check/external-links.json b/scripts/link-check/external-links.json new file mode 100644 index 0000000..f615e5d --- /dev/null +++ b/scripts/link-check/external-links.json @@ -0,0 +1,41 @@ +{ + "$comment": "Hosts the site may link to. scripts/check-links.ts fails on links to any other host, and requests links to these hosts through safe-request.ts. Keep entries narrow: exact hosts, and acceptStatus or skipRequest only for a documented false positive.", + "approvedHosts": [ + { "host": "docs.usewraith.xyz" }, + { "host": "demo.usewraith.xyz" }, + { "host": "console.usewraith.xyz" }, + { "host": "github.com" }, + { + "host": "www.npmjs.com", + "acceptStatus": [403], + "reason": "npm answers automated clients with a Cloudflare challenge (HTTP 403)." + }, + { "host": "eips.ethereum.org" }, + { "host": "stellar.org" }, + { "host": "developers.stellar.org" }, + { "host": "soroban.stellar.org" }, + { "host": "stellar.expert" }, + { "host": "www.drips.network" }, + { "host": "aqua.network" }, + { "host": "albedo.link" }, + { "host": "blend.capital" }, + { "host": "freighter.app" }, + { "host": "horizen.io" }, + { "host": "lobstr.co" }, + { "host": "reflector.network" }, + { "host": "soroswap.finance" }, + { "host": "xbull.app" }, + { "host": "plausible.io" }, + { "host": "fonts.google.com" }, + { + "host": "twitter.com", + "skipRequest": true, + "reason": "X serves the same login-walled app shell for every handle and blocks automated clients, so a request cannot tell a live profile from a missing one." + }, + { + "host": "x.com", + "skipRequest": true, + "reason": "X serves the same login-walled app shell for every handle and blocks automated clients, so a request cannot tell a live profile from a missing one." + } + ] +} diff --git a/scripts/link-check/external.ts b/scripts/link-check/external.ts new file mode 100644 index 0000000..44083b7 --- /dev/null +++ b/scripts/link-check/external.ts @@ -0,0 +1,130 @@ +/** + * External link policy: which hosts the site may link to, and how a link to + * one of them is checked. + */ + +import { retry, type RetryPolicy } from './pool.ts'; +import { safeRequest, UnsafeDestinationError, type SafeRequestOptions } from './safe-request.ts'; + +export interface ApprovedHost { + /** Exact hostname, or `*.example.com` for subdomains of example.com. */ + host: string; + /** Statuses that still count as reachable, e.g. a bot challenge. */ + acceptStatus?: number[]; + /** + * Approve links to the host without requesting them, for sites whose answer + * to an automated client says nothing about the link (login walls, bot blocks). + */ + skipRequest?: boolean; + /** Why acceptStatus or skipRequest is needed. Required with either. */ + reason?: string; +} + +export interface ExternalLinkResult { + url: string; + ok: boolean; + status?: number; + finalUrl?: string; + attempts: number; + error?: string; +} + +const TRANSIENT_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); + +export function validateApprovedHosts(entries: unknown): ApprovedHost[] { + if (!Array.isArray(entries)) throw new Error('approvedHosts must be an array'); + return entries.map((entry, index) => { + const value = entry as ApprovedHost; + if (typeof value?.host !== 'string' || !/^(\*\.)?[a-z0-9.-]+$/.test(value.host)) { + throw new Error(`approvedHosts[${index}].host must be a lowercase hostname`); + } + if ((value.acceptStatus !== undefined || value.skipRequest) && !value.reason) { + throw new Error( + `approvedHosts[${index}] (${value.host}) sets acceptStatus or skipRequest without a reason`, + ); + } + return value; + }); +} + +export function findApprovedHost( + hostname: string, + approved: readonly ApprovedHost[], +): ApprovedHost | undefined { + const host = hostname.toLowerCase(); + return approved.find((entry) => + entry.host.startsWith('*.') ? host.endsWith(entry.host.slice(1)) : host === entry.host, + ); +} + +function retryAfterMs(value: string | string[] | undefined): number | undefined { + const header = Array.isArray(value) ? value[0] : value; + if (!header) return undefined; + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const date = Date.parse(header); + return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); +} + +function isTransientError(error: unknown): boolean { + if (error instanceof UnsafeDestinationError) return false; + const code = (error as NodeJS.ErrnoException).code; + const message = (error as Error).message ?? ''; + return ( + ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'EPIPE', 'ENETUNREACH'].includes( + code ?? '', + ) || /timed out|socket hang up|EAI_AGAIN/.test(message) + ); +} + +/** + * Checks one approved external link: HEAD first, then GET when HEAD is refused + * or fails, retrying network errors and transient statuses. + */ +export async function checkExternalLink( + url: string, + approved: ApprovedHost, + policy: RetryPolicy, + requestOptions: SafeRequestOptions = {}, + request: typeof safeRequest = safeRequest, +): Promise { + const accepted = new Set(approved.acceptStatus ?? []); + const isOk = (status: number) => (status >= 200 && status < 300) || accepted.has(status); + + const { value, attempts } = await retry(async () => { + try { + let response = await request(url, { ...requestOptions, method: 'HEAD' }); + if (!isOk(response.status)) { + // Plenty of servers answer HEAD with 403, 404 or 405 but serve GET fine. + response = await request(url, { ...requestOptions, method: 'GET' }); + } + const result: ExternalLinkResult = { + url, + ok: isOk(response.status), + status: response.status, + finalUrl: response.url, + attempts: 0, + }; + if (!result.ok && TRANSIENT_STATUSES.has(response.status)) { + return { + done: false, + value: result, + delayMs: retryAfterMs(response.headers['retry-after']), + }; + } + return { done: true, value: result }; + } catch (error) { + const result: ExternalLinkResult = { + url, + ok: false, + attempts: 0, + error: (error as Error).message, + }; + return isTransientError(error) + ? { done: false, value: result } + : { done: true, value: result }; + } + }, policy); + + return { ...value, attempts }; +} diff --git a/scripts/link-check/ip-policy.ts b/scripts/link-check/ip-policy.ts new file mode 100644 index 0000000..393206c --- /dev/null +++ b/scripts/link-check/ip-policy.ts @@ -0,0 +1,163 @@ +/** + * Decides whether the link checker may connect to an IP address. + * + * Only globally routable unicast addresses pass. Loopback, private, link-local, + * shared (CGNAT), documentation, benchmarking, multicast and reserved ranges are + * blocked, and IPv6 forms that carry an IPv4 address (IPv4-mapped, NAT64, 6to4) + * are judged by the IPv4 address they embed, so `::ffff:127.0.0.1` and + * `::ffff:7f00:1` are loopback like `127.0.0.1`. Anything that does not parse as + * an IP address is blocked. + */ + +import { isIPv4 } from 'node:net'; + +export type AddressVerdict = { blocked: false } | { blocked: true; reason: string }; + +type Range = { base: bigint; prefix: number; reason: string }; + +const IPV4_BLOCKED: Array<[string, number, string]> = [ + ['0.0.0.0', 8, '"this network" (0.0.0.0/8)'], + ['10.0.0.0', 8, 'private (10.0.0.0/8)'], + ['100.64.0.0', 10, 'shared address space (100.64.0.0/10)'], + ['127.0.0.0', 8, 'loopback (127.0.0.0/8)'], + ['169.254.0.0', 16, 'link-local (169.254.0.0/16)'], + ['172.16.0.0', 12, 'private (172.16.0.0/12)'], + ['192.0.0.0', 24, 'IETF protocol assignments (192.0.0.0/24)'], + ['192.0.2.0', 24, 'documentation (192.0.2.0/24)'], + ['192.88.99.0', 24, '6to4 relay anycast (192.88.99.0/24)'], + ['192.168.0.0', 16, 'private (192.168.0.0/16)'], + ['198.18.0.0', 15, 'benchmarking (198.18.0.0/15)'], + ['198.51.100.0', 24, 'documentation (198.51.100.0/24)'], + ['203.0.113.0', 24, 'documentation (203.0.113.0/24)'], + ['224.0.0.0', 4, 'multicast (224.0.0.0/4)'], + ['240.0.0.0', 4, 'reserved (240.0.0.0/4)'], +]; + +const IPV6_BLOCKED: Array<[string, number, string]> = [ + ['2001:db8::', 32, 'documentation (2001:db8::/32)'], + ['2001:10::', 28, 'ORCHID (2001:10::/28)'], + ['2001:20::', 28, 'ORCHIDv2 (2001:20::/28)'], + ['3fff::', 20, 'documentation (3fff::/20)'], +]; + +const GLOBAL_UNICAST_V6: Range = { base: 0x2n << 124n, prefix: 3, reason: 'not global unicast' }; +const IPV4_MAPPED: Range = { base: 0xffffn << 32n, prefix: 96, reason: 'IPv4-mapped' }; +const IPV4_COMPATIBLE: Range = { base: 0n, prefix: 96, reason: 'IPv4-compatible (deprecated)' }; +const NAT64: Range = { base: 0x64ff9bn << 96n, prefix: 96, reason: 'NAT64' }; +const SIX_TO_FOUR: Range = { base: 0x2002n << 112n, prefix: 16, reason: '6to4' }; +const TEREDO: Range = { base: 0x20010000n << 96n, prefix: 32, reason: 'Teredo (2001::/32)' }; + +const v4Ranges: Range[] = IPV4_BLOCKED.map(([base, prefix, reason]) => ({ + base: parseIPv4(base) as bigint, + prefix, + reason, +})); +const v6Ranges: Range[] = IPV6_BLOCKED.map(([base, prefix, reason]) => ({ + base: parseIPv6(base) as bigint, + prefix, + reason, +})); + +function inRange(value: bigint, range: Range, bits: number): boolean { + const shift = BigInt(bits - range.prefix); + return value >> shift === range.base >> shift; +} + +/** Parses a dotted-quad IPv4 address into a 32-bit integer, or null. */ +export function parseIPv4(address: string): bigint | null { + if (!isIPv4(address)) return null; + const parts = address.split('.'); + if (parts.length !== 4 || parts.some((part) => !/^(0|[1-9]\d{0,2})$/.test(part))) return null; + return parts.reduce((value, part) => (value << 8n) | BigInt(Number(part)), 0n); +} + +/** Parses an IPv6 address (optionally bracketed, with an IPv4 tail) into a 128-bit integer. */ +export function parseIPv6(input: string): bigint | null { + let address = input.startsWith('[') && input.endsWith(']') ? input.slice(1, -1) : input; + if (address.includes('%')) return null; // zone identifiers are link-local by definition + + // A trailing dotted IPv4 address (::ffff:127.0.0.1) fills the last two groups + let tail: bigint[] = []; + if (address.includes('.')) { + const lastColon = address.lastIndexOf(':'); + const v4 = parseIPv4(address.slice(lastColon + 1)); + if (v4 === null) return null; + tail = [v4 >> 16n, v4 & 0xffffn]; + const head = address.slice(0, lastColon + 1); + address = head.endsWith('::') ? head : head.slice(0, -1); + } + + const halves = address.split('::'); + if (halves.length > 2) return null; + const parse = (part: string): bigint[] | null => { + if (part === '') return []; + const groups = part.split(':'); + if (groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null; + return groups.map((group) => BigInt(parseInt(group, 16))); + }; + + const head = parse(halves[0] ?? ''); + const rest = halves.length === 2 ? parse(halves[1] ?? '') : []; + if (head === null || rest === null) return null; + + const explicit = head.length + rest.length + tail.length; + let groups: bigint[]; + if (halves.length === 2) { + if (explicit > 7) return null; + groups = [...head, ...Array(8 - explicit).fill(0n), ...rest, ...tail]; + } else { + groups = [...head, ...tail]; + } + if (groups.length !== 8) return null; + return groups.reduce((value, group) => (value << 16n) | group, 0n); +} + +function formatIPv4(value: bigint): string { + return [24n, 16n, 8n, 0n].map((shift) => String((value >> shift) & 0xffn)).join('.'); +} + +function classifyIPv4(value: bigint): AddressVerdict { + const range = v4Ranges.find((candidate) => inRange(value, candidate, 32)); + return range ? { blocked: true, reason: range.reason } : { blocked: false }; +} + +function classifyEmbedded(value: bigint, label: string): AddressVerdict { + const verdict = classifyIPv4(value); + return verdict.blocked + ? { blocked: true, reason: `${label} ${formatIPv4(value)}: ${verdict.reason}` } + : verdict; +} + +function classifyIPv6(value: bigint): AddressVerdict { + if (value === 0n) return { blocked: true, reason: 'unspecified (::)' }; + if (value === 1n) return { blocked: true, reason: 'loopback (::1)' }; + if (inRange(value, IPV4_MAPPED, 128)) { + return classifyEmbedded(value & 0xffffffffn, 'IPv4-mapped'); + } + if (inRange(value, IPV4_COMPATIBLE, 128)) { + return { blocked: true, reason: IPV4_COMPATIBLE.reason }; + } + if (inRange(value, NAT64, 128)) { + return classifyEmbedded(value & 0xffffffffn, 'NAT64'); + } + if (inRange(value, TEREDO, 128)) { + return { blocked: true, reason: TEREDO.reason }; + } + if (inRange(value, SIX_TO_FOUR, 128)) { + return classifyEmbedded((value >> 80n) & 0xffffffffn, '6to4'); + } + if (!inRange(value, GLOBAL_UNICAST_V6, 128)) { + return { blocked: true, reason: 'not global unicast (outside 2000::/3)' }; + } + const range = v6Ranges.find((candidate) => inRange(value, candidate, 128)); + return range ? { blocked: true, reason: range.reason } : { blocked: false }; +} + +/** Classifies an IPv4 or IPv6 address string. Unparseable input is blocked. */ +export function classifyAddress(address: string): AddressVerdict { + const v4 = parseIPv4(address); + if (v4 !== null) return classifyIPv4(v4); + const v6 = parseIPv6(address); + if (v6 !== null) return classifyIPv6(v6); + return { blocked: true, reason: `not an IP address: ${address}` }; +} diff --git a/scripts/link-check/pool.ts b/scripts/link-check/pool.ts new file mode 100644 index 0000000..374f7f1 --- /dev/null +++ b/scripts/link-check/pool.ts @@ -0,0 +1,61 @@ +/** Bounded concurrency and retry helpers for the link checker. */ + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Runs `task` over `items` with at most `limit` calls in flight, keeping the + * results in input order. + */ +export async function mapLimit( + items: readonly T[], + limit: number, + task: (item: T, index: number) => Promise, +): Promise { + if (!Number.isInteger(limit) || limit < 1) { + throw new RangeError(`concurrency limit must be a positive integer, got ${limit}`); + } + const results = new Array(items.length); + let next = 0; + + async function worker(): Promise { + while (next < items.length) { + const index = next++; + results[index] = await task(items[index] as T, index); + } + } + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} + +export interface RetryPolicy { + /** Extra attempts after the first one. */ + retries: number; + baseDelayMs: number; + maxDelayMs: number; + sleep?: (ms: number) => Promise; +} + +export type AttemptOutcome = + | { done: true; value: T } + | { done: false; value: T; delayMs?: number }; + +/** + * Calls `attempt` until it reports done or the retries run out, waiting + * baseDelayMs * 2^n between attempts (or the delay the attempt asks for, such + * as a Retry-After), capped at maxDelayMs. Returns the last value. + */ +export async function retry( + attempt: (attemptNumber: number) => Promise>, + policy: RetryPolicy, +): Promise<{ value: T; attempts: number }> { + const wait = policy.sleep ?? sleep; + for (let attemptNumber = 1; ; attemptNumber++) { + const outcome = await attempt(attemptNumber); + if (outcome.done || attemptNumber > policy.retries) { + return { value: outcome.value, attempts: attemptNumber }; + } + const backoff = policy.baseDelayMs * 2 ** (attemptNumber - 1); + await wait(Math.min(policy.maxDelayMs, outcome.delayMs ?? backoff)); + } +} diff --git a/scripts/link-check/safe-request.ts b/scripts/link-check/safe-request.ts new file mode 100644 index 0000000..75b408b --- /dev/null +++ b/scripts/link-check/safe-request.ts @@ -0,0 +1,200 @@ +/** + * HTTP(S) requests that cannot be pointed at internal infrastructure. + * + * Link targets come from site content, so a pull request could add a link to + * `http://169.254.169.254/` or to a hostname that resolves to a private address + * and turn CI into an SSRF probe. Every hop of a request therefore: + * + * 1. accepts only http(s) URLs without credentials, on ports 80 and 443; + * 2. resolves the hostname once and rejects it if any returned address is not + * globally routable (see ip-policy.ts, which also unwraps IPv4-mapped, + * NAT64 and 6to4 IPv6 addresses); + * 3. connects to exactly that validated address through a pinned `lookup`, so + * a second DNS answer (DNS rebinding) is never used. TLS still verifies the + * certificate against the original hostname; + * 4. follows redirects manually, repeating 1 to 3 for every `Location`. + */ + +import { lookup } from 'node:dns/promises'; +import http, { type IncomingHttpHeaders } from 'node:http'; +import https from 'node:https'; +import { isIP, type LookupFunction } from 'node:net'; +import { classifyAddress, type AddressVerdict } from './ip-policy.ts'; + +export type ResolvedAddress = { address: string; family: 4 | 6 }; +export type Resolver = (hostname: string) => Promise; + +export interface SafeRequestOptions { + method?: 'HEAD' | 'GET'; + timeoutMs?: number; + maxRedirects?: number; + userAgent?: string; + /** DNS resolver; defaults to the system resolver. Injected in tests. */ + resolve?: Resolver; + /** Address policy; defaults to classifyAddress. Injected in tests. */ + classify?: (address: string) => AddressVerdict; + allowedPorts?: readonly number[]; +} + +export interface SafeResponse { + /** URL of the final hop. */ + url: string; + status: number; + headers: IncomingHttpHeaders; + /** Every Location that was followed, in order. */ + redirects: string[]; + /** Validated address the final hop connected to. */ + address: string; +} + +/** The destination is not allowed. Never retried. */ +export class UnsafeDestinationError extends Error { + constructor( + readonly url: string, + readonly reason: string, + ) { + super(`${url}: ${reason}`); + this.name = 'UnsafeDestinationError'; + } +} + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const DEFAULT_USER_AGENT = 'wraith-www-link-checker/1.0 (+https://github.com/wraith-protocol/www)'; + +export const systemResolver: Resolver = async (hostname) => { + const results = await lookup(hostname, { all: true, verbatim: true }); + return results.map(({ address, family }) => ({ address, family: family === 6 ? 6 : 4 })); +}; + +/** + * Checks the URL and resolves its host. Throws UnsafeDestinationError unless + * every address is public, and returns the address the request must use. + */ +export async function resolveSafeTarget( + url: URL, + options: SafeRequestOptions = {}, +): Promise { + const classify = options.classify ?? classifyAddress; + const allowedPorts = options.allowedPorts ?? [80, 443]; + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new UnsafeDestinationError(url.href, `scheme ${url.protocol} is not allowed`); + } + if (url.username || url.password) { + throw new UnsafeDestinationError(url.href, 'credentials in URLs are not allowed'); + } + const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80; + if (!allowedPorts.includes(port)) { + throw new UnsafeDestinationError(url.href, `port ${port} is not allowed`); + } + + // WHATWG URL parsing already normalizes 2130706433, 0x7f.1 and friends to dotted + // quads and IPv6 literals to bracketed, compressed hex. + const hostname = url.hostname.replace(/^\[(.*)\]$/, '$1'); + const literalFamily = isIP(hostname); + if (literalFamily !== 0) { + const verdict = classify(hostname); + if (verdict.blocked) throw new UnsafeDestinationError(url.href, verdict.reason); + return { address: hostname, family: literalFamily === 6 ? 6 : 4 }; + } + + let addresses: ResolvedAddress[]; + try { + addresses = await (options.resolve ?? systemResolver)(hostname); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? (error as Error).message; + throw new Error(`DNS lookup for ${hostname} failed (${code})`); + } + if (addresses.length === 0) { + throw new Error(`DNS lookup for ${hostname} returned no addresses`); + } + for (const { address } of addresses) { + const verdict = classify(address); + if (verdict.blocked) { + throw new UnsafeDestinationError( + url.href, + `${hostname} resolves to ${address}, ${verdict.reason}`, + ); + } + } + return addresses[0] as ResolvedAddress; +} + +/** A `lookup` that always answers with the address validated above. */ +export function pinnedLookup(target: ResolvedAddress): LookupFunction { + return (_hostname, options, callback) => { + if (options?.all) { + callback(null, [{ address: target.address, family: target.family }]); + } else { + callback(null, target.address, target.family); + } + }; +} + +function requestOnce( + url: URL, + target: ResolvedAddress, + options: SafeRequestOptions, +): Promise<{ status: number; headers: IncomingHttpHeaders }> { + const timeoutMs = options.timeoutMs ?? 15_000; + const client = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const request = client.request( + url, + { + method: options.method ?? 'HEAD', + agent: false, + lookup: pinnedLookup(target), + timeout: timeoutMs, + headers: { + 'user-agent': options.userAgent ?? DEFAULT_USER_AGENT, + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }, + (response) => { + resolve({ status: response.statusCode ?? 0, headers: response.headers }); + // Only the status line and headers matter; never download bodies. + response.destroy(); + }, + ); + request.on('timeout', () => request.destroy(new Error(`timed out after ${timeoutMs} ms`))); + request.on('error', reject); + request.end(); + }); +} + +/** Requests `input`, validating and pinning every hop. */ +export async function safeRequest( + input: string, + options: SafeRequestOptions = {}, +): Promise { + const maxRedirects = options.maxRedirects ?? 5; + let url = new URL(input); + url.hash = ''; + const redirects: string[] = []; + + for (;;) { + const target = await resolveSafeTarget(url, options); + const response = await requestOnce(url, target, options); + const location = response.headers.location; + + if (REDIRECT_STATUSES.has(response.status) && location) { + if (redirects.length >= maxRedirects) { + throw new Error(`more than ${maxRedirects} redirects`); + } + url = new URL(location, url); + url.hash = ''; + redirects.push(url.href); + continue; + } + + return { + url: url.href, + status: response.status, + headers: response.headers, + redirects, + address: target.address, + }; + } +} diff --git a/scripts/link-check/site.ts b/scripts/link-check/site.ts new file mode 100644 index 0000000..e6ef26e --- /dev/null +++ b/scripts/link-check/site.ts @@ -0,0 +1,87 @@ +/** Parsing helpers for the link checker: sitemaps, feeds and link targets. */ + +export const SITE_ORIGIN = 'https://usewraith.xyz'; + +function decodeXml(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') + .trim(); +} + +/** Returns the `` URLs of a sitemap. */ +export function parseSitemap(xml: string): string[] { + if (!/]/.test(xml)) throw new Error('not a sitemap: is missing'); + return [...xml.matchAll(/([\s\S]*?)<\/loc>/g)].map((match) => decodeXml(match[1] ?? '')); +} + +export interface ParsedFeed { + /** The channel's own . */ + channelLink?: string; + /** of every item. */ + itemLinks: string[]; +} + +/** Reads an RSS 2.0 feed's channel link and item links. */ +export function parseRssFeed(xml: string): ParsedFeed { + if (!/]/.test(xml) || !/]/.test(xml)) { + throw new Error('not an RSS feed: or is missing'); + } + const items = [...xml.matchAll(/]([\s\S]*?)<\/item>/g)].map((match) => match[1] ?? ''); + const linkOf = (block: string) => { + const link = /([\s\S]*?)<\/link>/.exec(block)?.[1]; + return link === undefined ? undefined : decodeXml(link); + }; + const channel = xml.replace(/][\s\S]*?<\/item>/g, ''); + return { + channelLink: linkOf(channel), + itemLinks: items.map(linkOf).filter((link): link is string => link !== undefined), + }; +} + +export type LinkTarget = + | { kind: 'internal'; path: string; fragment?: string } + | { kind: 'external'; url: string } + | { kind: 'ignored'; reason: string } + | { kind: 'invalid'; reason: string }; + +/** + * Classifies an href found on a page. Links to the checked base URL and to the + * production origin are internal; other http(s) links are external. + */ +export function classifyLink(href: string, pageUrl: string, baseOrigin: string): LinkTarget { + const trimmed = href.trim(); + if (trimmed === '') return { kind: 'invalid', reason: 'empty href' }; + if (/^(mailto|tel|sms):/i.test(trimmed)) return { kind: 'ignored', reason: 'not a web link' }; + if (/^javascript:/i.test(trimmed)) return { kind: 'invalid', reason: 'javascript: link' }; + + let url: URL; + try { + url = new URL(trimmed, pageUrl); + } catch { + return { kind: 'invalid', reason: `unparseable href ${JSON.stringify(href)}` }; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return { kind: 'invalid', reason: `unsupported scheme ${url.protocol}` }; + } + if (url.origin === baseOrigin || url.origin === SITE_ORIGIN) { + const fragment = url.hash ? decodeURIComponent(url.hash.slice(1)) : undefined; + return { kind: 'internal', path: url.pathname, ...(fragment ? { fragment } : {}) }; + } + url.hash = ''; + return { kind: 'external', url: url.href }; +} + +/** Paths with an extension (other than .html) are files, not app routes. */ +export function isAssetPath(path: string): boolean { + const last = path.split('/').pop() ?? ''; + return /\.[a-z0-9]+$/i.test(last) && !/\.html?$/i.test(last); +} + +/** Collapses `/about/` to `/about` so a route is checked once. */ +export function normalizeRoute(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index bb9a8d5..85f4839 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -48,7 +48,7 @@ export default function NotFound() { }; return ( -
+
Wraith diff --git a/tests/link-check/external.test.ts b/tests/link-check/external.test.ts new file mode 100644 index 0000000..96fba35 --- /dev/null +++ b/tests/link-check/external.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + checkExternalLink, + findApprovedHost, + validateApprovedHosts, +} from '../../scripts/link-check/external'; +import { UnsafeDestinationError, type SafeResponse } from '../../scripts/link-check/safe-request'; + +const policy = { + retries: 2, + baseDelayMs: 10, + maxDelayMs: 1_000, + sleep: vi.fn(async () => undefined), +}; + +function response(status: number, headers: Record = {}): SafeResponse { + return { url: 'https://example.com/', status, headers, redirects: [], address: '93.184.216.34' }; +} + +describe('checkExternalLink', () => { + it('falls back to GET when HEAD is refused', async () => { + const request = vi + .fn() + .mockResolvedValueOnce(response(405)) + .mockResolvedValueOnce(response(200)); + const result = await checkExternalLink( + 'https://example.com/', + { host: 'example.com' }, + policy, + {}, + request, + ); + expect(result).toMatchObject({ ok: true, status: 200, attempts: 1 }); + expect(request.mock.calls.map(([, options]) => options.method)).toEqual(['HEAD', 'GET']); + }); + + it('retries transient statuses and honours Retry-After', async () => { + const sleep = vi.fn(async () => undefined); + const request = vi + .fn() + .mockResolvedValueOnce(response(503, { 'retry-after': '0.5' })) + .mockResolvedValueOnce(response(503, { 'retry-after': '0.5' })) + .mockResolvedValueOnce(response(200)); + const result = await checkExternalLink( + 'https://example.com/', + { host: 'example.com' }, + { ...policy, sleep }, + {}, + request, + ); + expect(result).toMatchObject({ ok: true, attempts: 2 }); + expect(sleep).toHaveBeenCalledWith(500); + }); + + it('does not retry a 404', async () => { + const request = vi.fn().mockResolvedValue(response(404)); + const result = await checkExternalLink( + 'https://example.com/', + { host: 'example.com' }, + policy, + {}, + request, + ); + expect(result).toMatchObject({ ok: false, status: 404, attempts: 1 }); + }); + + it('never retries an unsafe destination', async () => { + const request = vi + .fn() + .mockRejectedValue(new UnsafeDestinationError('https://x.test/', 'loopback')); + const result = await checkExternalLink( + 'https://x.test/', + { host: 'x.test' }, + policy, + {}, + request, + ); + expect(result).toMatchObject({ ok: false, attempts: 1 }); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('retries network errors', async () => { + const request = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('reset'), { code: 'ECONNRESET' })) + .mockResolvedValueOnce(response(200)); + const result = await checkExternalLink( + 'https://example.com/', + { host: 'example.com' }, + policy, + {}, + request, + ); + expect(result).toMatchObject({ ok: true, attempts: 2 }); + }); + + it('accepts documented statuses for a host', async () => { + const request = vi.fn().mockResolvedValue(response(403)); + const approved = { host: 'www.npmjs.com', acceptStatus: [403], reason: 'bot challenge' }; + const result = await checkExternalLink( + 'https://www.npmjs.com/x', + approved, + policy, + {}, + request, + ); + expect(result).toMatchObject({ ok: true, status: 403 }); + }); +}); + +describe('approved hosts', () => { + const hosts = validateApprovedHosts([{ host: 'github.com' }, { host: '*.example.org' }]); + + it('matches exact hosts and wildcard subdomains only', () => { + expect(findApprovedHost('github.com', hosts)?.host).toBe('github.com'); + expect(findApprovedHost('GitHub.com', hosts)?.host).toBe('github.com'); + expect(findApprovedHost('gist.github.com', hosts)).toBeUndefined(); + expect(findApprovedHost('docs.example.org', hosts)?.host).toBe('*.example.org'); + expect(findApprovedHost('example.org', hosts)).toBeUndefined(); + expect(findApprovedHost('evilexample.org', hosts)).toBeUndefined(); + }); + + it('requires a reason for acceptStatus and skipRequest', () => { + expect(() => validateApprovedHosts([{ host: 'a.com', acceptStatus: [403] }])).toThrow( + /without a reason/, + ); + expect(() => validateApprovedHosts([{ host: 'a.com', skipRequest: true }])).toThrow( + /without a reason/, + ); + expect(() => validateApprovedHosts([{ host: 'A.com' }])).toThrow(/lowercase hostname/); + }); +}); diff --git a/tests/link-check/ip-policy.test.ts b/tests/link-check/ip-policy.test.ts new file mode 100644 index 0000000..503c39f --- /dev/null +++ b/tests/link-check/ip-policy.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { classifyAddress, parseIPv6 } from '../../scripts/link-check/ip-policy'; + +const blocked = (address: string) => classifyAddress(address).blocked; + +describe('classifyAddress', () => { + it.each([ + '127.0.0.1', + '127.255.255.254', + '10.1.2.3', + '172.16.0.1', + '172.31.255.255', + '192.168.1.1', + '169.254.169.254', + '100.64.0.1', + '0.0.0.0', + '192.0.0.8', + '192.0.2.1', + '198.18.0.1', + '198.51.100.7', + '203.0.113.9', + '224.0.0.1', + '240.0.0.1', + '255.255.255.255', + ])('blocks the IPv4 address %s', (address) => { + expect(blocked(address)).toBe(true); + }); + + it.each([ + '1.1.1.1', + '8.8.8.8', + '172.15.255.255', + '172.32.0.1', + '100.63.255.255', + '100.128.0.1', + '192.169.0.1', + ])('allows the public IPv4 address %s', (address) => { + expect(classifyAddress(address)).toEqual({ blocked: false }); + }); + + it.each([ + '::', + '::1', + 'fe80::1', + 'fc00::1', + 'fd12:3456::1', + 'ff02::1', + '2001:db8::1', + '3fff::1', + '64:ff9b:1::1', + '2001:0:4136:e378:8000:63bf:3fff:fdd2', + '100::1', + ])('blocks the IPv6 address %s', (address) => { + expect(blocked(address)).toBe(true); + }); + + it.each([ + ['::ffff:127.0.0.1', 'loopback'], + ['::ffff:7f00:1', 'loopback'], + ['[::ffff:127.0.0.1]', 'loopback'], + ['::ffff:10.0.0.1', 'private'], + ['::ffff:169.254.169.254', 'link-local'], + ['64:ff9b::7f00:1', 'loopback'], + ['64:ff9b::10.0.0.1', 'private'], + ['2002:7f00:1::1', 'loopback'], + ['2002:c0a8:101::1', 'private'], + ])('judges %s by the IPv4 address it embeds', (address, reason) => { + const verdict = classifyAddress(address); + expect(verdict.blocked).toBe(true); + expect(verdict.blocked && verdict.reason).toContain(reason); + }); + + it('blocks IPv4-compatible IPv6 addresses', () => { + expect(blocked('::127.0.0.1')).toBe(true); + expect(blocked('::1.1.1.1')).toBe(true); + }); + + it.each(['2606:4700:4700::1111', '::ffff:1.1.1.1', '64:ff9b::1.1.1.1', '2002:101:101::1'])( + 'allows the public IPv6 address %s', + (address) => { + expect(classifyAddress(address)).toEqual({ blocked: false }); + }, + ); + + it.each([ + 'localhost', + '', + '1.2.3', + '01.2.3.4', + '1.2.3.256', + '::ffff:1.2.3', + 'g::1', + '1:2:3:4:5:6:7:8:9', + '1::2::3', + 'fe80::1%eth0', + ])('blocks %j, which is not a usable IP address', (address) => { + expect(blocked(address)).toBe(true); + }); + + it('sees through the IPv4 spellings the URL parser normalizes', () => { + for (const url of [ + 'http://2130706433/', + 'http://0x7f.1/', + 'http://0177.0.0.1/', + 'http://127.1/', + ]) { + expect(blocked(new URL(url).hostname)).toBe(true); + } + expect(new URL('http://[::ffff:127.0.0.1]/').hostname).toBe('[::ffff:7f00:1]'); + expect(blocked(new URL('http://[::ffff:127.0.0.1]/').hostname)).toBe(true); + }); +}); + +describe('parseIPv6', () => { + it('expands compressed and dotted forms', () => { + expect(parseIPv6('::1')).toBe(1n); + expect(parseIPv6('::ffff:127.0.0.1')).toBe(parseIPv6('::ffff:7f00:1')); + expect(parseIPv6('2001:db8::1')).toBe(0x20010db8000000000000000000000001n); + }); +}); diff --git a/tests/link-check/pool.test.ts b/tests/link-check/pool.test.ts new file mode 100644 index 0000000..fc8903b --- /dev/null +++ b/tests/link-check/pool.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mapLimit, retry } from '../../scripts/link-check/pool'; + +describe('mapLimit', () => { + it('never runs more than the limit at once and keeps input order', async () => { + let inFlight = 0; + let peak = 0; + const results = await mapLimit([30, 5, 20, 1, 10, 15], 2, async (delay, index) => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, delay)); + inFlight -= 1; + return index; + }); + expect(peak).toBe(2); + expect(results).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it('rejects a limit below one', async () => { + await expect(mapLimit([1], 0, async () => 1)).rejects.toThrow(RangeError); + }); +}); + +describe('retry', () => { + it('backs off exponentially, honours a requested delay and caps it', async () => { + const sleep = vi.fn(async () => undefined); + let calls = 0; + const { value, attempts } = await retry( + async () => { + calls += 1; + if (calls === 1) return { done: false, value: 'first' }; + if (calls === 2) return { done: false, value: 'second', delayMs: 60_000 }; + return { done: true, value: 'third' }; + }, + { retries: 3, baseDelayMs: 100, maxDelayMs: 5_000, sleep }, + ); + expect(value).toBe('third'); + expect(attempts).toBe(3); + expect(sleep.mock.calls).toEqual([[100], [5_000]]); + }); + + it('returns the last value when the retries run out', async () => { + const sleep = vi.fn(async () => undefined); + const { value, attempts } = await retry(async (n) => ({ done: false, value: n }), { + retries: 2, + baseDelayMs: 10, + maxDelayMs: 100, + sleep, + }); + expect(value).toBe(3); + expect(attempts).toBe(3); + expect(sleep.mock.calls).toEqual([[10], [20]]); + }); +}); diff --git a/tests/link-check/safe-request.test.ts b/tests/link-check/safe-request.test.ts new file mode 100644 index 0000000..b55e7cf --- /dev/null +++ b/tests/link-check/safe-request.test.ts @@ -0,0 +1,159 @@ +// Runs real requests against local servers. 127.0.0.1 is blocked by the default +// policy, so these tests inject a policy that allows exactly that address and +// defers everything else to classifyAddress, plus a fake resolver. +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { classifyAddress } from '../../scripts/link-check/ip-policy'; +import { + safeRequest, + UnsafeDestinationError, + type ResolvedAddress, + type SafeRequestOptions, +} from '../../scripts/link-check/safe-request'; + +let server: Server; +let port: number; + +beforeAll(async () => { + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + if (url.pathname === '/ok') { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ok'); + } else if (url.pathname === '/redirect') { + res.writeHead(302, { location: url.searchParams.get('to') ?? '/ok' }); + res.end(); + } else if (url.pathname === '/loop') { + res.writeHead(301, { location: '/loop' }); + res.end(); + } else if (url.pathname === '/hang') { + // never answers + } else { + res.writeHead(404); + res.end(); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; +}); + +afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +/** Resolver that answers from a table and counts lookups per hostname. */ +function fakeResolver(table: Record ResolvedAddress[])>) { + const lookups: Record = {}; + const resolve = async (hostname: string) => { + lookups[hostname] = (lookups[hostname] ?? 0) + 1; + const entry = table[hostname]; + if (!entry) throw Object.assign(new Error('not found'), { code: 'ENOTFOUND' }); + return typeof entry === 'function' ? entry() : entry; + }; + return { resolve, lookups }; +} + +const v4 = (address: string): ResolvedAddress => ({ address, family: 4 }); + +function testOptions(resolve: SafeRequestOptions['resolve']): SafeRequestOptions { + return { + resolve, + allowedPorts: [port], + timeoutMs: 2_000, + classify: (address) => + address === '127.0.0.1' ? { blocked: false } : classifyAddress(address), + }; +} + +describe('safeRequest', () => { + it('connects to the address it validated, even if DNS changes its answer (rebinding)', async () => { + let answers = 0; + const { resolve, lookups } = fakeResolver({ + // First answer passes the policy; every later one points at a private address. + 'rebind.test': () => (answers++ === 0 ? [v4('127.0.0.1')] : [v4('10.0.0.1')]), + }); + const response = await safeRequest(`http://rebind.test:${port}/ok`, testOptions(resolve)); + expect(response.status).toBe(200); + expect(response.address).toBe('127.0.0.1'); + // One validated lookup per hop; the socket used the pinned answer instead of + // resolving rebind.test again (the system resolver does not know it at all). + expect(lookups['rebind.test']).toBe(1); + }); + + it('re-validates every redirect hop', async () => { + const { resolve } = fakeResolver({ + 'site.test': [v4('127.0.0.1')], + 'internal.test': [v4('10.0.0.5')], + }); + const target = encodeURIComponent(`http://internal.test:${port}/ok`); + await expect( + safeRequest(`http://site.test:${port}/redirect?to=${target}`, testOptions(resolve)), + ).rejects.toThrow(/internal\.test resolves to 10\.0\.0\.5, private/); + }); + + it('rejects a redirect to an IPv4-mapped IPv6 loopback literal', async () => { + const { resolve } = fakeResolver({ 'site.test': [v4('127.0.0.1')] }); + const target = encodeURIComponent(`http://[::ffff:127.0.0.1]:${port}/ok`); + const request = safeRequest( + `http://site.test:${port}/redirect?to=${target}`, + testOptions(resolve), + ); + await expect(request).rejects.toBeInstanceOf(UnsafeDestinationError); + await expect(request).rejects.toThrow(/IPv4-mapped 127\.0\.0\.1: loopback/); + }); + + it('follows safe redirects and reports the chain', async () => { + const { resolve } = fakeResolver({ 'site.test': [v4('127.0.0.1')] }); + const response = await safeRequest( + `http://site.test:${port}/redirect?to=%2Fok`, + testOptions(resolve), + ); + expect(response.status).toBe(200); + expect(response.redirects).toEqual([`http://site.test:${port}/ok`]); + }); + + it('stops after too many redirects', async () => { + const { resolve } = fakeResolver({ 'site.test': [v4('127.0.0.1')] }); + await expect( + safeRequest(`http://site.test:${port}/loop`, { ...testOptions(resolve), maxRedirects: 3 }), + ).rejects.toThrow('more than 3 redirects'); + }); + + it('times out a server that never answers', async () => { + const { resolve } = fakeResolver({ 'site.test': [v4('127.0.0.1')] }); + await expect( + safeRequest(`http://site.test:${port}/hang`, { ...testOptions(resolve), timeoutMs: 200 }), + ).rejects.toThrow('timed out after 200 ms'); + }); + + it('rejects a hostname when any of its addresses is private', async () => { + const { resolve } = fakeResolver({ 'mixed.test': [v4('1.1.1.1'), v4('192.168.0.10')] }); + await expect(safeRequest('https://mixed.test/', { resolve })).rejects.toThrow( + /mixed\.test resolves to 192\.168\.0\.10, private/, + ); + }); + + it.each([ + ['ftp://example.com/', /scheme ftp: is not allowed/], + ['http://user:secret@example.com/', /credentials in URLs are not allowed/], + ['http://example.com:8080/', /port 8080 is not allowed/], + ['http://169.254.169.254/latest/meta-data/', /link-local/], + ['http://[::1]/', /loopback/], + ['http://[fd00::1]/', /not global unicast/], + ['http://2130706433/', /loopback/], + ['http://[::ffff:10.0.0.1]/', /IPv4-mapped 10\.0\.0\.1: private/], + ])('refuses %s before connecting', async (url, reason) => { + const { resolve, lookups } = fakeResolver({}); + await expect(safeRequest(url, { resolve })).rejects.toThrow(reason); + expect(Object.keys(lookups)).toEqual([]); + }); + + it('reports DNS failures as errors, not unsafe destinations', async () => { + const { resolve } = fakeResolver({}); + const request = safeRequest('https://missing.test/', { resolve }); + await expect(request).rejects.toThrow('DNS lookup for missing.test failed (ENOTFOUND)'); + await expect(request).rejects.not.toBeInstanceOf(UnsafeDestinationError); + }); +}); diff --git a/tests/link-check/site.test.ts b/tests/link-check/site.test.ts new file mode 100644 index 0000000..2d3bdca --- /dev/null +++ b/tests/link-check/site.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyLink, + isAssetPath, + normalizeRoute, + parseRssFeed, + parseSitemap, +} from '../../scripts/link-check/site'; + +const base = 'http://127.0.0.1:4174'; + +describe('classifyLink', () => { + it('treats the checked origin and the production origin as internal', () => { + expect(classifyLink('/about', `${base}/`, base)).toEqual({ kind: 'internal', path: '/about' }); + expect(classifyLink('https://usewraith.xyz/security#audits', `${base}/`, base)).toEqual({ + kind: 'internal', + path: '/security', + fragment: 'audits', + }); + expect(classifyLink('#compare', `${base}/es`, base)).toEqual({ + kind: 'internal', + path: '/es', + fragment: 'compare', + }); + }); + + it('strips fragments from external links', () => { + expect(classifyLink('https://github.com/wraith-protocol#readme', `${base}/`, base)).toEqual({ + kind: 'external', + url: 'https://github.com/wraith-protocol', + }); + }); + + it('ignores mail links and rejects script and odd schemes', () => { + expect(classifyLink('mailto:security@usewraith.xyz', `${base}/`, base).kind).toBe('ignored'); + expect(classifyLink('javascript:void(0)', `${base}/`, base).kind).toBe('invalid'); + expect(classifyLink('ftp://example.com/file', `${base}/`, base).kind).toBe('invalid'); + expect(classifyLink(' ', `${base}/`, base).kind).toBe('invalid'); + }); +}); + +describe('paths', () => { + it('tells files from routes', () => { + expect(isAssetPath('/press-kit.zip')).toBe(true); + expect(isAssetPath('/.well-known/security.txt')).toBe(true); + expect(isAssetPath('/blog/wave-7-kickoff')).toBe(false); + expect(isAssetPath('/about.html')).toBe(false); + }); + + it('normalizes trailing slashes', () => { + expect(normalizeRoute('/about/')).toBe('/about'); + expect(normalizeRoute('/')).toBe('/'); + }); +}); + +describe('parsers', () => { + it('reads sitemap locations', () => { + const xml = `https://usewraith.xyz/ + https://usewraith.xyz/blog?a=1&b=2`; + expect(parseSitemap(xml)).toEqual([ + 'https://usewraith.xyz/', + 'https://usewraith.xyz/blog?a=1&b=2', + ]); + expect(() => parseSitemap('')).toThrow(/not a sitemap/); + }); + + it('reads the channel link and item links of an RSS feed', () => { + const xml = `Wraithhttps://usewraith.xyz/blog + Ahttps://usewraith.xyz/blog/a + Bhttps://usewraith.xyz/blog/b`; + expect(parseRssFeed(xml)).toEqual({ + channelLink: 'https://usewraith.xyz/blog', + itemLinks: ['https://usewraith.xyz/blog/a', 'https://usewraith.xyz/blog/b'], + }); + expect(() => parseRssFeed('')).toThrow(/not an RSS feed/); + }); +}); From 8502612049e88cef00c57fce9ced300b071bbe16 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sat, 17 Oct 2026 12:13:24 +0300 Subject: [PATCH 2/5] fix: repair the broken links and canonical URLs the link checker found - Register /press and add a real /terms page instead of linking to 404s. - Localized links and hreflang alternates only point at routes that exist under /es and /pt; og.ts no longer emits them for other routes, and the SPA shell no longer carries the homepage canonical onto every fallback route (React 19 hoists Helmet tags next to static ones). - Stellar and Press render their own canonical URLs. - The footer Compare link goes to /#compare, and pages scroll to hash targets once lazy sections render. - Status links use /status until the status host exists. - Chain docs links use /sdk/chains/{evm,stellar,solana,ckb}, Aquarius moved to aqua.network, the grants CTA points at the live Stellar Wave, and footer docs links point at pages that exist. - Audit, bounty and disclosure links go to anchored sections on /security, Spectre goes to its roadmap milestone, and the placeholder PGP key is hidden. - Team GitHub profiles that return 404 are removed. --- scripts/og.ts | 24 ++++-- scripts/sitemap.ts | 1 + src/App.tsx | 6 ++ src/__tests__/Footer.test.tsx | 8 +- src/components/Footer.tsx | 31 +++++--- src/components/Header.tsx | 4 +- src/data/chains.json | 14 ++-- src/data/ecosystem.json | 2 +- src/data/security.json | 10 +-- src/data/team.json | 16 +--- src/data/trust.json | 8 +- src/data/wave.json | 2 +- src/hooks/useLocalePath.ts | 33 +------- src/hooks/useScrollToHash.ts | 25 ++++++ src/pages/About.tsx | 10 ++- src/pages/Governance.tsx | 6 +- src/pages/Press.tsx | 8 ++ src/pages/Roadmap.tsx | 3 + src/pages/Security.tsx | 27 ++++--- src/pages/Stellar.tsx | 24 +++--- src/pages/Terms.tsx | 145 ++++++++++++++++++++++++++++++++++ src/utils/localizedRoutes.ts | 29 +++++++ 22 files changed, 324 insertions(+), 112 deletions(-) create mode 100644 src/hooks/useScrollToHash.ts create mode 100644 src/pages/Terms.tsx create mode 100644 src/utils/localizedRoutes.ts diff --git a/scripts/og.ts b/scripts/og.ts index af4358d..cfcdc30 100644 --- a/scripts/og.ts +++ b/scripts/og.ts @@ -5,6 +5,7 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import React from 'react'; import { listOgImageJobs, type OgImageJob } from '../src/utils/og-metadata'; +import { hasLocalizedRoute } from '../src/utils/localizedRoutes'; const h = React.createElement; @@ -199,16 +200,27 @@ function patchMetadata(html: string, config: OgImageJob): string { const esUrl = `${SITE_URL}/es${cleanPath}`; const ptUrl = `${SITE_URL}/pt${cleanPath}`; + // Only routes registered under /es and /pt get locale alternates. + const localized = hasLocalizedRoute(config.routePath); const hreflangLinks = [ ``, - ``, - ``, - ``, - ``, + ...(localized + ? [ + ``, + ``, + ``, + ``, + ] + : []), ].join('\n '); - // Inject before - patched = patched.replace('', ` ${hreflangLinks}\n `); + // dist/index.html is also the SPA fallback for every route without its own + // file. React 19 hoists each page's Helmet tags without removing static ones, + // so a canonical in that shell would sit next to the page's own canonical on + // every fallback route. The homepage gets its canonical from Helmet instead. + if (config.routePath !== '/') { + patched = patched.replace('', ` ${hreflangLinks}\n `); + } // Breadcrumb JSON-LD const breadcrumbListElement: any[] = [ diff --git a/scripts/sitemap.ts b/scripts/sitemap.ts index e34da6b..f9a9b7d 100644 --- a/scripts/sitemap.ts +++ b/scripts/sitemap.ts @@ -20,6 +20,7 @@ const knownRoutes = [ '/stellar', '/careers', '/press', + '/terms', ]; function getCaseStudyRoutes(): string[] { diff --git a/src/App.tsx b/src/App.tsx index e07440a..1fa638e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import PartnerStrip from './components/PartnerStrip'; import { ThemeProvider } from './context/ThemeContext'; import { usePageSeo } from './utils/seo'; import { changeLocale, type Locale } from './i18n'; +import { useScrollToHash } from './hooks/useScrollToHash'; // Lazy load below-the-fold homepage components const StealthAnimation = lazy(() => import('./components/StealthAnimation')); @@ -27,6 +28,8 @@ const Footer = lazy(() => import('./components/Footer')); // Lazy load pages const Faq = lazy(() => import('./pages/Faq')); const Privacy = lazy(() => import('./pages/Privacy')); +const Terms = lazy(() => import('./pages/Terms')); +const Press = lazy(() => import('./pages/Press')); const Newsletter = lazy(() => import('./pages/Newsletter')); const UseCases = lazy(() => import('./pages/UseCases')); const CostCalculatorPage = lazy(() => import('./pages/CostCalculatorPage')); @@ -78,6 +81,7 @@ function LocaleSync() { function Home() { const seo = usePageSeo('home'); + useScrollToHash(); return (
@@ -131,6 +135,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/__tests__/Footer.test.tsx b/src/__tests__/Footer.test.tsx index 2caa44f..856355d 100644 --- a/src/__tests__/Footer.test.tsx +++ b/src/__tests__/Footer.test.tsx @@ -24,9 +24,9 @@ describe('Footer status badge', () => { ); expect(await screen.findByText(/all systems normal/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /open wraith protocol status page/i })).toHaveAttribute( - 'href', - 'https://status.usewraith.xyz', - ); + // The status host is not provisioned yet, so the badge opens the on-site page. + const badge = screen.getByRole('link', { name: /open wraith protocol status page/i }); + expect(badge).toHaveAttribute('href', '/status'); + expect(badge).not.toHaveAttribute('target'); }); }); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 1eb4bd9..ef296fe 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -20,7 +20,9 @@ const acknowledgments = [ { label: 'Drips', src: '/logos/drips-mark.svg' }, ]; -const statusPageUrl = import.meta.env.VITE_STATUS_PAGE_URL || 'https://status.usewraith.xyz'; +// The status host is not provisioned yet, so the on-site status page is the default. +const statusPageUrl = import.meta.env.VITE_STATUS_PAGE_URL || '/status'; +const statusPageIsExternal = /^https?:\/\//.test(statusPageUrl); const statusApiUrl = import.meta.env.VITE_STATUS_API_URL || ''; function normalizeStatus(payload: unknown): StatusState { @@ -217,16 +219,22 @@ export default function Footer() { { label: t('footer.product.docs'), href: 'https://docs.usewraith.xyz' }, { label: t('footer.product.demo'), href: 'https://demo.usewraith.xyz' }, { label: t('footer.product.console'), href: 'https://console.usewraith.xyz' }, - { label: t('footer.product.compare'), href: '#compare' }, + { label: t('footer.product.compare'), href: '/#compare' }, { label: t('footer.product.faq'), href: '/faq' }, - { label: t('footer.product.changelog'), href: 'https://docs.usewraith.xyz/changelog' }, + { + label: t('footer.product.changelog'), + href: 'https://github.com/wraith-protocol/sdk/blob/develop/CHANGELOG.md', + }, ], }, { title: t('footer.columns.developers'), links: [ { label: t('footer.developers.sdk'), href: 'https://docs.usewraith.xyz/sdk/overview' }, - { label: t('footer.developers.apiReference'), href: 'https://docs.usewraith.xyz/api' }, + { + label: t('footer.developers.apiReference'), + href: 'https://docs.usewraith.xyz/api-reference/endpoints', + }, { label: t('footer.developers.github'), href: 'https://github.com/wraith-protocol' }, { label: t('footer.developers.npm'), @@ -239,7 +247,7 @@ export default function Footer() { links: [ { label: t('footer.resources.erc5564'), href: 'https://eips.ethereum.org/EIPS/eip-5564' }, { label: t('footer.resources.erc6538'), href: 'https://eips.ethereum.org/EIPS/eip-6538' }, - { label: t('footer.resources.security'), href: 'https://docs.usewraith.xyz/security' }, + { label: t('footer.resources.security'), href: '/security' }, { label: t('footer.resources.press'), href: '/press' }, { label: t('footer.resources.blog'), href: '/blog' }, { label: t('footer.resources.caseStudies'), href: '/case-studies' }, @@ -351,9 +359,9 @@ export default function Footer() {
@@ -377,13 +385,12 @@ export default function Footer() { > {t('footer.legal.privacy')} - {t('footer.legal.terms')} - + (null); const menuRef = useRef(null); - const lp = useLocalePath(); + const lp = useLocalizedPath(); const currentLocale = (i18n.language.split('-')[0] ?? 'en') as Locale; const nextLocale: Locale = currentLocale === 'en' ? 'es' : 'en'; diff --git a/src/data/chains.json b/src/data/chains.json index 98a0760..87b3290 100644 --- a/src/data/chains.json +++ b/src/data/chains.json @@ -21,7 +21,7 @@ "wallets": "Horizen Wallet, MetaMask (EON)", "status": "live", "audit": "In-house + third-party", - "docs": "https://docs.usewraith.xyz/chains/horizen", + "docs": "https://docs.usewraith.xyz/sdk/chains/evm", "description": "Horizen EON is an EVM-compatible sidechain with low fees and fast block times. Wraith stealth addresses are fully operational on EON mainnet with SDK support for send and scan flows." }, { @@ -33,7 +33,7 @@ "wallets": "Freighter,LOBSTR,Rabet", "status": "live", "audit": "Stellar Development Foundation audits", - "docs": "https://docs.usewraith.xyz/chains/stellar", + "docs": "https://docs.usewraith.xyz/sdk/chains/stellar", "description": "Stellar offers sub-cent transaction fees and deterministic finality. Wraith stealth addresses on Stellar use the Soroban smart contract layer for on-chain scanning and withdrawal." }, { @@ -45,7 +45,7 @@ "wallets": "Phantom, Solflare, Backpack", "status": "devnet", "audit": "In progress", - "docs": "https://docs.usewraith.xyz/chains/solana", + "docs": "https://docs.usewraith.xyz/sdk/chains/solana", "description": "Solana provides high throughput with sub-second block times. Wraith stealth addresses on Solana are in active development on devnet, targeting low-latency privacy transfers." }, { @@ -57,7 +57,7 @@ "wallets": "Neuron, JoyID", "status": "testnet", "audit": "Nervos Foundation audits", - "docs": "https://docs.usewraith.xyz/chains/nervos", + "docs": "https://docs.usewraith.xyz/sdk/chains/ckb", "description": "Nervos CKB uses a UTXO model with RISC-V smart contracts. Wraith stealth addresses leverage CKB's cell model for on-chain lock ownership verification." }, { @@ -69,7 +69,7 @@ "wallets": "MetaMask, Ledger, Rainbow, Coinbase Wallet", "status": "planned", "audit": "Pending", - "docs": "https://docs.usewraith.xyz/chains/ethereum", + "docs": "https://docs.usewraith.xyz/sdk/chains/evm", "description": "Ethereum mainnet support is planned for a future release. Higher fees are offset by the deepest wallet ecosystem and largest DeFi surface." }, { @@ -81,7 +81,7 @@ "wallets": "MetaMask, Ledger, Rainbow", "status": "planned", "audit": "Pending", - "docs": "https://docs.usewraith.xyz/chains/polygon", + "docs": "https://docs.usewraith.xyz/sdk/chains/evm", "description": "Polygon PoS offers EVM compatibility with low fees. Wraith support is planned to extend stealth payments to Polygon's growing DeFi ecosystem." }, { @@ -93,7 +93,7 @@ "wallets": "MetaMask, Coinbase Wallet, Rainbow", "status": "planned", "audit": "Pending", - "docs": "https://docs.usewraith.xyz/chains/base", + "docs": "https://docs.usewraith.xyz/sdk/chains/evm", "description": "Base is an Optimism-powered L2 with Coinbase distribution. Planned Wraith integration targets the L2's growing developer and consumer app ecosystem." } ] diff --git a/src/data/ecosystem.json b/src/data/ecosystem.json index 42307a4..5b59d80 100644 --- a/src/data/ecosystem.json +++ b/src/data/ecosystem.json @@ -86,7 +86,7 @@ "shortName": "Aquarius", "description": "Liquidity aggregator and DEX on Stellar offering best-price routing for private stealth swaps via Wraith.", "category": "dexes", - "link": "https://aquarius.fi", + "link": "https://aqua.network", "logo": "/logos/ecosystem/aquarius.svg", "width": 28 }, diff --git a/src/data/security.json b/src/data/security.json index 4450609..9eeebd8 100644 --- a/src/data/security.json +++ b/src/data/security.json @@ -2,12 +2,12 @@ "contact": { "email": "security@usewraith.xyz", "pgp": { - "enabled": true, + "enabled": false, "fingerprint": "ABCD 1234 5678 90EF GHIJ KLMN OPQR STUV WXYZ 0123", "keyUrl": "https://keys.openpgp.org/vks/v1/by-fingerprint/ABCD123456789..." }, "preferredLanguages": ["en"], - "acknowledgments": "https://docs.usewraith.xyz/security/disclosures" + "acknowledgments": "https://usewraith.xyz/security#disclosures" }, "threatModel": { "summary": "Wraith Protocol implements stealth address cryptography (ERC-5564/ERC-6538) for unlinkable payments. Our threat model assumes adversaries can observe all blockchain data and network traffic but cannot break discrete log assumptions or compromise user key material stored in secure enclaves.", @@ -52,19 +52,19 @@ "scope": "Core SDK cryptographic implementation (ERC-5564 stealth address derivation and scanning)", "findings": "3 low-severity findings, all remediated", "nextScheduled": "2026-12-15", - "reports": "https://docs.usewraith.xyz/security/audits" + "reports": "https://usewraith.xyz/security#audits" }, "bounty": { "status": "active", "platform": "self-hosted", - "url": "https://docs.usewraith.xyz/security/bug-bounty", + "url": "https://usewraith.xyz/security#bug-bounty", "maxReward": "$10,000 USD", "eligibility": "Vulnerabilities in @wraith-protocol/sdk, stealth address implementation, or key infrastructure components.", "ineligibility": "Issues already known, out-of-scope systems, or non-security bugs." }, "disclosures": { "count": 2, - "public": "https://docs.usewraith.xyz/security/disclosures" + "public": "https://usewraith.xyz/security#disclosures" }, "compliance": { "standards": ["OWASP Top 10", "CWE Top 25", "RFC 9116 (security.txt)"], diff --git a/src/data/team.json b/src/data/team.json index 5e5da8d..61f15f6 100644 --- a/src/data/team.json +++ b/src/data/team.json @@ -5,33 +5,25 @@ "name": "Alex Chen", "role": "Founder & Protocol Architect", "bio": "Cryptographer and systems engineer. Previously built privacy tooling at a Tier-1 exchange. Leads protocol design and stealth address research.", - "photo": "/team/alex.jpg", - "github": "https://github.com/alexchen-wraith", - "twitter": "https://x.com/alexchen_wraith" + "photo": "/team/alex.jpg" }, { "name": "Priya Sharma", "role": "Lead Engineer", "bio": "Full-stack engineer focused on SDK ergonomics and developer experience. Previously core contributor to a privacy-focused L2. Owns the Wraith SDK and integration tooling.", - "photo": "/team/priya.jpg", - "github": "https://github.com/priyash-wraith", - "twitter": "https://x.com/priyash_wraith" + "photo": "/team/priya.jpg" }, { "name": "Marcus Okonkwo", "role": "Cryptography Engineer", "bio": "Applied cryptographer specializing in zero-knowledge proofs and elliptic curve pairings. Leads implementation of EIP-5564 stealth meta-address protocol.", - "photo": "/team/marcus.jpg", - "github": "https://github.com/marcusok-wraith", - "twitter": "https://x.com/marcusok_wraith" + "photo": "/team/marcus.jpg" }, { "name": "Elena Kovac", "role": "Protocol Researcher", "bio": "Researcher focused on anonymity sets, linking attacks, and on-chain privacy metrics. Published in top privacy conferences. Drives threat modeling and security audits.", - "photo": "/team/elena.jpg", - "github": "https://github.com/elenak-wraith", - "twitter": "https://x.com/elenak_wraith" + "photo": "/team/elena.jpg" } ], "advisors": [ diff --git a/src/data/trust.json b/src/data/trust.json index 22a6718..e438bac 100644 --- a/src/data/trust.json +++ b/src/data/trust.json @@ -6,13 +6,13 @@ "label": "Smart Contract Audit", "status": "scheduled", "detail": "Stealth address contracts — scheduled for Phase 3", - "link": "https://docs.usewraith.xyz/security" + "link": "https://usewraith.xyz/security" }, { "label": "TEE Attestation", "status": "verified", "detail": "AI agents run inside Phala Network Intel TDX enclaves", - "link": "https://docs.usewraith.xyz/security" + "link": "https://usewraith.xyz/security" } ] }, @@ -20,12 +20,12 @@ "eyebrow": "Ecosystem", "count": 10, "label": "Live chain & wallet integrations", - "link": "https://docs.usewraith.xyz/ecosystem" + "link": "https://usewraith.xyz/ecosystem" }, "uptime": { "eyebrow": "Reliability", "fallbackPercent": "99.95", "windowLabel": "Last 90 days", - "statusPageUrl": "https://status.usewraith.xyz" + "statusPageUrl": "https://usewraith.xyz/status" } } diff --git a/src/data/wave.json b/src/data/wave.json index e0836a8..c5908f7 100644 --- a/src/data/wave.json +++ b/src/data/wave.json @@ -9,7 +9,7 @@ "fundingSource": "Drips + External", "description": "Funding for teams building stealth address infrastructure, SDK integrations, and developer tooling. If you are building privacy-preserving payment rails, stealth-address middleware, or tooling that makes unlinkable payments easier to ship, this wave is for you.", "howToApply": "Submit your proposal through the Drips platform. Include a clear scope, timeline, and budget breakdown with defined milestones.", - "applyUrl": "https://drips.network/grants/wraith/wave-1", + "applyUrl": "https://www.drips.network/wave/stellar", "rewardRange": "$5,000 – $50,000", "eligibility": [ "Open to individual developers and teams", diff --git a/src/hooks/useLocalePath.ts b/src/hooks/useLocalePath.ts index 54d9983..b5e514e 100644 --- a/src/hooks/useLocalePath.ts +++ b/src/hooks/useLocalePath.ts @@ -14,43 +14,14 @@ import { useTranslation } from 'react-i18next'; import { type Locale } from '../i18n'; +import { hasLocalizedRoute } from '../utils/localizedRoutes'; const LOCALE_PREFIX: Partial> = { es: '/es', pt: '/pt', }; -// Routes that have localized versions (exist under /es/* and /pt/*) -// Base paths that support localization -const LOCALIZED_BASE_PATHS = new Set([ - '/', - '/stellar', - '/grants', - '/blog', - '/case-studies', -]); - -/** - * Checks if a given path has a localized version. - * Only routes defined in the locale-prefixed routes in App.tsx should return true. - * This includes base paths and their sub-routes (e.g., /blog, /blog/:slug, /blog/author/:id). - */ -export function hasLocalizedRoute(path: string): boolean { - if (!path.startsWith('/')) return false; - if (path === '/') return true; - - const segments = path.split('/').filter(Boolean); - if (segments.length === 0) return false; - - const basePath = `/${segments[0]}`; - - // Check if the base path is in the localized routes - if (LOCALIZED_BASE_PATHS.has(basePath)) { - return true; - } - - return false; -} +export { hasLocalizedRoute } from '../utils/localizedRoutes'; export function useLocalePath(): (path: string) => string { const { i18n } = useTranslation(); diff --git a/src/hooks/useScrollToHash.ts b/src/hooks/useScrollToHash.ts new file mode 100644 index 0000000..0dfe7a7 --- /dev/null +++ b/src/hooks/useScrollToHash.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +/** + * Scrolls to the element named by the URL hash once it renders, so links such + * as /#compare or /security#audits land on sections of lazily rendered pages. + */ +export function useScrollToHash(): void { + const { hash } = useLocation(); + + useEffect(() => { + if (!hash) return; + const id = decodeURIComponent(hash.slice(1)); + let attempts = 0; + const timer = window.setInterval(() => { + const element = document.getElementById(id); + attempts += 1; + if (element || attempts > 50) { + window.clearInterval(timer); + element?.scrollIntoView(); + } + }, 100); + return () => window.clearInterval(timer); + }, [hash]); +} diff --git a/src/pages/About.tsx b/src/pages/About.tsx index ad0efd2..a79b979 100644 --- a/src/pages/About.tsx +++ b/src/pages/About.tsx @@ -2,6 +2,10 @@ import { Helmet } from 'react-helmet-async'; import { trackOutbound } from '../utils/track'; import teamData from '../data/team.json'; +type TeamMember = (typeof teamData.team)[number] & { github?: string; twitter?: string }; + +const team: TeamMember[] = teamData.team; + const socialIcon = (type: 'github' | 'twitter') => { if (type === 'github') { return ( @@ -81,7 +85,7 @@ export default function About() {
- {teamData.team.map((member) => ( + {team.map((member) => (
{member.bio}

- {'github' in member && ( + {member.github && (
{socialIcon('github')} - {'twitter' in member && ( + {member.twitter && ( maintainerRoles.includes(member.role) && (!member.github || !optOutData.includes(member.github.replace('https://github.com/', ''))), diff --git a/src/pages/Press.tsx b/src/pages/Press.tsx index b54887d..595d27f 100644 --- a/src/pages/Press.tsx +++ b/src/pages/Press.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { Helmet } from 'react-helmet-async'; import { copyToClipboard } from '../utils/clipboard'; const BRAND_COLORS = [ @@ -58,6 +59,13 @@ function CopyBlock({ children }: { children: string }) { export default function Press() { return (
+ + Press Kit — Wraith Protocol + + + + + {/* Header */}
diff --git a/src/pages/Roadmap.tsx b/src/pages/Roadmap.tsx index f77470b..2f1fc2d 100644 --- a/src/pages/Roadmap.tsx +++ b/src/pages/Roadmap.tsx @@ -1,4 +1,5 @@ import Header from '../components/Header'; +import { useScrollToHash } from '../hooks/useScrollToHash'; import Footer from '../components/Footer'; import roadmap from '../data/roadmap.json'; @@ -34,6 +35,7 @@ const statusMeta: Record @@ -74,6 +76,7 @@ export default function Roadmap() { return (
  • + /^https?:\/\//.test(href) && !href.startsWith('https://usewraith.xyz/') + ? { target: '_blank', rel: 'noopener noreferrer' } + : {}; export default function Security() { + useScrollToHash(); const { contact, threatModel, @@ -241,7 +249,7 @@ export default function Security() { {/* Security Audits */} -
    +

    Security Audits

    @@ -280,8 +288,7 @@ export default function Security() {
  • View Audit Reports ↗ @@ -290,7 +297,7 @@ export default function Security() { {/* Bug Bounty */} -
    +

    Bug Bounty Program

    @@ -332,8 +339,7 @@ export default function Security() {
    View Bounty Program ↗ @@ -342,7 +348,7 @@ export default function Security() {
    {/* Public Disclosures */} -
    +

    Public Disclosures

    @@ -360,8 +366,7 @@ export default function Security() {
    View Disclosure Log ↗ @@ -516,12 +521,12 @@ export default function Security() { Privacy Policy → - Full Security Docs ↗ + TEE Security Model ↗
    diff --git a/src/pages/Stellar.tsx b/src/pages/Stellar.tsx index 30c9794..801c3bb 100644 --- a/src/pages/Stellar.tsx +++ b/src/pages/Stellar.tsx @@ -228,6 +228,10 @@ export default function Stellar() { property="og:description" content="Low-cost, sub-second, ed25519 stealth payments on Stellar with Soroban smart contracts." /> + + {seo.alternates.map((alt) => ( + + ))} @@ -280,7 +284,7 @@ export default function Stellar() { Try Stellar Demo track('cta_click', { source: 'stellar-docs' })} @@ -288,15 +292,13 @@ export default function Stellar() { > Read Stellar docs - track('cta_click', { source: 'stellar-spectre' })} className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" > Spectre on Stellar - +
    {/* Value prop stats — matches Hero.tsx stat block */} @@ -608,7 +610,7 @@ export default function Stellar() { Try Stellar Demo track('cta_click', { source: 'stellar-docs' })} @@ -616,15 +618,13 @@ export default function Stellar() { > Read Stellar docs - track('cta_click', { source: 'stellar-spectre' })} className="flex h-12 items-center justify-center border border-outline-variant px-7 font-heading text-[13px] font-semibold uppercase tracking-[1.5px] text-primary transition-colors duration-150 hover:bg-surface-bright" > Spectre on Stellar - + ( +
    +

    + {title} +

    +
    + {children} +
    +
    +); + +export default function Terms() { + return ( +
    + + Terms of Use — Wraith Protocol + + + + + + +
    + + Wraith + + WRAITH PROTOCOL + + +
    + +
    +
    +
    + + Legal + +

    + Terms of Use +

    +

    + usewraith.xyz · Last updated {LAST_UPDATED} +

    +
    + +

    + These terms cover your use of usewraith.xyz. By using the site you accept them. If you + do not, please do not use the site. +

    + +
    +

    + The site describes Wraith Protocol software and links to its documentation, demo, and + source code. Nothing on it is financial, investment, legal, or tax advice. +

    +
    + +
    +

    + The Wraith SDK, contracts, and this website are open source. Each repository's + license governs your use of that code; the source of this site is published under the + MIT License at{' '} + + github.com/wraith-protocol/www + + . +

    +
    + +
    +

    + The site and the software it describes are provided "as is", without + warranty of any kind. You are responsible for how you use them, including any + transaction you sign and the keys you keep. +

    +
    + +
    +

    + The site links to services that Wraith Protocol does not operate, such as GitHub, npm, + and Stellar ecosystem projects. Their own terms and policies apply when you use them. +

    +
    + +
    +

    + How the site handles analytics and data is described in the{' '} + + Privacy Policy + + . +

    +
    + +
    +

    + These terms may change. The date at the top of this page shows the latest revision. +

    +
    + +
    +

    + Questions about these terms can be raised in{' '} + + the website repository + + . +

    +
    + +
    + + ← Back to Home + +
    +
    +
    +
    + ); +} diff --git a/src/utils/localizedRoutes.ts b/src/utils/localizedRoutes.ts new file mode 100644 index 0000000..d0d9fcc --- /dev/null +++ b/src/utils/localizedRoutes.ts @@ -0,0 +1,29 @@ +/** + * Routes that exist under the locale prefixes (/es/*, /pt/*). + * + * Shared by the router helpers in src/hooks/useLocalePath.ts and by + * scripts/og.ts, which must only emit hreflang alternates for these routes. + * Keep it in sync with the locale-prefixed routes in App.tsx. + */ +export const LOCALIZED_BASE_PATHS = new Set([ + '/', + '/stellar', + '/grants', + '/blog', + '/case-studies', +]); + +/** + * Checks if a given path has a localized version. + * Only routes defined in the locale-prefixed routes in App.tsx should return true. + * This includes base paths and their sub-routes (e.g., /blog, /blog/:slug, /blog/author/:id). + */ +export function hasLocalizedRoute(path: string): boolean { + if (!path.startsWith('/')) return false; + if (path === '/') return true; + + const segments = path.split('/').filter(Boolean); + if (segments.length === 0) return false; + + return LOCALIZED_BASE_PATHS.has(`/${segments[0]}`); +} From a83fff0fe18eab4542008c009ab80a1301d60099 Mon Sep 17 00:00:00 2001 From: Arminas Attas <106035711+aratass@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:19:41 +0300 Subject: [PATCH 3/5] ci: run the link checker on every pull request --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6c468a..ab0e855 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,35 @@ jobs: - name: Smoke test the serverless routes run: pnpm test:smoke + link-check: + name: Link Check + runs-on: ubuntu-latest + 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:link-check + - run: pnpm build + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Check links in the build + run: pnpm check:links --report link-check-report.json + + - name: Upload link check report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: link-check-report + path: link-check-report.json + if-no-files-found: ignore + retention-days: 14 + playwright-a11y: name: Playwright A11y Gate needs: build From 7159bba4c6cd3b7eb105b0c3fe210fafde99eb27 Mon Sep 17 00:00:00 2001 From: Arminas Attas <106035711+aratass@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:20:59 +0300 Subject: [PATCH 4/5] ci: check the production site's links every day --- .github/workflows/production-links.yml | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/production-links.yml diff --git a/.github/workflows/production-links.yml b/.github/workflows/production-links.yml new file mode 100644 index 0000000..275711a --- /dev/null +++ b/.github/workflows/production-links.yml @@ -0,0 +1,37 @@ +name: Production Links + +on: + schedule: + - cron: '23 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + production-links: + name: Check usewraith.xyz links + runs-on: ubuntu-latest + 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 + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Check production links + run: pnpm check:links --base https://usewraith.xyz --report link-check-report.json + + - name: Upload link check report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: production-link-check-report + path: link-check-report.json + if-no-files-found: ignore + retention-days: 14 From f883126615d354bb1e8fd66b9d59c0fc91d49bf3 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Sat, 17 Oct 2026 18:23:59 +0000 Subject: [PATCH 5/5] fix(stellar): take the page head from develop (seo values) so the branch merges cleanly --- src/pages/Stellar.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/pages/Stellar.tsx b/src/pages/Stellar.tsx index 801c3bb..551e238 100644 --- a/src/pages/Stellar.tsx +++ b/src/pages/Stellar.tsx @@ -218,20 +218,14 @@ export default function Stellar() { return ( <> - Stealth payments on Stellar – Wraith Protocol - - - + {seo.title} + {seo.alternates.map((alt) => ( ))} + +