From 2280781d334bf638845179935924f6638e433cb4 Mon Sep 17 00:00:00 2001 From: MaciejZet Date: Wed, 30 Sep 2026 11:34:46 +0200 Subject: [PATCH 1/2] Guard the Google provider URL and stop silent CLI fallbacks. Unknown market, device and depth values now error, scan uses --depth, Node older than 22 stops at startup, and the install docs no longer point at an unpublished npm package. --- .github/workflows/ci.yml | 3 + .prettierignore | 13 + CHANGELOG.md | 14 +- README.md | 32 +- SECURITY.md | 2 +- bin/rankproof.mjs | 50 +++- docs/api.md | 44 +-- docs/architecture.md | 10 +- docs/cli.md | 34 +-- docs/metrics.md | 16 +- docs/production-ops.md | 49 +-- docs/providers.md | 16 +- docs/search-console.md | 16 +- e2e/smoke.spec.ts | 4 +- eslint.config.mjs | 5 +- package-lock.json | 24 +- package.json | 11 +- scripts/migrate.mjs | 12 +- scripts/node-version.mjs | 30 ++ scripts/node-version.test.mjs | 34 +++ scripts/serve-api.mjs | 5 +- src/components/scan-brand.tsx | 40 ++- src/components/scan-insights.tsx | 49 ++- src/components/scan-plan.tsx | 36 ++- src/components/scan-results.tsx | 45 +-- src/components/scan-toxic.tsx | 20 +- src/components/scan/charts.tsx | 6 +- src/components/scan/custom-keyword-panel.tsx | 26 +- src/components/scan/empty-states.tsx | 10 +- src/components/scan/keyword-ideas-panel.tsx | 46 +-- src/components/scan/keywords-tab.tsx | 26 +- src/components/scan/link-gap-panel.tsx | 12 +- src/components/scan/on-page-panel.tsx | 17 +- src/components/scan/prospects-tab.tsx | 21 +- src/components/scan/serp-labels.ts | 8 +- src/components/scan/serp-panels.tsx | 31 +- src/components/scan/serp-tab.tsx | 12 +- src/components/scan/tab-nav.tsx | 10 +- src/components/ui/badge.tsx | 4 +- src/components/ui/button.tsx | 18 +- src/lib/auth/gate-identity.server.ts | 19 +- src/lib/auth/gate-session.server.ts | 50 +--- src/lib/auth/gates.tsx | 6 +- src/lib/auth/pglite-dialect.ts | 28 +- src/lib/auth/verify.server.ts | 4 +- src/lib/backlinks/backlinks.test.ts | 211 +++++++++++-- src/lib/backlinks/brand-serp.ts | 27 +- src/lib/backlinks/cli.ts | 51 +++- src/lib/backlinks/doctor.server.ts | 16 +- src/lib/backlinks/engine.server.ts | 29 +- src/lib/backlinks/export.ts | 24 +- src/lib/backlinks/history-diff.ts | 1 - src/lib/backlinks/html.ts | 4 +- src/lib/backlinks/keywords.ts | 22 +- src/lib/backlinks/market.ts | 6 +- src/lib/backlinks/onpage.ts | 15 +- src/lib/backlinks/plan.ts | 33 ++- src/lib/backlinks/scan.ts | 5 +- src/lib/backlinks/score.ts | 9 +- src/lib/backlinks/scorecard.ts | 10 +- src/lib/backlinks/search-console.server.ts | 7 +- src/lib/backlinks/segments.ts | 27 +- src/lib/backlinks/serp-cluster.ts | 29 +- src/lib/backlinks/serp-intel.ts | 25 +- src/lib/backlinks/serp-providers.ts | 37 +++ src/lib/backlinks/serp.server.ts | 18 +- src/lib/backlinks/serp.ts | 24 +- src/lib/backlinks/site-audit.server.ts | 37 ++- src/lib/backlinks/ssrf.ts | 30 +- src/lib/backlinks/suggest.server.ts | 5 +- src/lib/backlinks/target.server.ts | 3 - src/lib/backlinks/toxic.ts | 11 +- src/lib/backlinks/types.ts | 21 +- src/lib/backlinks/verify.server.ts | 7 +- src/lib/db.ts | 20 +- src/routes/index.tsx | 296 +++++++++---------- tsconfig.json | 20 +- 77 files changed, 1232 insertions(+), 816 deletions(-) create mode 100644 .prettierignore create mode 100644 scripts/node-version.mjs create mode 100644 scripts/node-version.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7674bb9..d16e635 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - name: Lint run: npm run lint + - name: Format + run: npm run format:check + - name: Tests run: npm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..20a3e90 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,13 @@ +# Generated / vendored — do not auto-format. +node_modules +dist +.output +.nitro +.vercel +.rankproof +coverage +package-lock.json +src/routeTree.gen.ts + +# Golden SERP HTML fixtures — whitespace is part of the capture. +src/lib/backlinks/fixtures/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 07167ee..0a3b17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ truthfulness, and the packaging a stranger's first clone runs into. NAT64 and 6to4, plus `ff00::/8`, `100::/64`, `2001:db8::/32` and `2001::/23`. Regression tests cover the URL-normalised spelling, which is the one that actually arrives. +- `RANKPROOF_GOOGLE_PROVIDER_URL` (and the legacy OpenVis / SerpRadar aliases) + goes through the same guard before fetch. Loopback, link-local and metadata + addresses are refused, including after DNS resolution. - `GET /doctor` is rate-limited like the POST endpoints; it runs five sequential engine probes and previously skipped the limiter entirely. `GET /health` stays free as a liveness probe. @@ -51,6 +54,15 @@ truthfulness, and the packaging a stranger's first clone runs into. ### Fixed +- Unknown `--market`, `--device` and `--depth` values error with the allow-list + instead of falling back. `scan --depth` is the depth the scan uses (10 or 20). +- The CLI exits before `--experimental-strip-types` when Node is older than 22, + with a message that names the floor. `doctor` stays non-zero when unhealthy, + matching HTTP 503. +- Install docs no longer tell people to `npm install rankproof` while the + registry 404s. Clone and `npm ci` is the path that works. +- Dependency overrides clear the high advisories on `fast-uri`, `js-yaml` and + `brace-expansion`. CI runs `prettier --check`. - **`.env` was never read.** README and `.env.example` both told people to create one; nothing loaded it, so every variable was silently ignored. The CLI, the HTTP API and `dev`/`build`/`preview` now load it via `util.parseEnv` — no new @@ -71,7 +83,7 @@ truthfulness, and the packaging a stranger's first clone runs into. `anomaly-modal` puzzle with HTTP 202 and Mojeek a JavaScript challenge with HTTP 200; neither says "captcha", so `doctor` told users to file an issue against a working parser. Both now read as `blocked`. -- Conversely, a SERP that merely *mentions* "captcha" or "unusual traffic" in a +- Conversely, a SERP that merely _mentions_ "captcha" or "unusual traffic" in a snippet is no longer reported as blocked — parsed results settle it first. - `doctor --format json` returned before the exit code was set, so the CI canary the docs promise always exited 0. diff --git a/README.md b/README.md index d185d4f..b7c9a11 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,17 @@ Replace `example.com` with a public site you are authorized to audit. Scans make ![Actual local CLI help output, showing available commands without running a website scan.](docs/media/cli.gif) -*Animated excerpt of actual local command output. [Static version](docs/media/cli.png).* +_Animated excerpt of actual local command output. [Static version](docs/media/cli.png)._ ## What can I inspect? -| Area | Output | -| --- | --- | -| Search visibility | Positions, keyword ideas, SERP overlap and scan history | -| Backlinks | Discovered links checked against source-page HTML | -| Site structure | Internal links, redirects, canonical issues and orphan candidates | -| Comparison | Link gaps and changes between scans | -| Reporting | JSON, CSV and a standalone HTML report | +| Area | Output | +| ----------------- | ----------------------------------------------------------------- | +| Search visibility | Positions, keyword ideas, SERP overlap and scan history | +| Backlinks | Discovered links checked against source-page HTML | +| Site structure | Internal links, redirects, canonical issues and orphan candidates | +| Comparison | Link gaps and changes between scans | +| Reporting | JSON, CSV and a standalone HTML report | Bing, DuckDuckGo and Mojeek are enabled by default; Brave is optional. **Google organic results require a separately configured provider.** Search Console supplies data for a property you control. See [providers](docs/providers.md) and [Search Console setup](docs/search-console.md). @@ -58,14 +58,14 @@ Read the [metric definitions](docs/metrics.md) before comparing scores. ## Go further -| Task | Guide | -| --- | --- | -| CLI commands and flags | [CLI reference](docs/cli.md) | -| Run the HTTP service | [API reference](docs/api.md) | -| Configure engines and integrations | [.env.example](.env.example) | -| Understand discovery and verification | [Architecture](docs/architecture.md) | -| Deploy beyond localhost | [Production operations](docs/production-ops.md) | -| Contribute or report a vulnerability | [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) | +| Task | Guide | +| ------------------------------------- | --------------------------------------------------------- | +| CLI commands and flags | [CLI reference](docs/cli.md) | +| Run the HTTP service | [API reference](docs/api.md) | +| Configure engines and integrations | [.env.example](.env.example) | +| Understand discovery and verification | [Architecture](docs/architecture.md) | +| Deploy beyond localhost | [Production operations](docs/production-ops.md) | +| Contribute or report a vulnerability | [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) | ```bash npm run check diff --git a/SECURITY.md b/SECURITY.md index b923a7d..fc81bbb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a vulnerability -**Do not open a public issue** for a security flaw. Email `hello@cometweb.io` or use GitHub's private vulnerability reporting (*Security* tab → *Report a vulnerability*). +**Do not open a public issue** for a security flaw. Email `hello@cometweb.io` or use GitHub's private vulnerability reporting (_Security_ tab → _Report a vulnerability_). We respond within 5 working days. If the issue is real, we agree a disclosure timeline together with the reporter. diff --git a/bin/rankproof.mjs b/bin/rankproof.mjs index b5c0a6d..284aa5d 100755 --- a/bin/rankproof.mjs +++ b/bin/rankproof.mjs @@ -11,8 +11,16 @@ import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import process from "node:process"; +import { doctorExitCode, nodeVersionError } from "../scripts/node-version.mjs"; + const STRIP = "--experimental-strip-types"; +const versionError = nodeVersionError(process.version); +if (versionError) { + console.error(versionError); + process.exit(1); +} + /** * Where the engine lives. * @@ -105,6 +113,7 @@ async function runScanCommand(options, log) { market: options.market, device: options.device, engines: options.engines.length > 0 ? options.engines : undefined, + depth: options.depth, skipSiteAudit: options.skipAudit, }); if (!result.ok) throw new Error(result.error); @@ -176,20 +185,16 @@ async function runDoctorCommand(options, log) { const { runDoctor } = await import(engine("doctor.server")); log("Probing engines…"); const diagnosis = await runDoctor(); - // The non-zero exit is the point of `doctor` in CI, so it has to be set - // before the JSON path returns — not only on the text path below. - if (!diagnosis.healthy) process.exitCode = 1; + // Non-zero when no engine works — same contract as HTTP GET /doctor → 503 + // and docs/production-ops.md (cron canary). Set before any return path. + process.exitCode = doctorExitCode(diagnosis.healthy); if (options.format === "json") return JSON.stringify(diagnosis, null, 2); const lines = ["RankProof doctor", ""]; lines.push("ENGINES"); for (const engine of diagnosis.engines) { const mark = - engine.status === "ok" - ? "ok " - : engine.status === "not-configured" - ? "skip" - : "FAIL"; + engine.status === "ok" ? "ok " : engine.status === "not-configured" ? "skip" : "FAIL"; lines.push( ` ${mark} ${engine.engine.padEnd(12)} ${String(engine.hits).padStart(2)} results ${String(engine.ms).padStart(5)} ms ${engine.status}`, ); @@ -223,7 +228,9 @@ function renderScanText(report) { lines.push(`RankProof — ${report.target.host}`); lines.push(`${report.target.title ?? ""}`.trim()); lines.push(""); - lines.push(`VISIBILITY INDEX ${scorecard.index}/100 (${scorecard.grade}) ${bar(scorecard.index)}`); + lines.push( + `VISIBILITY INDEX ${scorecard.index}/100 (${scorecard.grade}) ${bar(scorecard.index)}`, + ); for (const part of scorecard.parts) { lines.push( ` ${part.label.padEnd(20)} ${String(part.score).padStart(3)}/${String(part.max).padEnd(3)} ${bar(part.score, part.max, 14)}`, @@ -237,7 +244,9 @@ function renderScanText(report) { lines.push( ` lost ${stats.lostLinks} · broken ${stats.brokenLinks} · to disavow ${toxic.disavowCount} · footprint ${report.footprint.score}/100 (${report.footprint.verdict})`, ); - lines.push(` velocity: ${report.velocity.perMonth} new domains/month (${report.velocity.verdict})`); + lines.push( + ` velocity: ${report.velocity.perMonth} new domains/month (${report.velocity.verdict})`, + ); lines.push(""); lines.push("SERP"); lines.push( @@ -270,7 +279,10 @@ function renderScanText(report) { if (report.searchConsole?.connected) { const totals = report.searchConsole.providers .filter((provider) => provider.connected) - .map((provider) => `${provider.source}: ${provider.totals.clicks} clicks / ${provider.totals.impressions} impressions`) + .map( + (provider) => + `${provider.source}: ${provider.totals.clicks} clicks / ${provider.totals.impressions} impressions`, + ) .join(" · "); lines.push(""); lines.push("MEASURED PERFORMANCE"); @@ -284,13 +296,17 @@ function renderScanText(report) { lines.push(""); lines.push("MEASUREMENT WARNINGS"); for (const item of unhealthy) { - lines.push(` ${item.engine}: ${item.status} — visibility is based on fewer engines than intended`); + lines.push( + ` ${item.engine}: ${item.status} — visibility is based on fewer engines than intended`, + ); } } lines.push(""); lines.push(`ACTION PLAN (${plan.items.length} tasks, ${plan.quickWins} quick wins)`); for (const [index, item] of plan.items.slice(0, 10).entries()) { - lines.push(` ${index + 1}. [${String(item.priority).padStart(3)}] ${item.title} (${item.effort})`); + lines.push( + ` ${index + 1}. [${String(item.priority).padStart(3)}] ${item.title} (${item.effort})`, + ); lines.push(` ${item.detail}`); } lines.push(""); @@ -305,7 +321,9 @@ function renderSerpText(snapshot, keywords) { lines.push( `Visibility ${snapshot.visibility}/100 · top3 ${snapshot.top3} · top10 ${snapshot.top10} · traffic ~${snapshot.trafficScore}`, ); - lines.push(`Market ${snapshot.market}/${snapshot.device} · engines: ${snapshot.engines.join(", ")}`); + lines.push( + `Market ${snapshot.market}/${snapshot.device} · engines: ${snapshot.engines.join(", ")}`, + ); lines.push(""); for (const row of keywords) { const positions = row.engines @@ -319,7 +337,9 @@ function renderSerpText(snapshot, keywords) { lines.push(""); lines.push("CANNIBALISATION"); for (const item of snapshot.cannibalization) { - lines.push(` "${item.keyword}" (${item.engine}): ${item.urls.map((u) => `#${u.position}`).join(", ")}`); + lines.push( + ` "${item.keyword}" (${item.engine}): ${item.urls.map((u) => `#${u.position}`).join(", ")}`, + ); } } return lines.join("\n"); diff --git a/docs/api.md b/docs/api.md index 2e48f31..9cf5330 100644 --- a/docs/api.md +++ b/docs/api.md @@ -45,13 +45,13 @@ curl -X POST http://127.0.0.1:8787/scan \ -d '{"url":"example.com","market":"us","device":"desktop"}' ``` -| Field | Type | Required | -| --- | --- | --- | -| `url` | string | yes | -| `market` | `pl` `us` `gb` `de` `fr` `es` | no | -| `device` | `desktop` `mobile` | no | -| `engines` | string[] | no | -| `skipSiteAudit` | boolean | no | +| Field | Type | Required | +| --------------- | ----------------------------- | -------- | +| `url` | string | yes | +| `market` | `pl` `us` `gb` `de` `fr` `es` | no | +| `device` | `desktop` `mobile` | no | +| `engines` | string[] | no | +| `skipSiteAudit` | boolean | no | Response: `{ "ok": true, "report": ScanReport }` or `{ "ok": false, "error": "…" }`. @@ -61,16 +61,16 @@ The most important fields: ```jsonc { - "target": { "host": "…", "domainRating": 0 }, - "scorecard": { "index": 0, "grade": "C", "parts": [] }, - "stats": { "backlinks": 0, "referringDomains": 0, "serpVisibility": 0 }, - "backlinks": [], - "serp": { "queries": [], "competitors": [], "clusters": [], "moves": [] }, - "toxic": { "domains": [], "disavowCount": 0 }, - "plan": { "items": [], "quickWins": 0 }, - "brandSerp": null, - "siteAudit": { "score": 0, "orphans": 0, "issues": [] }, - "searchConsole": null + "target": { "host": "…", "domainRating": 0 }, + "scorecard": { "index": 0, "grade": "C", "parts": [] }, + "stats": { "backlinks": 0, "referringDomains": 0, "serpVisibility": 0 }, + "backlinks": [], + "serp": { "queries": [], "competitors": [], "clusters": [], "moves": [] }, + "toxic": { "domains": [], "disavowCount": 0 }, + "plan": { "items": [], "quickWins": 0 }, + "brandSerp": null, + "siteAudit": { "score": 0, "orphans": 0, "issues": [] }, + "searchConsole": null, } ``` @@ -96,9 +96,9 @@ It scans the competitors as well, so a response can take several minutes. Set a ## Response codes -| Code | Meaning | -| --- | --- | -| `200` | Success | -| `400` | Invalid input, or the scan failed | +| Code | Meaning | +| ----- | -------------------------------------------------------------------------- | +| `200` | Success | +| `400` | Invalid input, or the scan failed | | `405` | Wrong method (`/health` and `/doctor` accept GET; everything else is POST) | -| `429` | Rate limit exceeded | +| `429` | Rate limit exceeded | diff --git a/docs/architecture.md b/docs/architecture.md index a04a1ae..05ce87d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,11 +41,11 @@ When an engine changes its HTML, one parser breaks rather than the whole scan The same engine serves three interfaces: -| Entry point | File | Use | -| --- | --- | --- | -| Web app | `src/routes/index.tsx` + `scan.ts` (server functions) | interactive audit | -| CLI | `bin/rankproof.mjs` + `cli.ts` (argument parser) | automation, CI, cron | -| HTTP API | `scripts/serve-api.mjs` | integrations, self-hosting | +| Entry point | File | Use | +| ----------- | ----------------------------------------------------- | -------------------------- | +| Web app | `src/routes/index.tsx` + `scan.ts` (server functions) | interactive audit | +| CLI | `bin/rankproof.mjs` + `cli.ts` (argument parser) | automation, CI, cron | +| HTTP API | `scripts/serve-api.mjs` | integrations, self-hosting | The CLI argument parser is a separate, pure module — tested without running a scan. diff --git a/docs/cli.md b/docs/cli.md index 2f0d738..ca24234 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,28 +62,28 @@ Exits with code `1` when no engine returns results — useful as a CI canary, be ## Options -| Option | Short | Values | Default | -| --- | --- | --- | --- | -| `--keywords` | `-k` | comma-separated list | — | -| `--competitors` | `-c` | comma-separated list (max 5) | — | -| `--market` | `-m` | `pl` `us` `gb` `de` `fr` `es` | `pl` | -| `--device` | `-d` | `desktop` `mobile` | `desktop` | -| `--engines` | `-e` | `bing` `duckduckgo` `mojeek` `brave` `google` | from configuration | -| `--depth` | | `10` `20` | `10` | -| `--format` | `-f` | `text` `json` `csv` `html` `disavow` | `text` | -| `--out` | `-o` | file path | stdout | -| `--quiet` | `-q` | — | off | -| `--no-audit` | | — | off | +| Option | Short | Values | Default | +| --------------- | ----- | --------------------------------------------- | ------------------ | +| `--keywords` | `-k` | comma-separated list | — | +| `--competitors` | `-c` | comma-separated list (max 5) | — | +| `--market` | `-m` | `pl` `us` `gb` `de` `fr` `es` | `pl` | +| `--device` | `-d` | `desktop` `mobile` | `desktop` | +| `--engines` | `-e` | `bing` `duckduckgo` `mojeek` `brave` `google` | from configuration | +| `--depth` | | `10` `20` | `10` | +| `--format` | `-f` | `text` `json` `csv` `html` `disavow` | `text` | +| `--out` | `-o` | file path | stdout | +| `--quiet` | `-q` | — | off | +| `--no-audit` | | — | off | Progress messages go to **stderr**, results to **stdout**. `npm run` prints its own banner to stdout, so redirect from the binary rather than through npm — `node bin/rankproof.mjs scan example.com --format json > report.json` — or use `--out report.json`, which npm cannot pollute. ## Exit codes -| Code | Meaning | -| --- | --- | -| `0` | Success | -| `1` | Runtime failure (the scan did not complete) | -| `2` | Invalid arguments | +| Code | Meaning | +| ---- | ------------------------------------------- | +| `0` | Success | +| `1` | Runtime failure (the scan did not complete) | +| `2` | Invalid arguments | ## Keeping it working diff --git a/docs/metrics.md b/docs/metrics.md index 0f24326..be2458a 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -118,14 +118,14 @@ Unlike everything above, these are **not estimates** — they come from the sear `scorecard.ts` → `buildScorecard()` -| Component | Points | Source | -| --- | --- | --- | -| Link strength | 25 | DR + number of referring domains | -| Profile quality | 15 | profile health + anchor audit | -| SERP visibility | 25 | visibility + modelled traffic | -| On-page and structure | 10 | landing-page audit + internal structure | -| Safety | 15 | toxicity + footprint (subtracted) | -| Momentum and brand | 10 | link velocity + brand SERP control | +| Component | Points | Source | +| --------------------- | ------ | --------------------------------------- | +| Link strength | 25 | DR + number of referring domains | +| Profile quality | 15 | profile health + anchor audit | +| SERP visibility | 25 | visibility + modelled traffic | +| On-page and structure | 10 | landing-page audit + internal structure | +| Safety | 15 | toxicity + footprint (subtracted) | +| Momentum and brand | 10 | link velocity + brand SERP control | Grades: A ≥80, B ≥65, C ≥48, D ≥32, E below. diff --git a/docs/production-ops.md b/docs/production-ops.md index 9e69658..a61c3a8 100644 --- a/docs/production-ops.md +++ b/docs/production-ops.md @@ -21,20 +21,27 @@ curl -s http://127.0.0.1:8787/doctor | jq . ## npm install -The package ships a compiled CLI (`dist/`) via `prepack`. Publish from a clean tree: +RankProof is **not published to the npm registry yet**. Install from a clone (or a +GitHub release tarball) until a separate publish gate lands: ```bash -npm run check -npm run build:cli -npm pack --dry-run # inspect tarball contents -npm publish --tag next # or --tag latest after GA validation +git clone https://github.com/CometWeb-io/rankproof.git +cd rankproof +npm ci +npm run build:cli # optional: emit dist/ for packaged CLI +node bin/rankproof.mjs doctor ``` -Consumers install with: +The package ships a compiled CLI (`dist/`) via `prepack` when maintainers do +publish. Until then, `npm install rankproof` will 404. + +When publishing becomes intentional: ```bash -npm install rankproof -rankproof scan example.com --format json +npm run check +npm run build:cli +npm pack --dry-run # inspect tarball contents +npm publish --tag next # or --tag latest after GA validation ``` CI verifies the packed tarball installs into a path containing a space and runs `rankproof version`. @@ -59,13 +66,13 @@ Set `DATABASE_URL` for persistent scan history across restarts. Without it, embe ### Required environment -| Variable | Production value | -| --- | --- | -| `PUBLIC_ORIGIN` | Your public URL (if using auth callbacks) | -| `BETTER_AUTH_SECRET` | Random 32+ byte secret when auth is enabled | -| `BETTER_AUTH_URL` | Public origin, e.g. `https://rankproof.example.com` | -| `DATABASE_URL` | Postgres connection string | -| `RANKPROOF_USER_AGENT` | Include a contact URL site owners can reach | +| Variable | Production value | +| ---------------------- | --------------------------------------------------- | +| `PUBLIC_ORIGIN` | Your public URL (if using auth callbacks) | +| `BETTER_AUTH_SECRET` | Random 32+ byte secret when auth is enabled | +| `BETTER_AUTH_URL` | Public origin, e.g. `https://rankproof.example.com` | +| `DATABASE_URL` | Postgres connection string | +| `RANKPROOF_USER_AGENT` | Include a contact URL site owners can reach | Never expose `GOOGLE_OAUTH_*` or `BING_WEBMASTER_API_KEY` without treating the host as production. @@ -108,12 +115,12 @@ See [`providers.md`](providers.md). ## Monitoring signals -| Signal | Meaning | -| --- | --- | -| `doctor` exit 1 | All engines blocked or parsers broken — investigate fixtures | -| Rising CAPTCHA rate in scan notes | Reduce concurrency or enable disk cache | -| Scan duration p95 over budget | Raise `RANKPROOF_SCAN_BUDGET_MS` or reduce keyword depth | -| Empty backlink lists on known sites | Source API change — check `sources` tab and GitHub issues | +| Signal | Meaning | +| ----------------------------------- | ------------------------------------------------------------ | +| `doctor` exit 1 | All engines blocked or parsers broken — investigate fixtures | +| Rising CAPTCHA rate in scan notes | Reduce concurrency or enable disk cache | +| Scan duration p95 over budget | Raise `RANKPROOF_SCAN_BUDGET_MS` or reduce keyword depth | +| Empty backlink lists on known sites | Source API change — check `sources` tab and GitHub issues | ## Security reminders diff --git a/docs/providers.md b/docs/providers.md index 040a291..6a05ad6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -2,14 +2,14 @@ RankProof ships four built-in HTML scrapers and an optional Google organic adapter. -| Engine | Mode | Default | Notes | -|---|---|---|---| -| Bing | scrape | on | Primary free SERP | -| DuckDuckGo | scrape | on | HTML endpoint | -| Mojeek | scrape | on | Independent index | -| Brave | scrape | off | Often challenged | -| Google organic | http-json | off | Needs self-hosted provider | -| Google (owned site) | Search Console API | opt-in | Not a SERP engine — see `search-console.md` | +| Engine | Mode | Default | Notes | +| ------------------- | ------------------ | ------- | ------------------------------------------- | +| Bing | scrape | on | Primary free SERP | +| DuckDuckGo | scrape | on | HTML endpoint | +| Mojeek | scrape | on | Independent index | +| Brave | scrape | off | Often challenged | +| Google organic | http-json | off | Needs self-hosted provider | +| Google (owned site) | Search Console API | opt-in | Not a SERP engine — see `search-console.md` | ## Google organic provider diff --git a/docs/search-console.md b/docs/search-console.md index 57f6029..6dc6200 100644 --- a/docs/search-console.md +++ b/docs/search-console.md @@ -4,13 +4,13 @@ Everything else in RankProof estimates. These two APIs do not — they report wh ## What changes once connected -| Without | With | -| --- | --- | -| Positions scraped from Bing, DDG, Mojeek, Brave | Real Google positions alongside them, with the gap shown | -| Traffic modelled from a CTR curve | Actual clicks and impressions | -| "This keyword looks like an opportunity" | "This keyword earns 2,000 impressions at position 6 — reaching position 3 is worth ~180 clicks" | -| No way to see snippet problems | CTR anomalies: ranks well, nobody clicks | -| No decay detection | Queries losing clicks while the position holds | +| Without | With | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Positions scraped from Bing, DDG, Mojeek, Brave | Real Google positions alongside them, with the gap shown | +| Traffic modelled from a CTR curve | Actual clicks and impressions | +| "This keyword looks like an opportunity" | "This keyword earns 2,000 impressions at position 6 — reaching position 3 is worth ~180 clicks" | +| No way to see snippet problems | CTR anomalies: ranks well, nobody clicks | +| No decay detection | Queries losing clicks while the position holds | The report also states how far our CTR model sits from your measured reality, so you know how much to trust the modelled numbers on sites without a connection. @@ -19,7 +19,7 @@ The report also states how far our CTR model sits from your measured reality, so You need OAuth credentials with the read-only Search Console scope. This is private performance data, so there is no shortcut around the site owner's own authorisation. 1. In [Google Cloud Console](https://console.cloud.google.com/), create a project and enable the **Google Search Console API**. -2. Create an **OAuth 2.0 Client ID** of type *Desktop app*. +2. Create an **OAuth 2.0 Client ID** of type _Desktop app_. 3. Grant your account the `https://www.googleapis.com/auth/webmasters.readonly` scope and complete the consent flow to obtain a refresh token. Any standard OAuth helper works; the token exchange is a single POST to `https://oauth2.googleapis.com/token`. 4. Put the values in `.env`: diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 5db7d53..9afd7dd 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -5,9 +5,7 @@ test.describe("RankProof UI smoke", () => { await page.goto("/"); await expect(page).toHaveTitle(/RankProof/i); - await expect(page.getByRole("heading", { level: 1 })).toContainText( - "What you rank for", - ); + await expect(page.getByRole("heading", { level: 1 })).toContainText("What you rank for"); await expect(page.getByLabel("Site address")).toBeVisible(); await expect(page.getByRole("button", { name: "Scan" })).toBeVisible(); await expect(page.getByLabel("How RankProof works")).toBeVisible(); diff --git a/eslint.config.mjs b/eslint.config.mjs index bfc3c25..2d73741 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,10 +31,7 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-unused-vars": [ "warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, diff --git a/package-lock.json b/package-lock.json index fe1208f..922bdb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4098,9 +4098,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4474,9 +4474,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5354,9 +5354,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -5720,9 +5720,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 2d710d7..00ac14f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,13 @@ "CHANGELOG.md" ], "overrides": { - "nf3": "0.3.17" + "nf3": "0.3.17", + "fast-uri": "3.1.8", + "js-yaml": "4.3.2", + "brace-expansion": "1.1.21", + "@typescript-eslint/typescript-estree": { + "brace-expansion": "5.0.12" + } }, "scripts": { "dev": "node scripts/with-app-env.mjs vite dev --port 8080", @@ -63,13 +69,14 @@ "test": "node --experimental-strip-types --test 'src/**/*.test.ts'", "lint": "eslint .", "format": "prettier --write .", + "format:check": "prettier --check .", "cli": "node bin/rankproof.mjs", "api": "node scripts/serve-api.mjs", "check": "npm run typecheck && npm run lint && npm test", "test:e2e": "playwright test", "test:e2e:install": "playwright install chromium", "smoke": "node bin/rankproof.mjs doctor && node bin/rankproof.mjs version && node bin/rankproof.mjs help >/dev/null", - "test:scaffold": "node --test scripts/check-auth-invariant.test.mjs scripts/migration-plan.test.mjs scripts/sign-out-plan.test.mjs scripts/with-app-env.test.mjs", + "test:scaffold": "node --test scripts/check-auth-invariant.test.mjs scripts/migration-plan.test.mjs scripts/sign-out-plan.test.mjs scripts/with-app-env.test.mjs scripts/node-version.test.mjs", "dev:lan": "node scripts/with-app-env.mjs vite dev --host 0.0.0.0 --port 8080", "build:cli": "tsc -p tsconfig.build.json", "prepack": "npm run build:cli" diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 7b3dc08..ede826e 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -20,9 +20,7 @@ import { pendingMigrations } from "./migration-plan.mjs"; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { - console.log( - "[migrate] DATABASE_URL not set — skipping (the PGLite fallback migrates itself).", - ); + console.log("[migrate] DATABASE_URL not set — skipping (the PGLite fallback migrates itself)."); process.exit(0); } @@ -48,9 +46,7 @@ async function main() { await client.query( "CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())", ); - const applied = (await client.query("SELECT name FROM _migrations")).rows.map( - (r) => r.name, - ); + const applied = (await client.query("SELECT name FROM _migrations")).rows.map((r) => r.name); let count = 0; for (const { name } of pendingMigrations(entries, applied)) { @@ -73,7 +69,9 @@ async function main() { console.log(`[migrate] applied ${name}`); count += 1; } - console.log(count ? `[migrate] done — ${count} migration(s) applied.` : "[migrate] up to date."); + console.log( + count ? `[migrate] done — ${count} migration(s) applied.` : "[migrate] up to date.", + ); } finally { client.release(); await pool.end(); diff --git a/scripts/node-version.mjs b/scripts/node-version.mjs new file mode 100644 index 0000000..df14775 --- /dev/null +++ b/scripts/node-version.mjs @@ -0,0 +1,30 @@ +/** + * Pure Node version gate used by the CLI entry before --experimental-strip-types. + * Kept as plain ESM so it can be unit-tested on any Node that can run .mjs. + */ + +/** @param {string} version process.version style, e.g. "v20.11.0" */ +export function nodeMajorVersion(version) { + const match = String(version).match(/^v?(\d+)/); + return match ? Number(match[1]) : 0; +} + +/** @param {string} version @param {number} [minMajor=22] */ +export function meetsMinNodeVersion(version, minMajor = 22) { + return nodeMajorVersion(version) >= minMajor; +} + +/** + * @param {string} version + * @param {number} [minMajor=22] + * @returns {string | null} error message when too old, otherwise null + */ +export function nodeVersionError(version, minMajor = 22) { + if (meetsMinNodeVersion(version, minMajor)) return null; + return `RankProof needs Node.js ${minMajor}+ (found ${version}). Upgrade Node or use a newer runtime.`; +} + +/** Exit code for `doctor` when canary health is known — matches HTTP /doctor (503 when down). */ +export function doctorExitCode(healthy) { + return healthy ? 0 : 1; +} diff --git a/scripts/node-version.test.mjs b/scripts/node-version.test.mjs new file mode 100644 index 0000000..379b158 --- /dev/null +++ b/scripts/node-version.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + doctorExitCode, + meetsMinNodeVersion, + nodeMajorVersion, + nodeVersionError, +} from "./node-version.mjs"; + +test("nodeMajorVersion parses process.version strings", () => { + assert.equal(nodeMajorVersion("v22.11.0"), 22); + assert.equal(nodeMajorVersion("v20.18.1"), 20); + assert.equal(nodeMajorVersion("22.0.0"), 22); + assert.equal(nodeMajorVersion("garbage"), 0); +}); + +test("meetsMinNodeVersion requires Node 22+", () => { + assert.equal(meetsMinNodeVersion("v22.0.0"), true); + assert.equal(meetsMinNodeVersion("v23.1.0"), true); + assert.equal(meetsMinNodeVersion("v20.11.0"), false); + assert.equal(meetsMinNodeVersion("v18.20.0"), false); +}); + +test("nodeVersionError names the floor clearly", () => { + assert.equal(nodeVersionError("v22.3.0"), null); + assert.match(nodeVersionError("v20.11.0") ?? "", /needs Node\.js 22\+/); + assert.match(nodeVersionError("v20.11.0") ?? "", /found v20\.11\.0/); +}); + +test("doctorExitCode matches HTTP /doctor (503 when unhealthy)", () => { + assert.equal(doctorExitCode(true), 0); + assert.equal(doctorExitCode(false), 1); +}); diff --git a/scripts/serve-api.mjs b/scripts/serve-api.mjs index a683386..4c9a205 100755 --- a/scripts/serve-api.mjs +++ b/scripts/serve-api.mjs @@ -124,7 +124,10 @@ async function start() { return; } if (req.method !== "POST") { - send(405, { ok: false, error: "Only POST requests are supported (except /health and /doctor)." }); + send(405, { + ok: false, + error: "Only POST requests are supported (except /health and /doctor).", + }); return; } if (!allow(req.socket.remoteAddress ?? "unknown")) { diff --git a/src/components/scan-brand.tsx b/src/components/scan-brand.tsx index 18cfef5..fb3d1a8 100644 --- a/src/components/scan-brand.tsx +++ b/src/components/scan-brand.tsx @@ -69,7 +69,13 @@ export function ScorecardPanel({ card }: { card: Scorecard }) { 0.66 ? "good" : part.score / part.max < 0.34 ? "risk" : "default"} + tone={ + part.score / part.max > 0.66 + ? "good" + : part.score / part.max < 0.34 + ? "risk" + : "default" + } />

{part.hint}

@@ -102,7 +108,9 @@ export function BrandSerpPanel({ brand }: { brand: BrandSerp }) {

{brand.hint}

- = 60 ? "follow" : brand.risky > 0 ? "nofollow" : "default"}> + = 60 ? "follow" : brand.risky > 0 ? "nofollow" : "default"} + > control {brand.control}% @@ -149,10 +157,26 @@ export function BrandSerpPanel({ brand }: { brand: BrandSerp }) { /** Patterns of an artificially built link profile. */ export function FootprintPanel({ footprint }: { footprint: FootprintRisk }) { const metrics = [ - { label: "Largest /24 subnet", value: footprint.topSubnetShare, risky: footprint.topSubnetShare >= 30 }, - { label: "Sitewide domains", value: footprint.sitewideShare, risky: footprint.sitewideShare >= 25 }, - { label: "Exact-match anchors", value: footprint.exactAnchorShare, risky: footprint.exactAnchorShare > 12 }, - { label: "Subnet diversity", value: footprint.subnetDiversity, risky: footprint.subnetDiversity < 50 }, + { + label: "Largest /24 subnet", + value: footprint.topSubnetShare, + risky: footprint.topSubnetShare >= 30, + }, + { + label: "Sitewide domains", + value: footprint.sitewideShare, + risky: footprint.sitewideShare >= 25, + }, + { + label: "Exact-match anchors", + value: footprint.exactAnchorShare, + risky: footprint.exactAnchorShare > 12, + }, + { + label: "Subnet diversity", + value: footprint.subnetDiversity, + risky: footprint.subnetDiversity < 50, + }, ]; return ( @@ -178,8 +202,8 @@ export function FootprintPanel({ footprint }: { footprint: FootprintRisk }) {

- A natural profile is dispersed: different servers, different anchors, links inside content. A - repeating pattern signals that the links come from a single source. + A natural profile is dispersed: different servers, different anchors, links inside content. + A repeating pattern signals that the links come from a single source.