diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/live-probe.yml b/.github/workflows/live-probe.yml new file mode 100644 index 0000000..fd4c731 --- /dev/null +++ b/.github/workflows/live-probe.yml @@ -0,0 +1,22 @@ +name: Live probe + +on: + workflow_dispatch: + schedule: + - cron: '29 6 * * 1' + +permissions: + contents: read + +jobs: + probe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + - name: Probe production browser.gainratio.com + run: node scripts/live-probe.mjs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..34ad5fd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ +# CLAUDE.md — browser-docs (`browser.gainratio.com`) + +Canonical agent instructions for the EdgeProc Browser documentation site (`gainratio/browser-docs`). + +## Status & Portfolio Pointers + +- **Live URL:** `https://browser.gainratio.com/` (Cloudflare Pages project `browser-docs-bai`) +- **Upstream library repo:** `https://github.com/gainratio/edgeproc-browser` (npm `@gainratio/browser`, current published version `0.4.6`) +- **Portfolio hub:** `https://open-source.gainratio.com/edgeproc-browser/` +- **Portfolio engineering standard §8 (WASM):** Not applicable to this docs site — pure static HTML/CSS with zero client-side JavaScript or WASM assets. +- **Portfolio engineering standard §3.1–§3.4:** §3.1 enforced via strict `default-src 'none'` CSP and executable-script ban in `scripts/check.mjs`; §3.2–§3.4 N/A. + +## Stack & Layout + +- **Runtime:** Node 22+ (build and gate scripts only; zero npm dependencies). +- **Pages:** `index.html`, `docs/index.html`, `examples/index.html`, `security/index.html`, `404.html`. +- **Public root artifacts:** `_headers`, `robots.txt`, `sitemap.xml`, `llms.txt`, `favicon.svg`, `favicon.ico`. +- **Scripts:** + - `scripts/build.mjs` — copies explicit `ROOT_FILES` + `DIRS` allowlist into `dist/`. + - `scripts/check.mjs` — validates HTML tag nesting, headings, anchors, JSON-LD, sitemap, `llms.txt`, and strict `_headers` CSP. + - `scripts/live-probe.mjs` — secret-free production probe (`live-probe.yml`). + +## Core Invariants + +1. **Zero executable scripts on pages.** `scripts/check.mjs` rejects any ` @@ -53,7 +59,7 @@
-

Open source · TypeScript · MIT · v0.4.4

+

Open source · TypeScript · MIT · v0.4.6

Signed data, checked in the browser.

Have the page check that downloaded data was signed by you and not changed, then keep it for offline use. @gainratio/browser verifies a signed bundle in a Web Worker before your app sees a byte.

@@ -65,7 +71,7 @@

Signed data, checked in the browser.

  • SHA-256 content addresses
  • SQLite on OPFS
  • Typed refusals
  • -
  • 0.4.4
  • +
  • 0.4.6
  • AlmaMesh uses it to download its chart engine.

    @@ -285,7 +291,7 @@

    It cannot protect a compromised page

    It is still pre-1.0

    -

    0.4.4 is the current release. Chromium gets the full browser suite; Firefox and WebKit get a cross-browser test. Playwright's WebKit refuses OPFS, and real Safari is not tested.

    +

    0.4.6 is the current release. Chromium gets the full browser suite; Firefox and WebKit get a cross-browser test. Playwright's WebKit refuses OPFS, and real Safari is not tested.

    diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..ed2a21a --- /dev/null +++ b/llms.txt @@ -0,0 +1,18 @@ +# @gainratio/browser (EdgeProc Browser) — Signed Data Checked in the Browser + +> TypeScript browser engine (`@gainratio/browser` v0.4.6 on npm) for syncing Ed25519-signed, SHA-256 content-addressed data bundles inside a Web Worker with anti-rollback sequence enforcement and SQLite-on-OPFS caching. + +- Live Documentation: https://browser.gainratio.com/ +- Open Source Hub Overview: https://open-source.gainratio.com/edgeproc-browser/ +- Portfolio Hub: https://open-source.gainratio.com/ +- Source Repository: https://github.com/gainratio/edgeproc-browser +- npm Package: https://www.npmjs.com/package/@gainratio/browser +- Upstream Python Publisher (`edge-proc`): https://github.com/gainratio/edge-proc +- Sitemap: https://browser.gainratio.com/sitemap.xml + +## Documentation Pages + +- [Overview & Quickstart](https://browser.gainratio.com/): Why checksums are not signatures, Web Worker verification flow, copyable Node and Vite quickstarts, storage boundaries, and browser compatibility. +- [API & Concepts Reference](https://browser.gainratio.com/docs/): Install `@gainratio/browser`, bundle publishing via `edgeproc`, `EngineClient` + `@gainratio/browser/worker`, `syncIndex`, keyring rotation, SQLite/OPFS storage, vector subpaths, `/seal` passphrase encryption, and typed error codes. +- [Runnable Examples](https://browser.gainratio.com/examples/): Copyable Vite + Worker setup, Node verification, cache inspection, keyring rotation, SQL queries, vector retrieval, sealed exports, and tamper/rollback refusal examples. +- [Security & Threat Model](https://browser.gainratio.com/security/): Trust-root custody, anti-rollback sequence guarantees, expiry handling, OIDC npm provenance, supported versions (`0.4.6`), and private vulnerability reporting. diff --git a/package.json b/package.json index 6476be7..8266c4c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "description": "Standalone documentation site for @gainratio/browser (browser.gainratio.com)", "scripts": { "build": "node scripts/build.mjs", - "check": "node scripts/check.mjs" + "check": "node scripts/check.mjs", + "gate": "npm run build && npm run check", + "probe:live": "node scripts/live-probe.mjs" } } diff --git a/scripts/build.mjs b/scripts/build.mjs index d73f7fb..7dfe6b5 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -13,6 +13,7 @@ const ROOT_FILES = [ "_headers", "robots.txt", "sitemap.xml", + "llms.txt", "favicon.ico", "favicon.svg", "b1e67f1efb625383dea94135ac083633.txt", diff --git a/scripts/check.mjs b/scripts/check.mjs index 930a412..979e715 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -23,6 +23,7 @@ const REQUIRED_ASSETS = [ "favicon.svg", "robots.txt", "sitemap.xml", + "llms.txt", "_headers", "scripts/check.mjs", ]; @@ -432,11 +433,34 @@ if (!sitemap.includes('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"')) { fail("sitemap namespace missing"); } -/* Robots. */ +/* Robots and llms.txt. */ const robots = read("robots.txt"); if (!/^User-agent:\s*\*$/m.test(robots)) fail("robots.txt missing User-agent: *"); if (!/^Allow:\s*\/$/m.test(robots)) fail("robots.txt missing Allow: /"); if (!robots.includes(`Sitemap: ${SITE}/sitemap.xml`)) fail("robots.txt sitemap"); +if (existsSync(join(ROOT, "llms.txt"))) { + const llms = read("llms.txt"); + const hasHttpsUrl = (expectedHost, expectedPath = "/") => { + for (const [raw] of llms.matchAll(/https?:\/\/[^\s)>\]"`]+/g)) { + try { + const parsed = new URL(raw.replace(/[.,;:]+$/, "")); + if (parsed.protocol === "https:" && parsed.hostname === expectedHost && parsed.pathname === expectedPath) { + return true; + } + } catch { + // ignore non-URL tokens + } + } + return false; + }; + for (const loc of expectedLocs) { + const target = new URL(loc); + if (!hasHttpsUrl(target.hostname, target.pathname)) fail(`llms.txt missing ${loc}`); + } + if (!hasHttpsUrl("open-source.gainratio.com", "/")) { + fail("llms.txt missing https://open-source.gainratio.com/"); + } +} /* Security headers and caching. */ const headers = read("_headers"); @@ -495,7 +519,8 @@ for (const page of PAGES) { const distDir = join(ROOT, "dist"); if (existsSync(distDir)) { - for (const forbidden of ["package.json", "README.md", "SECURITY.md", "LICENSE", "scripts"]) { + if (!existsSync(join(distDir, "llms.txt"))) fail("dist/: missing llms.txt"); + for (const forbidden of ["package.json", "README.md", "CLAUDE.md", "SECURITY.md", "LICENSE", "scripts"]) { if (readdirSync(distDir).includes(forbidden)) fail(`dist/: leaked non-public file ${forbidden}`); } } diff --git a/scripts/live-probe.mjs b/scripts/live-probe.mjs new file mode 100644 index 0000000..1b71e99 --- /dev/null +++ b/scripts/live-probe.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Secret-free live production probe for https://browser.gainratio.com. + * Verifies robots.txt, sitemap.xml, llms.txt, HTTP 404 on unknown route, + * and canonical/H1/JSON-LD across all sitemap URLs. + */ +const HOST = "browser.gainratio.com"; +const PAGES_HOST = "browser-docs-bai.pages.dev"; +const BASE = `https://${HOST}`; +const failures = []; + +async function fetchRaw(url) { + const res = await fetch(url, { + headers: { "user-agent": "GainRatio-Live-Probe/1.0 (+https://gainratio.com)" }, + signal: AbortSignal.timeout(25_000), + }); + return { + status: res.status, + type: res.headers.get("content-type") ?? "", + mitigated: res.headers.get("cf-mitigated") ?? "", + body: await res.text(), + }; +} + +async function fetchSite(url) { + const res = await fetchRaw(url); + if (res.status === 403 && res.mitigated === "challenge") { + const fallback = new URL(url); + fallback.hostname = PAGES_HOST; + return fetchRaw(fallback.href); + } + return res; +} + +function hasHttpsUrl(text, expectedHost, expectedPath = "/") { + for (const [raw] of text.matchAll(/https?:\/\/[^\s)>\]"`]+/g)) { + try { + const parsed = new URL(raw.replace(/[.,;:]+$/, "")); + if (parsed.protocol === "https:" && parsed.hostname === expectedHost && parsed.pathname === expectedPath) { + return true; + } + } catch { + // ignore non-URL tokens + } + } + return false; +} + +const robots = await fetchSite(`${BASE}/robots.txt`); +if (robots.status !== 200 || !robots.body.toLowerCase().includes("sitemap:")) { + failures.push(`robots.txt failed (HTTP ${robots.status})`); +} + +const llms = await fetchSite(`${BASE}/llms.txt`); +if (llms.status !== 200 || !hasHttpsUrl(llms.body, "open-source.gainratio.com", "/")) { + failures.push(`llms.txt failed (HTTP ${llms.status})`); +} + +const unknown = await fetchSite(`${BASE}/nonexistent-live-probe-404`); +if (unknown.status !== 404) { + failures.push(`expected HTTP 404 on unknown route, got HTTP ${unknown.status}`); +} + +const sitemap = await fetchSite(`${BASE}/sitemap.xml`); +if (sitemap.status !== 200) failures.push(`sitemap.xml HTTP ${sitemap.status}`); +const locs = [...sitemap.body.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]); +if (locs.length === 0) failures.push("sitemap.xml has 0 entries"); + +for (const url of locs) { + const page = await fetchSite(url); + if (page.status !== 200 || !page.type.includes("text/html")) { + failures.push(`${url}: HTTP ${page.status} (${page.type})`); + continue; + } + const canonical = page.body.match(/]+rel="canonical"[^>]+href="([^"]+)"/i)?.[1] ?? ""; + if (canonical.replace(/\/$/, "") !== url.replace(/\/$/, "")) { + failures.push(`${url}: canonical mismatch (${canonical})`); + } + if ([...page.body.matchAll(/]/gi)].length !== 1) { + failures.push(`${url}: expected 1

    `); + } +} + +if (failures.length > 0) { + for (const f of failures) console.error(`- ${f}`); + process.exit(1); +} +console.log(`Live probe passed for ${BASE} (${locs.length} pages + llms.txt + 404).`); diff --git a/security/index.html b/security/index.html index d9ed10b..472a753 100644 --- a/security/index.html +++ b/security/index.html @@ -173,7 +173,7 @@

    Supply chain and release checking

    Runtime dependencies are @noble/ed25519, @hpcc-js/wasm-zstd, and, for the opt-in /seal export only, age-encryption. The chunk store and the optional SQLite subpaths share pinned local WASM assets and do not load code from a CDN.

    Supported versions

    -

    This project is pre-1.0. Only the latest published version gets fixes. Version 0.4.4 is current. Once 1.0 ships, the latest minor of the current major will be supported as well.

    +

    This project is pre-1.0. Only the latest published version gets fixes. Version 0.4.6 is current. Once 1.0 ships, the latest minor of the current major will be supported as well.

    Report a vulnerability

    Please do not open a public issue for a security problem. Report it privately in one of two ways: