SPB Git

hardening: reader-role inserts, link checker classification, a11y fixes, e2e stabilization, verification report

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 2, 2026) parent 46938d6 Browse files

10 changed files +73 −17

modified docs/data-model.md +1 −1
@@ -21,7 +21,7 @@ VIEWS: public_tools, public_collections, public_link_checks ← the only tool
21 21 ## Public / private boundary
22 22 | Layer | Mechanism |
23 23 |---|---|
24 −| Database | Role `tools_reader` has SELECT only on `public_tools`, `public_collections`, `public_link_checks`, `vendors`, `categories`, `tags`, `tool_categories`, `tool_tags`, `tool_alternatives`, `sources`, `tool_sources`, `collection_items`, plus INSERT on `error_reports`, `search_events`. It cannot read `tools`, `owner_notes`, `projects`, `users`, `sessions`, `audit_log`, `jobs`. `default_transaction_read_only = on`, `statement_timeout = 5s`. |
24 +| Database | Role `tools_reader` has SELECT only on `public_tools`, `public_collections`, `public_link_checks`, `vendors`, `categories`, `tags`, `tool_categories`, `tool_tags`, `tool_alternatives`, `sources`, `tool_sources`, `collection_items`, plus INSERT on `error_reports`, `search_events`. It cannot read `tools`, `owner_notes`, `projects`, `users`, `sessions`, `audit_log`, `jobs`. `statement_timeout = 5s`; no table-level write privileges except the two public insert-only tables. |
25 25 | View | `public_tools` filters `visibility='public' AND editorial_status='published' AND duplicate_of IS NULL` and omits `visibility`, `editorial_status`, `author`, `duplicate_of`, `discovery_method`, `reviewed_by`, `content_hash`, `embedding_text`, `embedding_model`. |
26 26 | Application | `src/lib/public/*` imports only `sqlPublic`; `src/lib/admin/*` and `/api/admin/*` require a session (`requireUser`) + CSRF for mutations. Owner notes additionally require `role='owner'`. |
27 27 | Build | No private data is read at build time; static params come from `public_tools`. |
modified docs/security.md +1 −1
@@ -12,7 +12,7 @@ Assets: the public catalogue (integrity), the owner's private inventory (confide
12 12 - Authorization is enforced server-side on every `/api/admin/*` handler (`requireUser`) and every admin page (`requireUserPage`); owner-only operations (`owner_notes`, projects, full export) check `role`. Client-side hiding is never relied upon.
13 13
14 14 ## Public/private segregation (defence in depth)
15 −1. Database role `tools_reader` (used by all public routes) cannot read `tools`, `owner_notes`, `projects`, `users`, `sessions`, `audit_log`, `jobs`; it only sees the `public_tools` view (published + public + not merged) and reference tables. Read-only transactions, 5 s statement timeout.
15 +1. Database role `tools_reader` (used by all public routes) cannot read `tools`, `owner_notes`, `projects`, `users`, `sessions`, `audit_log`, `jobs`; it only sees the `public_tools` view (published + public + not merged) and reference tables. Writes are impossible by privilege except INSERT on `error_reports` and `search_events`; 5 s statement timeout.
16 16 2. Application: public query module imports only the public client; admin modules are `server-only`.
17 17 3. HTTP: `/admin`, `/login`, `/api/admin` send `X-Robots-Tag: noindex` and `Cache-Control: no-store`; `robots.txt` disallows them; sitemap lists public pages only.
18 18 4. Tests: `tests/integration/db.test.ts` asserts the role boundary; `scripts/security-audit.ts` asserts unauthenticated access, IDOR by slug/id, cross-site login, payload field sets and leakage patterns.
added docs/verification.md +66 −0
@@ -0,0 +1,66 @@
1 +# Verification report
2 +
3 +All results below were produced on 2026-11-02 against the production build (`pnpm build` + `next start`, port 8540) on the operator laptop (macOS 27, Node 25.9, PostgreSQL 17.9 + pgvector 0.8.2), commit `46938d6` plus the fixes committed as the deployment commit. Nothing marked as passed was skipped. Time zone America/Toronto.
4 +
5 +## Content (import of `content/` into the dev database)
6 +| Metric | Value |
7 +|---|---|
8 +| Public records published | **353** (target ≥ 150) |
9 +| In-depth (deep) records | **92** (target ≥ 50) — 100–300-word original descriptions, ≥ 3 sources, ≥ 2 alternatives, comparison notes |
10 +| Private owner-platform records | 6 (MacLustr, AKOS, PolyLLM, SPB Git, FinData OS, UQO Research DB) — never public |
11 +| Vendors | 300+ (one per publisher) · Categories: 8 disciplines / 71 leaves |
12 +| Alternative links | 1,249 edges · Distinct sources cited: 1,268 · Collections published: 7 · Owner notes: 54 · Projects: 7 |
13 +| Evidence confidence (published) | high 319 · medium 34 · low 0 |
14 +| Validator (`pnpm content:validate`) | records 359 — **0 errors**, 19 warnings (18 commercial records without a public pricing page, 1 shared homepage between two private records) |
15 +| Duplicate audit | `/admin/duplicates` candidate pairs reviewed: name-similar pairs are distinct products (e.g. mlx/mlx-lm, claude/claude-code, openai-platform/openai-codex); 0 merges needed |
16 +
17 +Command: `pnpm content:validate && pnpm content:import --publish`.
18 +
19 +## Link-validation audit (every URL field of every published record, `safeFetch`, UA declared)
20 +| State | Count |
21 +|---|---|
22 +| ok (all URLs 2xx, or reachable but bot-protected = unverifiable) | **347** |
23 +| redirected (official link now lands on another host, still live) | 6 |
24 +| broken | **0** (`apple-notes` and `automator` were flagged 404 until the checker stopped sending `Accept-Language`, which made Apple's geo-redirect return 404; fixed and re-checked) |
25 +| unchecked | 0 |
26 +| Unverifiable URLs (401/403/405/429, self-redirect loops, stalls) | 25 URL fields across ~20 records (openai.com, chatgpt.com, claude.ai, godaddy.com, tableau.com, mathworks.com, realtor.ca, moodle.org, microsoft.com, milvus.io, …) — listed in Admin → Links |
27 +Commands: worker `link_sweep` (342 jobs succeeded, 0 dead) + `pnpm links:check --slugs …` for the remainder. Duration ≈ 25 min for 353 records.
28 +
29 +## Automated tests
30 +| Suite | Command | Result |
31 +|---|---|---|
32 +| Unit + integration (vitest) | `pnpm test` | **25 passed / 25** (URL normalization, SSRF ranges, HTML meta, zod schema, quality score, markdown sanitizing; DB: import idempotency + revisions, public view hides private/unpublished, trigram typo search, reader-role boundary, append-only audit trigger, one-primary-category constraint, job queue dedupe/claim/backoff/dead-letter/retry/reap, argon2id + policy, login limiter) |
33 +| End-to-end desktop + iPhone 15 (Playwright/Chromium) | `E2E_OWNER_EMAIL=… E2E_OWNER_PASSWORD=… pnpm test:e2e` | **22 passed / 22** (home → autocomplete → tool page; directory query/filter/sort/share URL; empty state; tool page sources/facts/compare/JSON-LD; compare tables; categories & collections; keyboard `/` + skip link; robots/sitemap/health; no-JavaScript SSR fallback; error report; admin login → editor save round trip → review/links/jobs/owner/audit; no owner-note leakage). Admin flows run on the desktop project only (they mutate shared records). |
34 +| Security (black-box) | `pnpm test:security` | **44 passed / 44**: unauthenticated admin pages redirect, 6 admin/auth APIs → 401, 5 mutating APIs → 401/403, cross-site login blocked (403), SQL/XSS/hostile payloads in search & suggest, no private fields in public HTML/JSON, sitemap excludes private paths, CSP/frame-ancestors/nosniff/referrer/no-powered-by, admin noindex, report size/honeypot (400 or 429 when rate-limited), IDOR by slug → 401, path traversal → 404 |
35 +| Accessibility (axe-core, WCAG 2.0/2.1/2.2 A+AA + best-practice) | `pnpm test:a11y` | **0 serious/critical violations** on 10 pages × 2 devices (fixed during hardening: tertiary-text contrast, `aria-pressed` on links, heading order, duplicate search landmarks, non-focusable scroll region) |
36 +| Search ranking | `pnpm eval:ranking` (30 query→slug cases) | MRR **0.756**, hit@1 0.60, hit@5 0.967 (threshold 0.70) — lexical only; semantic fusion inactive without a provider key |
37 +| Type check | `pnpm typecheck` | clean |
38 +
39 +## Performance baseline (production build, laptop, warm, n = 15 sequential requests)
40 +| Route | P50 ms | P95 ms | HTML bytes |
41 +|---|---|---|---|
42 +| `/` (ISR 5 min) | 2 | 6 | 148 k |
43 +| `/tools` (dynamic) | 13 | 16 | 315 k |
44 +| `/tools?q=vector database` | 17 | 22 | 256 k |
45 +| `/tools?category=ai&source=open-source` | 10 | 14 | 262 k |
46 +| `/tools/duckdb` (SSG) | 2 | 2 | 156 k |
47 +| `/categories/analytics-engines` | 7 | 9 | 107 k |
48 +| `/compare?tools=duckdb,postgresql,sqlite` | 5 | 7 | 70 k |
49 +| `/api/suggest?q=post` | 6 | 9 | 1.7 k |
50 +Client JS: 868 KB total chunks, 9 scripts on a tool page (largest 229 KB uncompressed, Next runtime). Budget: keep first-party client JS < 150 KB gzipped; no third-party scripts. Core Web Vitals not yet measured with Lighthouse (not installed on the laptop) — see roadmap.
51 +
52 +## Backup / restore drill
53 +`pnpm restore:drill` → encrypted dump created (AES-256-CBC, PBKDF2 200k), restored into `tools_restore_drill_<ts>`, compared with the source: tools 359 = 359, tool_sources 1282 = 1282, audit_log 722 = 722, owner_notes 54 = 54, content-hash digest identical → **identical: true** (1 s). Temporary database dropped.
54 +
55 +## Editorial spot checks (manual, during review)
56 +- 12 records read against their official sites (duckdb, claude-code, postgresql, zotero, firstrate-data, sec-edgar, caddy, wireguard, qgis, mlx, homebrew, openalex): names, vendors, license identifiers and hosting model consistent with sources; descriptions original.
57 +- Renames caught by research and recorded with aliases: Polygon.io → Massive, Timescale → Tiger Data, MinIO → AIStor, Kùzu archived (discontinued), AutoGen deprecated, Weights & Biases under CoreWeave, Langfuse acquired by ClickHouse, Neon under Databricks, OpenSSH `.org`, Podman repo move.
58 +- Language proofreading: validator bans marketing adjectives and price figures; summaries 60–220 characters.
59 +
60 +## Gates not run / limitations
61 +- Lighthouse/Core Web Vitals: not run (tool unavailable); latency table above is the baseline.
62 +- Dependency audit: `pnpm audit --prod --audit-level high` is part of `scripts/ci.sh`; run before each release (result recorded in the deployment section below).
63 +- E2E for passkeys requires a real authenticator; covered by code review and the unit-tested WebAuthn library only.
64 +
65 +## Production deployment verification (filled in Phase 6)
66 +See the bottom of this file.
modified playwright.config.ts +1 −1
@@ -8,6 +8,6 @@ export default defineConfig({
8 8 use: { baseURL: base, trace: "retain-on-failure" },
9 9 projects: [
10 10 { name: "desktop", use: { ...devices["Desktop Chrome"] } },
11 − { name: "iphone", use: { ...devices["iPhone 15"], browserName: "chromium" } },
11 + { name: "iphone", use: { ...devices["iPhone 15"], browserName: "chromium" }, testIgnore: /admin\.spec/ }, // admin flows mutate shared records → desktop only
12 12 ],
13 13 });
modified scripts/db-roles.ts +1 −1
@@ -13,7 +13,7 @@ for (const role of ["tools_app", "tools_reader"]) {
13 13 if (!exists) { const p = pw(); await sql.unsafe(`CREATE ROLE ${role} LOGIN PASSWORD '${p}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT`); out[role] = p; }
14 14 else if (rotate) { const p = pw(); await sql.unsafe(`ALTER ROLE ${role} PASSWORD '${p}'`); out[role] = p; }
15 15 }
16 −await sql.unsafe(`ALTER ROLE tools_reader SET default_transaction_read_only = on`);
16 +await sql.unsafe(`ALTER ROLE tools_reader RESET default_transaction_read_only`); // reader may INSERT into error_reports/search_events (explicit grants); everything else is denied by privileges
17 17 await sql.unsafe(`ALTER ROLE tools_reader SET statement_timeout = '5s'`);
18 18 await sql.unsafe(`ALTER ROLE tools_app SET statement_timeout = '60s'`);
19 19 const db = (await sql`SELECT current_database() AS d`)[0].d;
deleted scripts/dev/fetch-probe.mjs +0 −5
@@ -1,5 +0,0 @@
1 −for (const u of ["https://qgis.org/", "https://gemini.google.com/", "https://www.r-project.org/Licenses"]) {
2 − try { const r = await fetch(u, { redirect: "manual", headers: { "user-agent": "tools.spboucher.ai link-checker" } }); console.log(u, r.status, r.headers.get("location")); }
3 − catch (e) { console.log(u, "ERR", e.message, e.cause?.code, e.cause?.message?.slice(0, 120)); }
4 −}
5 −import { lookup } from "node:dns/promises"; console.log(await lookup("qgis.org", { all: true }));
deleted scripts/dev/record-probe.ts +0 −5
@@ -1,5 +0,0 @@
1 −import "../_env";
2 −import { getToolRecord } from "../../src/lib/admin/tools";
3 −import { closeDb } from "../../src/db";
4 −try { const r = await getToolRecord("duckdb"); console.log(Object.keys(r ?? {}).length, r?.sources?.[0], r?.pricing_checked_at, r?.reviewed_at); } catch (e) { console.log("ERR", (e as Error).stack?.split("\n").slice(0, 4).join("\n")); }
5 −await closeDb();
modified src/lib/url.ts +1 −1
@@ -63,7 +63,7 @@ export async function safeFetch(url: string, opts: { method?: "GET" | "HEAD"; ma
63 63 const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs);
64 64 let res: Response;
65 65 try {
66 − res = await fetch(current, { method: opts.method ?? "GET", redirect: "manual", signal: ctrl.signal, dispatcher, headers: { "user-agent": UA(), accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.5", "accept-language": "en" }, credentials: "omit" as RequestCredentials } as RequestInit);
66 + res = await fetch(current, { method: opts.method ?? "GET", redirect: "manual", signal: ctrl.signal, dispatcher, headers: { "user-agent": UA(), accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.5" }, credentials: "omit" as RequestCredentials } as RequestInit);
67 67 } finally { clearTimeout(t); }
68 68 if (res.status >= 300 && res.status < 400) {
69 69 const loc = res.headers.get("location"); if (!loc) return { ok: false, status: res.status, finalUrl: current, redirected, hops, contentType: null, body: null, error: "redirect without location", durationMs: Date.now() - started };
modified tests/e2e/public.spec.ts +1 −1
@@ -7,7 +7,7 @@ test("home renders, search autocompletes and navigates", async ({ page }) => {
7 7 });
8 8 test("directory: query, filter toggle, sort and shareable URL", async ({ page }) => {
9 9 await page.goto("/tools?q=database"); await expect(page.getByText(/tools · lexical search/)).toBeVisible();
10 − const disclosure = page.getByText(/^Filters/); if (await disclosure.isVisible()) await disclosure.click();
10 + const disclosure = page.locator("summary", { hasText: "Filters" }); if (await disclosure.isVisible()) await disclosure.click();
11 11 const first = page.getByRole("link", { name: "Open source", exact: false }).first(); await first.click();
12 12 await expect(page).toHaveURL(/source=open-source/); await expect(page.getByText("Share this view")).toBeVisible();
13 13 await page.getByRole("link", { name: "Name" }).click(); await expect(page).toHaveURL(/sort=name/);
modified tests/integration/db.test.ts +1 −1
@@ -37,7 +37,7 @@ describe("database boundary", () => {
37 37 await expect(r`SELECT count(*) FROM owner_notes`).rejects.toThrow(/permission denied/);
38 38 await expect(r`SELECT count(*) FROM users`).rejects.toThrow(/permission denied/);
39 39 await expect(r`SELECT count(*) FROM audit_log`).rejects.toThrow(/permission denied/);
40 − await expect(r`UPDATE vendors SET name='x'`).rejects.toThrow(/permission denied|read-only/);
40 + await expect(r`UPDATE vendors SET name='x'`).rejects.toThrow(/permission denied/);
41 41 await r.end();
42 42 });
43 43 it("audit_log and tool_revisions are append-only", async () => {
44 44
Menu

Theme