diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f12b60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Check pull request diff + if: github.event_name == 'pull_request' + run: git diff --check "${{ github.event.pull_request.base.sha }}...${{ github.sha }}" + - name: Check push diff + if: github.event_name == 'push' + run: | + before="${{ github.event.before }}" + after="${{ github.sha }}" + empty_tree="$(git hash-object -t tree -w /dev/null)" + if [ "$before" = "0000000000000000000000000000000000000000" ] || + ! git cat-file -e "$before^{commit}" 2>/dev/null + then + git diff --check "$empty_tree" "$after" + else + git diff --check "$before..$after" + fi + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci --ignore-scripts + - name: Check repository + run: npm run check diff --git a/.github/workflows/deployment-health.yml b/.github/workflows/deployment-health.yml new file mode 100644 index 0000000..785612e --- /dev/null +++ b/.github/workflows/deployment-health.yml @@ -0,0 +1,27 @@ +name: Deployment health + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deployment-health: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci --ignore-scripts + - name: Check deployment + run: npm run check:deploy diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..de4fbdf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Linkout + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9eee615 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Linkout documentation + +This repository contains the public documentation site for Linkout and +`linkout-scraper`, published at [docs.linkout.space](https://docs.linkout.space). + +## Development + +Use Node.js 22 or newer and install the locked dependencies: + +```sh +npm ci +``` + +Start the local development server with `npm start`. To test the production +site locally, run `npm run build` followed by `npm run serve`. + +## Checks + +- `npm run check:local` checks the working-tree diff, types, tests, and + production build. +- `npm run check:source` checks live source contracts against the public + `linkout-scraper` repository. +- `npm run check:deps` checks the patched dependency tree and production audit. +- `npm run check:deploy` checks the live deployment and its TLS certificate. +- `npm run check` is the complete repository gate: local, dependency, and live + source checks. Deployment health remains a separate operational check. + +The live source contract check fetches public files from the public +`linkoutapp/linkout-scraper` repository's `main` branch. It retries transient +network, timeout, rate-limit, and server failures; a persistent fetch failure or +contract drift fails the check. + +## Contributions and operations + +Contributions are accepted only through pull requests. CI runs for pull requests +and updates to `main`. + +The documentation targets a dedicated Vercel project serving +`docs.linkout.space`. Repository changes do not mutate that project or DNS. +Maintainers can run the manual **Deployment health** GitHub Actions workflow to +check the deployed endpoints and TLS certificate outside the normal repository +gate. diff --git a/package-lock.json b/package-lock.json index d15f511..bb91183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,14 @@ "": { "name": "@linkout/docs", "version": "0.1.0", + "license": "MIT", "dependencies": { "@docusaurus/core": "3.10.2", "@docusaurus/preset-classic": "3.10.2", "@docusaurus/theme-mermaid": "3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", + "parse5": "7.3.0", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -21,6 +23,7 @@ "@docusaurus/module-type-aliases": "3.10.2", "@docusaurus/tsconfig": "3.10.2", "@docusaurus/types": "3.10.2", + "js-yaml": "4.3.0", "typescript": "~5.6.2" }, "engines": { diff --git a/package.json b/package.json index 244dd44..0011c2f 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "description": "Documentation for Linkout and linkout-scraper", + "license": "MIT", "scripts": { "start": "docusaurus start", "build": "docusaurus build", @@ -10,9 +11,11 @@ "serve": "docusaurus serve", "typecheck": "tsc", "test": "node --test tests/*.test.mjs", - "check:deps": "npm ls brace-expansion serialize-javascript uuid --all --silent && npm audit --omit=dev", + "check:local": "git diff --check && npm run typecheck && npm test && npm run build", + "check:deploy": "node scripts/check-deployment.mjs", + "check:deps": "node scripts/check-dependencies.mjs", "check:source": "node scripts/check-source-contracts.mjs", - "check": "npm run typecheck && npm test && npm run check:deps && npm run check:source && npm run build" + "check": "npm run check:local && npm run check:deps && npm run check:source" }, "dependencies": { "@docusaurus/core": "3.10.2", @@ -20,6 +23,7 @@ "@docusaurus/theme-mermaid": "3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", + "parse5": "7.3.0", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -28,6 +32,7 @@ "@docusaurus/module-type-aliases": "3.10.2", "@docusaurus/tsconfig": "3.10.2", "@docusaurus/types": "3.10.2", + "js-yaml": "4.3.0", "typescript": "~5.6.2" }, "overrides": { diff --git a/scripts/check-dependencies.mjs b/scripts/check-dependencies.mjs new file mode 100644 index 0000000..07d24bd --- /dev/null +++ b/scripts/check-dependencies.mjs @@ -0,0 +1,206 @@ +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +import { retry } from "./lib/retry.mjs"; + +const COMMAND_TIMEOUT_MS = 15_000; +const OUTPUT_TAIL_LIMIT = 64 * 1024; +const TREE_ARGS = [ + "ls", + "brace-expansion", + "serialize-javascript", + "uuid", + "--all", + "--silent", +]; +const AUDIT_ARGS = ["audit", "--omit=dev"]; +const NETWORK_ERROR_PATTERN = + /\b(?:EAI_AGAIN|ECONNRESET|ECONNREFUSED|ENETUNREACH|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT)\b|socket hang up|fetch failed/i; +const TRANSIENT_HTTP_PATTERN = + /\b(?:(?:HTTP|status)(?:\s+(?:status|code))?|npm error)\s*[:=]?\s*(?:408|429|5\d\d)\b|\bE(?:408|429|5\d\d)\b/i; + +function appendTail(current, chunk) { + return `${current}${chunk}`.slice(-OUTPUT_TAIL_LIMIT); +} + +function commandError(args, code, signal, stdout, stderr, timeoutMs) { + const detail = + code === "ETIMEDOUT" + ? `timed out after ${timeoutMs}ms` + : `exited with ${signal ? `signal ${signal}` : `code ${code}`}`; + return Object.assign(new Error(`npm ${args.join(" ")} ${detail}`), { + code, + signal, + stdout, + stderr, + }); +} + +function npmInvocation( + args, + { environment, nodeExecutable, platform } +) { + if (environment.npm_execpath) { + return { + args: [environment.npm_execpath, ...args], + executable: nodeExecutable, + }; + } + if (platform !== "win32") { + return { args, executable: "npm" }; + } + + throw new Error( + "npm_execpath is required on Windows; run this checker through npm" + ); +} + +export function createNpmRunner({ + environment = process.env, + nodeExecutable = process.execPath, + platform = process.platform, + spawnImpl = spawn, + timeoutMs = COMMAND_TIMEOUT_MS, + setTimer = setTimeout, + clearTimer = clearTimeout, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + return (npmArgs) => { + const invocation = npmInvocation(npmArgs, { + environment, + nodeExecutable, + platform, + }); + + return new Promise((resolve, reject) => { + const child = spawnImpl(invocation.executable, invocation.args, { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdoutTail = ""; + let stderrTail = ""; + let settled = false; + let timedOut = false; + + child.stdout.on("data", (chunk) => { + stdout.write(chunk); + stdoutTail = appendTail(stdoutTail, chunk); + }); + child.stderr.on("data", (chunk) => { + stderr.write(chunk); + stderrTail = appendTail(stderrTail, chunk); + }); + + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimer(timer); + callback(value); + }; + const timer = setTimer(() => { + if (settled) return; + timedOut = true; + try { + if (!child.kill("SIGKILL")) { + finish( + reject, + commandError( + npmArgs, + "ETIMEDOUT", + null, + stdoutTail, + stderrTail, + timeoutMs + ) + ); + } + } catch { + finish( + reject, + commandError( + npmArgs, + "ETIMEDOUT", + null, + stdoutTail, + stderrTail, + timeoutMs + ) + ); + } + }, timeoutMs); + + child.once("error", (error) => { + Object.assign(error, { stdout: stdoutTail, stderr: stderrTail }); + finish(reject, error); + }); + child.once("close", (code, signal) => { + if (timedOut) { + finish( + reject, + commandError( + npmArgs, + "ETIMEDOUT", + signal, + stdoutTail, + stderrTail, + timeoutMs + ) + ); + } else if (code === 0) { + finish(resolve); + } else { + finish( + reject, + commandError( + npmArgs, + code, + signal, + stdoutTail, + stderrTail, + timeoutMs + ) + ); + } + }); + }); + }; +} + +function isTransientNpmFailure(error) { + const output = [error?.code, error?.message, error?.stdout, error?.stderr] + .filter(Boolean) + .join("\n"); + return ( + NETWORK_ERROR_PATTERN.test(output) || TRANSIENT_HTTP_PATTERN.test(output) + ); +} + +export async function runDependencyChecks({ + runCommand, + runnerOptions, + sleep, +} = {}) { + const execute = runCommand + ? (args) => runCommand("npm", args) + : createNpmRunner(runnerOptions); + await execute(TREE_ARGS); + await retry(() => execute(AUDIT_ARGS), { + isRetryableError: isTransientNpmFailure, + ...(sleep ? { sleep } : {}), + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + await runDependencyChecks(); + } catch (error) { + if (!error?.stdout && !error?.stderr) { + console.error(error?.message || error); + } + process.exitCode = Number.isInteger(error?.code) ? error.code : 1; + } +} diff --git a/scripts/check-deployment.mjs b/scripts/check-deployment.mjs new file mode 100644 index 0000000..cf1b696 --- /dev/null +++ b/scripts/check-deployment.mjs @@ -0,0 +1,27 @@ +import { pathToFileURL } from "node:url"; + +import { checkDeployment } from "./lib/deployment-health.mjs"; + +export async function runDeploymentCheck({ + check = checkDeployment, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + try { + const result = await check(); + stdout.write( + `Deployment healthy: ${result.hostname} ${result.endpoints.join(" ")}\n` + ); + return 0; + } catch (error) { + stderr.write(`Deployment unhealthy: ${error?.message || error}\n`); + return 1; + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + process.exitCode = await runDeploymentCheck(); +} diff --git a/scripts/check-source-contracts.mjs b/scripts/check-source-contracts.mjs index 033c083..a489ba5 100644 --- a/scripts/check-source-contracts.mjs +++ b/scripts/check-source-contracts.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { fetchSourceText } from "./lib/source-fetch.mjs"; const RAW_BASE = "https://raw.githubusercontent.com/linkoutapp/linkout-scraper/main"; @@ -47,23 +48,10 @@ function parseJson(path, source) { } async function read(path) { - let response; - try { - response = await fetch(`${RAW_BASE}/${path}`, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - } catch (error) { - throw new Error( - diagnostic(path, `fetch failed (${error.code || error.message})`), - { cause: error } - ); - } - assert.equal( - response.ok, - true, - diagnostic(path, `fetch returned HTTP ${response.status}`) - ); - return response.text(); + return fetchSourceText(path, { + baseUrl: RAW_BASE, + timeoutMs: FETCH_TIMEOUT_MS, + }); } async function readAll(requestedPaths, concurrency = FETCH_CONCURRENCY) { diff --git a/scripts/lib/deployment-health.mjs b/scripts/lib/deployment-health.mjs new file mode 100644 index 0000000..855f398 --- /dev/null +++ b/scripts/lib/deployment-health.mjs @@ -0,0 +1,281 @@ +import { lookup as dnsLookup } from "node:dns"; +import https from "node:https"; +import tls from "node:tls"; +import { promisify } from "node:util"; +import { parse } from "parse5"; + +const DEFAULT_TARGET = "https://docs.linkout.space"; +const DEFAULT_TIMEOUT_MS = 5_000; +const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; +const ENDPOINTS = ["/", "/robots.txt", "/sitemap.xml"]; +const REQUIRED_CANONICAL = "https://docs.linkout.space/"; +const lookupAll = promisify(dnsLookup); +const MONTHS = new Map( + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + .map((month, index) => [month, index]) +); + +function errorDetail(error) { + return [error?.code, error?.message].filter(Boolean).join(": ") || String(error); +} + +function parseCertificateDate(value, label) { + const match = + /^([A-Z][a-z]{2})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\s+GMT$/.exec( + value || "" + ); + const month = MONTHS.get(match?.[1]); + const parts = match?.slice(2).map(Number); + if (!match || month === undefined) { + throw new Error(`TLS certificate has an invalid ${label} date`); + } + + const [day, hour, minute, second, year] = parts; + const timestamp = Date.UTC(year, month, day, hour, minute, second); + const parsed = new Date(timestamp); + if ( + year < 1000 || + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month || + parsed.getUTCDate() !== day || + parsed.getUTCHours() !== hour || + parsed.getUTCMinutes() !== minute || + parsed.getUTCSeconds() !== second + ) { + throw new Error(`TLS certificate has an invalid ${label} date`); + } + return timestamp; +} + +export function validateCertificate( + certificate, + hostname, + { now = new Date(), checkServerIdentity = tls.checkServerIdentity } = {} +) { + const validFrom = parseCertificateDate(certificate?.valid_from, "valid-from"); + const validTo = parseCertificateDate(certificate?.valid_to, "valid-to"); + const nowTimestamp = now instanceof Date ? now.getTime() : Number.NaN; + if (!Number.isFinite(nowTimestamp)) { + throw new Error("TLS certificate validation current time is invalid"); + } + + if (nowTimestamp < validFrom) { + throw new Error( + `TLS certificate is not valid until ${certificate.valid_from}` + ); + } + if (nowTimestamp > validTo) { + throw new Error(`TLS certificate expired at ${certificate.valid_to}`); + } + + const hostnameError = checkServerIdentity(hostname, certificate); + if (hostnameError) { + throw new Error( + `TLS certificate hostname mismatch: ${hostnameError.message}`, + { cause: hostnameError } + ); + } +} + +export function createTlsInspector({ + connect = tls.connect, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + return (hostname, { port = 443 } = {}) => + new Promise((resolve, reject) => { + const socket = connect({ + host: hostname, + port, + rejectUnauthorized: true, + servername: hostname, + }); + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + callback(value); + }; + const timer = setTimeout(() => { + const error = Object.assign( + new Error(`TLS handshake timed out after ${timeoutMs}ms`), + { code: "ETIMEDOUT" } + ); + finish(reject, error); + }, timeoutMs); + + socket.once("secureConnect", () => finish(resolve, socket.getPeerCertificate())); + socket.once("error", (error) => finish(reject, error)); + }); +} + +export function createHttpsRequester({ + requestImpl = https.request, + maxBodyBytes = DEFAULT_MAX_BODY_BYTES, + setTimer = setTimeout, + clearTimer = clearTimeout, +} = {}) { + return (url, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) => + new Promise((resolve, reject) => { + let request; + let response; + let responseCompleted = false; + let settled = false; + let deadline; + + const finish = (callback, value, { destroy = false } = {}) => { + if (settled) return; + settled = true; + clearTimer(deadline); + if (destroy) { + response?.destroy(); + request?.destroy(); + } + callback(value); + }; + const fail = (message, cause) => { + const error = new Error(message, cause ? { cause } : undefined); + finish(reject, error, { destroy: true }); + }; + + request = requestImpl( + url, + { + rejectUnauthorized: true, + servername: url.hostname, + }, + (incomingResponse) => { + response = incomingResponse; + const chunks = []; + let bodyBytes = 0; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + if (settled) return; + bodyBytes += Buffer.byteLength(chunk); + if (bodyBytes > maxBodyBytes) { + fail(`response exceeded ${maxBodyBytes} bytes`); + return; + } + chunks.push(chunk); + }); + response.once("error", (error) => + fail(`response failed (${error?.message || error})`, error) + ); + response.once("aborted", () => fail("response aborted")); + response.once("close", () => { + if (!responseCompleted) { + fail("response closed before completion"); + } + }); + response.once("end", () => { + responseCompleted = true; + finish(resolve, { + body: chunks.join(""), + statusCode: response.statusCode, + }); + }); + } + ); + + deadline = setTimer(() => { + fail(`request deadline exceeded after ${timeoutMs}ms`); + }, timeoutMs); + request.once("error", (error) => + fail(error?.message || String(error), error) + ); + request.end(); + }); +} + +function hasCanonicalLink(node) { + if (node.tagName === "link") { + const attributes = new Map( + (node.attrs || []).map(({ name, value }) => [name, value]) + ); + const relTokens = (attributes.get("rel") || "").split(/[ \t\n\f\r]+/); + if ( + relTokens.some((token) => token.toLowerCase() === "canonical") && + attributes.get("href") === REQUIRED_CANONICAL + ) { + return true; + } + } + return (node.childNodes || []).some(hasCanonicalLink); +} + +function containsRequiredCanonical(body) { + const document = parse(body, { scriptingEnabled: true }); + const html = document.childNodes.find((node) => node.tagName === "html"); + const heads = (html?.childNodes || []).filter( + (node) => node.tagName === "head" + ); + return heads.length === 1 && hasCanonicalLink(heads[0]); +} + +export async function checkDeployment({ + target = DEFAULT_TARGET, + timeoutMs = DEFAULT_TIMEOUT_MS, + lookup = (hostname) => lookupAll(hostname, { all: true }), + inspectTls = createTlsInspector({ timeoutMs }), + request = createHttpsRequester(), + now = new Date(), +} = {}) { + const baseUrl = new URL(target); + const hostname = baseUrl.hostname; + let addresses; + + try { + addresses = await lookup(hostname); + } catch (error) { + throw new Error(`${hostname}: DNS lookup failed (${errorDetail(error)})`, { + cause: error, + }); + } + if (!Array.isArray(addresses) || addresses.length === 0) { + throw new Error(`${hostname}: DNS lookup returned no addresses`); + } + + let certificate; + try { + certificate = await inspectTls(hostname, { + port: baseUrl.port ? Number(baseUrl.port) : 443, + timeoutMs, + }); + validateCertificate(certificate, hostname, { now }); + } catch (error) { + throw new Error( + `${hostname}: TLS validation failed (${errorDetail(error)})`, + { cause: error } + ); + } + + for (const endpoint of ENDPOINTS) { + const url = new URL(endpoint, baseUrl); + let response; + try { + response = await request(url, { timeoutMs }); + } catch (error) { + throw new Error( + `${endpoint}: request failed (${errorDetail(error)})`, + { cause: error } + ); + } + if ( + !Number.isInteger(response?.statusCode) || + response.statusCode < 200 || + response.statusCode >= 300 + ) { + throw new Error(`${endpoint}: returned HTTP ${response?.statusCode ?? "unknown"}`); + } + if (endpoint === "/" && !containsRequiredCanonical(response.body || "")) { + throw new Error(`/: missing canonical ${REQUIRED_CANONICAL}`); + } + } + + return { + addresses: addresses.map(({ address }) => address), + endpoints: [...ENDPOINTS], + hostname, + }; +} diff --git a/scripts/lib/retry.mjs b/scripts/lib/retry.mjs new file mode 100644 index 0000000..5c88c5a --- /dev/null +++ b/scripts/lib/retry.mjs @@ -0,0 +1,28 @@ +const DEFAULT_DELAYS_MS = [250, 1_000]; + +function isTransientHttpStatus(status) { + return status === 408 || status === 429 || (status >= 500 && status <= 599); +} + +export async function retry( + operation, + { + isRetryableError = () => false, + sleep = (delayMs) => + new Promise((resolve) => setTimeout(resolve, delayMs)), + delays = DEFAULT_DELAYS_MS, + } = {} +) { + const attempts = delays.length + 1; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await operation(); + } catch (error) { + const canRetry = + isTransientHttpStatus(error?.status) || isRetryableError(error); + if (!canRetry || attempt === attempts - 1) throw error; + await sleep(delays[attempt]); + } + } +} diff --git a/scripts/lib/source-fetch.mjs b/scripts/lib/source-fetch.mjs new file mode 100644 index 0000000..fb8940b --- /dev/null +++ b/scripts/lib/source-fetch.mjs @@ -0,0 +1,75 @@ +import { retry } from "./retry.mjs"; + +const DEFAULT_BASE_URL = + "https://raw.githubusercontent.com/linkoutapp/linkout-scraper/main"; +const DEFAULT_TIMEOUT_MS = 15_000; +const NETWORK_ERROR_CODES = new Set([ + "EAI_AGAIN", + "ECONNRESET", + "ECONNREFUSED", + "ENETUNREACH", + "ENOTFOUND", + "ETIMEDOUT", +]); + +function diagnostic(path, message) { + return `${path}: ${message}`; +} + +function isNetworkOrTimeoutError(error) { + return ( + error instanceof TypeError || + error?.name === "AbortError" || + error?.name === "TimeoutError" || + NETWORK_ERROR_CODES.has(error?.code) + ); +} + +export async function fetchSourceText( + path, + { + baseUrl = DEFAULT_BASE_URL, + fetchImpl = fetch, + timeoutMs = DEFAULT_TIMEOUT_MS, + timeoutSignal = AbortSignal.timeout, + sleep, + } = {} +) { + try { + return await retry( + async () => { + const response = await fetchImpl(`${baseUrl}/${path}`, { + signal: timeoutSignal(timeoutMs), + }); + if ( + typeof response?.ok !== "boolean" || + typeof response?.status !== "number" || + typeof response?.text !== "function" + ) { + throw new Error(diagnostic(path, "fetch returned a malformed response")); + } + if (!response.ok) { + throw Object.assign(new Error(`HTTP ${response.status}`), { + status: response.status, + }); + } + return await response.text(); + }, + { + isRetryableError: isNetworkOrTimeoutError, + ...(sleep ? { sleep } : {}), + } + ); + } catch (error) { + if (error?.message?.startsWith(`${path}:`)) throw error; + if (typeof error?.status === "number") { + throw new Error(diagnostic(path, `fetch returned HTTP ${error.status}`), { + cause: error, + }); + } + throw new Error( + diagnostic(path, `fetch failed (${error?.code || error?.message})`), + { cause: error } + ); + } +} diff --git a/tests/check-dependencies.test.mjs b/tests/check-dependencies.test.mjs new file mode 100644 index 0000000..d725f28 --- /dev/null +++ b/tests/check-dependencies.test.mjs @@ -0,0 +1,328 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { + createNpmRunner, + runDependencyChecks, +} from "../scripts/check-dependencies.mjs"; + +function fakeChild({ code = 0, stderr = "", stdout = "" } = {}) { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => true; + queueMicrotask(() => { + if (stdout) child.stdout.write(stdout); + if (stderr) child.stderr.write(stderr); + child.emit("close", code, null); + }); + return child; +} + +for (const platform of ["darwin", "win32"]) { + test(`real launcher uses Node and npm_execpath on ${platform}`, async () => { + const launches = []; + const runCommand = createNpmRunner({ + environment: { npm_execpath: "/opt/npm/bin/npm-cli.js" }, + nodeExecutable: "/opt/node/bin/node", + platform, + spawnImpl: (command, args, options) => { + launches.push({ command, args, options }); + return fakeChild(); + }, + stderr: { write() {} }, + stdout: { write() {} }, + }); + + await runCommand(["audit", "--omit=dev"]); + + assert.deepEqual(launches, [ + { + command: "/opt/node/bin/node", + args: ["/opt/npm/bin/npm-cli.js", "audit", "--omit=dev"], + options: { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }, + }, + ]); + }); +} + +test("real launcher falls back to the npm executable on POSIX", async () => { + const launches = []; + const runCommand = createNpmRunner({ + environment: {}, + platform: "linux", + spawnImpl: (command, args, options) => { + launches.push({ command, args, options }); + return fakeChild(); + }, + stderr: { write() {} }, + stdout: { write() {} }, + }); + + await runCommand(["audit", "--omit=dev"]); + + assert.deepEqual(launches, [ + { + command: "npm", + args: ["audit", "--omit=dev"], + options: { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }, + }, + ]); +}); + +test("Windows without npm_execpath fails immediately without spawning", () => { + let spawnCalls = 0; + const runCommand = createNpmRunner({ + environment: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + platform: "win32", + spawnImpl: () => { + spawnCalls += 1; + return fakeChild(); + }, + }); + + assert.throws( + () => runCommand(["audit", "--omit=dev"]), + /npm_execpath is required on Windows/ + ); + assert.equal(spawnCalls, 0); +}); + +test("real launcher kills and retries a stalled audit attempt", async () => { + let auditAttempts = 0; + const launches = []; + const killed = []; + const timeoutCalls = []; + + await runDependencyChecks({ + runnerOptions: { + environment: {}, + platform: "linux", + spawnImpl: (_command, args) => { + launches.push(args); + if (args[0] !== "audit") { + return fakeChild(); + } + auditAttempts += 1; + if (auditAttempts > 1) return fakeChild(); + + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = (signal) => { + killed.push(signal); + queueMicrotask(() => child.emit("close", null, signal)); + return true; + }; + return child; + }, + setTimer: (callback, timeoutMs) => { + timeoutCalls.push(timeoutMs); + const timer = { active: true }; + queueMicrotask(() => { + if (timer.active) callback(); + }); + return timer; + }, + clearTimer(timer) { + timer.active = false; + }, + stderr: { write() {} }, + stdout: { write() {} }, + }, + sleep: async () => {}, + }); + + assert.deepEqual( + launches.map((args) => args[0]), + ["ls", "audit", "audit"] + ); + assert.deepEqual(killed, ["SIGKILL"]); + assert.deepEqual(timeoutCalls, [15_000, 15_000, 15_000]); +}); + +test("real launcher preserves subprocess output and nonzero exit code", async () => { + const written = { stderr: "", stdout: "" }; + const runCommand = createNpmRunner({ + environment: {}, + spawnImpl: () => + fakeChild({ + code: 1, + stdout: "audit report\n", + stderr: "vulnerabilities found\n", + }), + stderr: { write: (chunk) => (written.stderr += chunk) }, + stdout: { write: (chunk) => (written.stdout += chunk) }, + }); + + await assert.rejects(runCommand(["audit", "--omit=dev"]), (error) => { + assert.equal(error.code, 1); + assert.equal(error.stdout, "audit report\n"); + assert.equal(error.stderr, "vulnerabilities found\n"); + return true; + }); + assert.deepEqual(written, { + stdout: "audit report\n", + stderr: "vulnerabilities found\n", + }); +}); + +test("validates the existing dependency tree before auditing production dependencies", async () => { + const commands = []; + + await runDependencyChecks({ + runCommand: async (command, args) => commands.push([command, args]), + sleep: async () => {}, + }); + + assert.deepEqual(commands, [ + [ + "npm", + ["ls", "brace-expansion", "serialize-javascript", "uuid", "--all", "--silent"], + ], + ["npm", ["audit", "--omit=dev"]], + ]); +}); + +test("retries transient npm audit network failures", async () => { + let auditAttempts = 0; + + await runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + if (auditAttempts < 3) { + throw Object.assign(new Error("request to advisory endpoint failed"), { + stderr: "npm error code ECONNRESET", + }); + } + }, + sleep: async () => {}, + }); + + assert.equal(auditAttempts, 3); +}); + +test("retries transient npm audit DNS failures", async () => { + let auditAttempts = 0; + + await runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + if (auditAttempts === 1) { + throw Object.assign(new Error("advisory endpoint unavailable"), { + stderr: "npm error code ENOTFOUND", + }); + } + }, + sleep: async () => {}, + }); + + assert.equal(auditAttempts, 2); +}); + +test("does not retry an invalid dependency tree", async () => { + let attempts = 0; + const failure = Object.assign(new Error("invalid tree"), { exitCode: 1 }); + + await assert.rejects( + runDependencyChecks({ + runCommand: async () => { + attempts += 1; + throw failure; + }, + sleep: async () => {}, + }), + (error) => error === failure + ); + assert.equal(attempts, 1); +}); + +test("does not retry audit failures caused by vulnerabilities", async () => { + let auditAttempts = 0; + const failure = Object.assign(new Error("vulnerabilities found"), { + stdout: "3 high severity vulnerabilities", + }); + + await assert.rejects( + runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + throw failure; + }, + sleep: async () => {}, + }), + (error) => error === failure + ); + assert.equal(auditAttempts, 1); +}); + +test("does not mistake a vulnerability count for an HTTP failure", async () => { + let auditAttempts = 0; + const failure = Object.assign(new Error("vulnerabilities found"), { + stdout: "500 vulnerabilities", + }); + + await assert.rejects( + runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + throw failure; + }, + sleep: async () => {}, + }), + (error) => error === failure + ); + assert.equal(auditAttempts, 1); +}); + +for (const code of ["E408", "E429"]) { + test(`retries standard npm ${code} advisory diagnostics`, async () => { + let auditAttempts = 0; + + await runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + if (auditAttempts === 1) { + throw Object.assign(new Error("advisory request failed"), { + stderr: `npm error code ${code}`, + }); + } + }, + sleep: async () => {}, + }); + + assert.equal(auditAttempts, 2); + }); +} + +test("retries transient advisory HTTP failures", async () => { + let auditAttempts = 0; + + await runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + if (auditAttempts === 1) { + throw Object.assign(new Error("503 Service Unavailable"), { + stderr: "npm error 503 Service Unavailable", + }); + } + }, + sleep: async () => {}, + }); + + assert.equal(auditAttempts, 2); +}); diff --git a/tests/deployment-health.test.mjs b/tests/deployment-health.test.mjs new file mode 100644 index 0000000..706668b --- /dev/null +++ b/tests/deployment-health.test.mjs @@ -0,0 +1,441 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { + checkDeployment, + createHttpsRequester, + validateCertificate, +} from "../scripts/lib/deployment-health.mjs"; + +const TARGET = "https://docs.linkout.space"; +const VALID_CERTIFICATE = { + valid_from: "Jan 01 00:00:00 2026 GMT", + valid_to: "Jan 01 00:00:00 2027 GMT", + subjectaltname: "DNS:docs.linkout.space", +}; +const CANONICAL_LINK = + ''; +const CANONICAL_HEAD = `${CANONICAL_LINK}`; + +function healthyDependencies(overrides = {}) { + return { + lookup: async () => [{ address: "192.0.2.1", family: 4 }], + inspectTls: async () => VALID_CERTIFICATE, + request: async (url) => ({ + body: + url.pathname === "/" + ? CANONICAL_HEAD + : "ok", + statusCode: 200, + }), + now: new Date("2026-07-29T00:00:00Z"), + ...overrides, + }; +} + +function fakeHttpsExchange({ statusCode = 200 } = {}) { + const response = new EventEmitter(); + response.statusCode = statusCode; + response.destroyed = false; + response.setEncoding = () => {}; + response.destroy = () => { + response.destroyed = true; + }; + + const request = new EventEmitter(); + request.destroyed = false; + request.setTimeout = () => {}; + request.destroy = () => { + request.destroyed = true; + }; + request.end = () => {}; + + return { request, response }; +} + +test("reports a DNS lookup failure with the hostname", async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + lookup: async () => { + throw Object.assign(new Error("query failed"), { code: "ENOTFOUND" }); + }, + }), + }), + /docs\.linkout\.space: DNS lookup failed \(ENOTFOUND: query failed\)/ + ); +}); + +test("rejects an empty DNS result", async () => { + await assert.rejects( + checkDeployment({ ...healthyDependencies({ lookup: async () => [] }) }), + /docs\.linkout\.space: DNS lookup returned no addresses/ + ); +}); + +test("rejects an expired certificate", () => { + assert.throws( + () => + validateCertificate(VALID_CERTIFICATE, "docs.linkout.space", { + now: new Date("2027-01-01T00:00:01Z"), + }), + /TLS certificate expired/ + ); +}); + +test("rejects a certificate that is not yet valid", () => { + assert.throws( + () => + validateCertificate(VALID_CERTIFICATE, "docs.linkout.space", { + now: new Date("2025-12-31T23:59:59Z"), + }), + /TLS certificate is not valid until/ + ); +}); + +test("rejects a certificate hostname mismatch", () => { + assert.throws( + () => + validateCertificate( + { ...VALID_CERTIFICATE, subjectaltname: "DNS:other.example" }, + "docs.linkout.space", + { now: new Date("2026-07-29T00:00:00Z") } + ), + /TLS certificate hostname mismatch/ + ); +}); + +test("rejects impossible certificate dates instead of normalizing them", () => { + assert.throws( + () => + validateCertificate( + { ...VALID_CERTIFICATE, valid_from: "Feb 30 00:00:00 2026 GMT" }, + "docs.linkout.space", + { now: new Date("2026-07-29T00:00:00Z") } + ), + /TLS certificate has an invalid valid-from date/ + ); +}); + +test("rejects an invalid current time", () => { + assert.throws( + () => + validateCertificate(VALID_CERTIFICATE, "docs.linkout.space", { + now: new Date("invalid"), + }), + /current time is invalid/ + ); +}); + +test("reports TLS and CA verification failures", async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + inspectTls: async () => { + throw Object.assign(new Error("unable to verify the first certificate"), { + code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }); + }, + }), + }), + /docs\.linkout\.space: TLS validation failed \(UNABLE_TO_VERIFY_LEAF_SIGNATURE: unable to verify the first certificate\)/ + ); +}); + +test("reports an endpoint timeout with its path", async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + request: async (url) => { + if (url.pathname === "/robots.txt") { + throw Object.assign(new Error("timed out after 5000ms"), { + code: "ETIMEDOUT", + }); + } + return { body: CANONICAL_HEAD, statusCode: 200 }; + }, + }), + }), + /\/robots\.txt: request failed \(ETIMEDOUT: timed out after 5000ms\)/ + ); +}); + +for (const endpoint of ["/", "/robots.txt", "/sitemap.xml"]) { + test(`rejects a non-2xx response from ${endpoint}`, async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + request: async (url) => ({ + body: + url.pathname === "/" + ? CANONICAL_HEAD + : "", + statusCode: url.pathname === endpoint ? 503 : 200, + }), + }), + }), + new RegExp(`${endpoint.replace(/[/.]/g, "\\$&")}: returned HTTP 503`) + ); + }); +} + +test("rejects a homepage without the required canonical URL", async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + request: async () => ({ body: "", statusCode: 200 }), + }), + }), + /\/: missing canonical https:\/\/docs\.linkout\.space\// + ); +}); + +for (const fakeCanonical of [ + ``, + `