diff --git a/.github/CONVENTIONS.md b/.github/CONVENTIONS.md index 4b4887c..4567435 100644 --- a/.github/CONVENTIONS.md +++ b/.github/CONVENTIONS.md @@ -316,14 +316,22 @@ someone might write. The second phrasing survives being checked carelessly; the And when reading a constraint someone else wrote, notice which shape it is. If it is a prohibition, convert it before searching: what is the subject, and where would that subject appear? -## 3. Adding a non-web file makes it public - -`publish = "."`. A new `.md`, `.sh`, `.csv` or `.py` at the root is served the moment it merges, and -nothing looks broken when it happens. `netlify.toml` carries a **denylist**, which does not close the -class. - -If you add a non-web file: put it under `.github/`, `scripts/` or `tools/` (the last two are -404'd by rule), or add a `netlify.toml` rule **in the same commit**. +## 3. Shipping a file is a decision, made in `tools/build-site.sh` + +`publish = "_site"`, and `_site/` is built by `tools/build-site.sh` from an allowlist: web files +at the repository root by extension, plus the directories the script names. A new root file of +any other kind, or a new directory, is **not served** until the script names it, in a commit. +The build refuses if a non-web kind (csv, py, sh, mjs, toml, lockfile, key material) reaches the +output, and it prints every tracked file it withheld, so the build log says what did not ship. + +What that does not decide: a file placed inside an allowlisted directory ships, whatever it is. +`verify-samples/README.md` ships on purpose; a notes file dropped into `credentials/` would ship +by the same rule. The directory list is the boundary, not the file. + +Until 2026-08-25 this section said the opposite, and it was true: `publish = "."` served the +whole repository and `netlify.toml` carried a denylist that could not close the class. Anything +you add outside the allowlist now stays in the repository, which is public, so it is still +readable there. That is a different exposure and the OPEN-CLOSED-RULE governs it. --- diff --git a/.gitignore b/.gitignore index 8033006..1a780c9 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ node_modules/ # and a tracked .pyc showed as modified after every sitemap regeneration (2026-08-24). __pycache__/ *.pyc + +# The built publish directory (tools/build-site.sh). Netlify builds it; never commit it. +_site/ diff --git a/DEMO-SCRIPT-MAR18.md b/DEMO-SCRIPT-MAR18.md deleted file mode 100644 index afbf47c..0000000 --- a/DEMO-SCRIPT-MAR18.md +++ /dev/null @@ -1,145 +0,0 @@ -# Observer Protocol Demo Script -## Lightning Labs Community Call — March 18, 2026 -**Duration:** 5 minutes -**Presenter:** Boyd -**Demo URL:** https://observerprotocol.org/demo - ---- - -## 1. Problem (15 seconds) - -> "L402 proves an agent *paid*. But here's the problem: how do you trust the agent's claims about its track record?" -> -> "Any agent can say 'I've processed 10,000 payments.' But where's the proof? Reputation scores can be gamed. Reviews can be faked." -> -> "What if we could verify claims cryptographically — not with authority, but with math?" - -**[Transition: Screen share the demo page]** - ---- - -## 2. Solution (15 seconds) - -> "Observer Protocol. It's a cryptographic trust layer for agentic payments." -> -> "Don't trust claims. Verify them. Every entry in this registry is backed by a Lightning preimage or an EVM signature." -> -> "Not a reputation score. Not a review. Math." - -**[Gesture to the hero section with live counter]** - ---- - -## 3. Live Feed (30 seconds) - -> "This is the live verification stream. Every few seconds, it polls the registry for new events." -> -> "Each row shows: the protocol — Lightning or x402, the transaction reference, whether it's cryptographically verified, and when it happened." -> -> "Notice the amber pulse? That happens when a new verification lands. It's subtle, but it's the moment math becomes truth." - -**[Point to feed items, scroll if needed]** - ---- - -## 4. The Moment (2 minutes) - -> "Now here's where it gets interesting. I'm going to generate a Lightning invoice. Someone on this call — anyone — can pay it." -> -> "And when that payment settles, you'll see it appear here in real time." - -**[Generate invoice via your LND node, display QR on screen]** - -> "Go ahead — scan it with any Lightning wallet. It's just a few sats." - -**[Wait for payment — this is the demo moment]** - -> "There! Did you see it? The amber pulse — that's the payment being cryptographically verified." -> -> "SHA256 of the preimage equals the payment hash. No trust required. The math is the record." -> -> "This entry is now permanently in the registry. Timestamped. Immutable." - ---- - -## 5. Badge (30 seconds) - -> "Every verified agent gets one of these." -> -> "[Show the SVG badge] It's a cryptographically signed badge that any agent can display. Embed it in your GitHub README, your website, your agent's profile." -> -> "The badge URL is deterministic: badge/{agent-id}.svg. It updates in real time as the agent builds its verification history." -> -> "This is maxi-0001 — my AI agent. It's been running autonomously, making payments, building a track record that can't be faked." - ---- - -## 6. CTA (1 minute) - -> "Here's the ask: We're proposing an `op-verify` skill for Lightning Agent Tools." -> -> "60-second integration. Any L402-enabled agent can join. No gatekeepers. No approval process. Just cryptographic proof." -> -> "The registry is live now at observerprotocol.org. The API is public. The SDK is on npm." -> -> "We're not selling anything. This is free infrastructure. We monetize intelligence at Agentic Terminal, not the protocol." -> -> "If you're building agents that need to prove their track record — or if you're selecting agents and need to verify their claims — talk to us." -> -> "The code is open source. The protocol is permissionless. The math is the trust." - -**[End screen share]** - ---- - -## Backup Plan - -If the live payment fails or takes too long: -- Have a pre-made payment hash ready -- Say: "While we're waiting, here's one that landed earlier today..." -- Click into the feed item to show the verification details - ---- - -## Technical Checklist (Day Of) - -- [ ] Test https://observerprotocol.org/demo loads -- [ ] Verify API endpoints responding: - - [ ] https://api.observerprotocol.org/observer/trends - - [ ] https://api.observerprotocol.org/observer/feed - - [ ] https://api.observerprotocol.org/observer/agents/list -- [ ] Generate test invoice beforehand (have QR ready) -- [ ] Have backup invoice in case first one fails -- [ ] Test screen sharing in call platform -- [ ] Close unnecessary tabs/apps -- [ ] Have LND node ready to generate invoices - ---- - -## Key Talking Points to Hit - -1. **L402 proves payment. OP proves claims.** — The distinction matters -2. **No reputation scores** — This is cryptographic, not social -3. **Rail-neutral** — Lightning today, x402 live, more coming -4. **Permanent record** — Once verified, always verifiable -5. **Free infrastructure** — Not a product pitch, a protocol proposal - ---- - -## Q&A Anticipated - -**Q: How is this different from a block explorer?** -A: Block explorers show *that* a payment happened. Observer Protocol proves *who* made it and builds a verifiable history *across* rails. - -**Q: Can agents fake their identity?** -A: They can create new identities, but they can't fake history. Every verification costs real sats. That's Sybil resistance without a gatekeeper. - -**Q: What's the business model?** -A: We don't monetize the protocol. We monetize intelligence at Agentic Terminal. This is public infrastructure. - -**Q: Is this live now?** -A: Yes. The registry has been running since February 22. The genesis transaction was between two AI agents on mainnet. - ---- - -*Good luck. The math is on your side.* diff --git a/IMPLEMENTATION_GUIDE.md b/IMPLEMENTATION_GUIDE.md deleted file mode 100644 index ebdd923..0000000 --- a/IMPLEMENTATION_GUIDE.md +++ /dev/null @@ -1,186 +0,0 @@ -# Observer Protocol — Site Upgrade v2.0 -## Implementation Guide for Maxi - -**Prepared by Boyd via Claude · March 2026** - ---- - -## What's in this package - -| File | Status | Priority | -|------|--------|----------| -| `shared-styles.css` | New | Deploy first — all pages depend on it | -| `index.html` | Full rewrite | P1 | -| `agents.html` | Full rewrite | P1 — was nearly empty | -| `api.html` | Full rewrite | P1 — replaces broken white Swagger aesthetics | -| `architecture.html` | Existing — no change needed | — | -| `spec.html` | Existing — no change needed | — | -| `sdk.html` | Existing — no change needed | — | - ---- - -## Step 1 — Deploy shared-styles.css - -All three new pages import `shared-styles.css` via a relative path. This file must exist at the root of the site before any pages are deployed, or fonts and variables will be missing. - -```bash -# Copy to site root (adjust path to your actual static file directory) -cp shared-styles.css /path/to/observerprotocol.org/public/shared-styles.css -``` - -The CSS imports IBM Plex Mono and IBM Plex Sans from Google Fonts via CDN. No local font files needed. - ---- - -## Step 2 — Replace index.html - -Drop `index.html` into the site root. Key changes from current version: - -- Hero: proper h1 scale, `npm install @observerprotocol/sdk` copy-to-clipboard block -- Status bar: live network stats strip (hardcoded for now — see Step 4 for live data) -- Genesis block: styled verification card with preimage math -- "What it answers" section: 4-cell grid -- Rails grid: Lightning (live), x402 (live), Solana/Ark/Fedimint (coming) -- 3-step integration with real code snippet -- Principles section with manifesto pull quote -- Footer: consistent across all pages - ---- - -## Step 3 — Replace agents.html - -The old agents page was essentially empty (12 agents listed as one line of text). The new page: - -- Registry stats bar (12 agents, 9 txns, 2 rails, genesis date) -- Filter bar: All / VERIFIED / PENDING / REGISTERED / Lightning / x402 -- Card grid with real data for Maxi (#0001), Vicky (#0002), AgentPay (#0003) -- Cards #0004–#0012 use placeholder key hashes — **update these with real data from the PostgreSQL registry** -- Click any named agent card to open a detail modal -- Badge levels section at bottom -- Register CTA linking to SDK - -### Data to update in agents.html - -Search for `TODO` comments — there are none, but update these hardcoded values with live data: - -| Element | Current Value | Source | -|---------|--------------|--------| -| `id="total-agents"` | 12 | `GET /api/v1/stats` | -| `id="verified-count"` | 9 | `GET /api/v1/stats` | -| Agent #0004–#0012 key hashes | placeholder sha256 stubs | PostgreSQL `agents` table | -| Agent #0004–#0012 statuses | PENDING / REGISTERED | PostgreSQL `agents` table | - -**Optional enhancement:** Replace the hardcoded agent grid with a fetch call to `GET /observer/feed` on page load so the registry stays current automatically. The card template is ready — just needs a JS render loop. - ---- - -## Step 4 — Replace api.html - -The existing site used the default Swagger UI (white background, generic styling). The new `api.html` is a custom dark API reference that: - -- Matches the site aesthetic fully -- Links to the live Swagger UI at `api.observerprotocol.org/docs` for those who want interactive testing -- Documents all 13 endpoints with expandable cards -- Shows real request/response schemas from the Observer Protocol spec -- Includes "Try it ↗" links that hit live endpoints directly - -No backend changes required. The Swagger UI can remain at `/docs` as a secondary reference. - ---- - -## Step 5 — Live data in the status bar (index.html) - -The hero status bar currently shows hardcoded values. To make it live, add this script before `` in `index.html`: - -```javascript -async function refreshStatus() { - try { - const r = await fetch('https://api.observerprotocol.org/api/v1/stats'); - const d = await r.json(); - // Update DOM elements as needed based on actual response shape - // e.g. document.querySelector('.status-bar .registry-entries').textContent = d.registry_entries; - } catch(e) { /* fail silently, hardcoded fallback shows */ } -} -refreshStatus(); -``` - -Adjust field names to match the actual `/stats` response shape. - ---- - -## Step 6 — Nav active states - -The `active` class on nav links is hardcoded per page (e.g., `agents.html` sets `class="active"` on the Agents link). This is intentional — no JS needed. Each file already has the correct active link set. - -If the site uses a template/include system (e.g., SSR or a static site generator), extract the nav into a shared partial and pass the active page as a variable. - ---- - -## Existing pages — no changes needed - -`architecture.html`, `spec.html`, and `sdk.html` are already well-structured with dark aesthetics and solid content. They share the same nav structure, so they will inherit the new nav style automatically once `shared-styles.css` is deployed — **but only if those pages also import it**. - -### Check existing pages for CSS import - -Open each existing page and verify it has: - -```html - -``` - -If existing pages use an inline ` - - -
-
Internal · Sovereign growth
-

Signups + onboarding

-
Loading…
- - - -
-
-
Distinct principals
-
-
- 24h - 7d -
-
-
-
Agents created
-
-
- 24h - 7d -
-
-
-
Delegations issued
-
-
- 24h - 7d -
-
-
-
Agents transacting
-
-
- 24h - 7d -
-
-
- -

Recent agents

-
Loading…
- -

Recent principals

-
Loading…
- -
- What each number means.
- Distinct principals — unique did:web identities that have claimed at least one agent. Proxy for signups that completed onboarding.
- Agents created — Sovereign-path agents (org-less). Each principal can create many.
- Delegations issued — signed delegation credentials. Real signal that someone went through the spending-cap flow.
- Agents transacting — agents whose first verified transaction has landed. The deepest funnel signal.

- Page polls the API every 60 seconds. Bookmark this URL with the ?t= token; it stays valid until rotated. -
-
- - - - diff --git a/netlify.toml b/netlify.toml index ee17306..c33d905 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,110 +1,25 @@ [build] - publish = "." - -# Internal pages are not for public serving. Return 404 for anything -# under /internal/ (force = true so the static files are not served). -[[redirects]] - from = "/internal/*" - to = "/404.html" - status = 404 - force = true + publish = "_site" + command = "bash tools/build-site.sh" # --------------------------------------------------------------------------- -# NON-WEB FILES IN THIS REPOSITORY ARE SERVED BY DEFAULT. THESE ARE NOT. -# -# `publish = "."` above means the publish directory IS the repository, so every -# tracked file ships to the live domain whether or not any page links to it. -# Netlify withholds dotfiles and netlify.toml itself; it withholds nothing else. -# Measured 2026-08-06: nine files were fetchable at observerprotocol.org that -# were never meant to be, among them an internal repo map naming private repos -# and on-disk server paths, a site-upgrade guide describing which agent cards -# carried placeholder data, and three social-media exports belonging to a -# different company. -# -# The rules below return a forced 404 for each. Nothing on the site links to any -# of them, so this removes nothing a reader could reach by navigating. +# THE PUBLISH DIRECTORY IS BUILT, NOT THE REPOSITORY. # -# WHAT THIS DOES NOT DO, which matters more than what it does: +# Until 2026-08-25 this said `publish = "."`, so every tracked file shipped to the +# live domain unless a forced-404 rule below named it. That was a denylist and +# it did not close the class: a new .md, .csv, .sh or .py was public the moment +# it merged, and nothing looked broken when it happened. Measured 2026-08-06: +# nine such files were fetchable, among them an internal repo map, a site-upgrade +# guide, and three social-media exports belonging to a different company. # -# 1. It is a DENYLIST. A new .md, .sh, .csv or .py added to this repository is -# PUBLIC THE MOMENT IT IS MERGED, until someone remembers to add it here. -# Netlify requires the splat at the end of a path, so `/*.md` is not -# available and the class cannot be closed by extension. -# 2. It does not remove the files. They remain in git history and are still -# readable to anyone with repository access, which is the correct scope: -# the defect was public serving, not their existence. -# -# The durable fix is a publish directory that is not the repository root, so -# that shipping a file is a decision rather than the default. That is a repo -# restructure and is deliberately not being done here. +# tools/build-site.sh now builds `_site/` from an ALLOWLIST (root web files by +# extension, plus named directories), refuses if any non-web kind reaches the +# output, and prints every tracked file it withheld. Shipping a file is a +# decision made in that script, in a commit. The per-file forced-404 rules that +# stood here are gone with the files they named (removed from the tree 2026-08-25 +# under the OPEN-CLOSED-RULE ledger) and with the mechanism that made them +# necessary; scripts/ and tools/ are not in the allowlist and do not ship. # --------------------------------------------------------------------------- -[[redirects]] - from = "/REPO_MAP.md" - to = "/404.html" - status = 404 - force = true - -[[redirects]] - from = "/IMPLEMENTATION_GUIDE.md" - to = "/404.html" - status = 404 - force = true - -[[redirects]] - from = "/DEMO-SCRIPT-MAR18.md" - to = "/404.html" - status = 404 - force = true - -[[redirects]] - from = "/observer-protocol-sdk.py" - to = "/404.html" - status = 404 - force = true - -[[redirects]] - from = "/verify-did-resolution.sh" - to = "/404.html" - status = 404 - force = true - -# Directory rule, not a file rule: anything added under scripts/ later is -# covered without a further edit. This is the only part of the block above that -# closes a class rather than an instance. -[[redirects]] - from = "/scripts/*" - to = "/404.html" - status = 404 - force = true - -# Build and verification tooling. Not web content. Same reasoning as /scripts/*: -# the publish directory is the repository, so anything committed here is served -# unless a rule says otherwise. -[[redirects]] - from = "/tools/*" - to = "/404.html" - status = 404 - force = true - -# ArcadiaB social scheduling exports. A different company's marketing copy, -# including Mexican regulated-lending claims, on Observer Protocol's domain. -[[redirects]] - from = "/arcadiab-facebook.csv" - to = "/404.html" - status = 404 - force = true - -[[redirects]] - from = "/arcadiab-linkedin-week.csv" - to = "/404.html" - status = 404 - force = true - -[[redirects]] - from = "/arcadiab-linkedin.csv" - to = "/404.html" - status = 404 - force = true # OP Crossrail product page — canonical extension-less URL. [[redirects]] diff --git a/ows/index.html b/ows/index.html index 814b4f9..a388bfd 100644 --- a/ows/index.html +++ b/ows/index.html @@ -313,7 +313,9 @@

OWS Quickstart

April 2026, is unmaintained, and carries a deprecation notice on the registry itself. It still installs and the steps below still run. For verifying a credential use @observer-protocol/policy-engine; the register-and-attest flow in steps 3 to 5 - has no replacement package yet, which is why this page still names the old one. See + has no replacement package yet, which is why this page still names the old one. The + package's repository pointer names observer-protocol/observer-protocol-spec, + which GitHub now redirects to observer-protocol/op-legacy-deployment. See the SDK page for what replaces which part.
npm install @observer-protocol/sdk
diff --git a/scripts/ATLAS-HANDOFF.md b/scripts/ATLAS-HANDOFF.md deleted file mode 100644 index eadc7d9..0000000 --- a/scripts/ATLAS-HANDOFF.md +++ /dev/null @@ -1,146 +0,0 @@ -# Hand carriage to Atlas: the served-page audit - -> **STATUS 2026-08-20: NOT CARRIED. THE CORROBORATION LEG IS UNBUILT.** -> -> This document describes a scheduled second observer. It does not exist. Checked on this -> machine and on op-vps: no copy of `served-page-audit.mjs` outside this repository, no -> crontab entry running it, no systemd timer running it. Scoped to those two hosts, which -> is what was searched. -> -> **What that costs, precisely.** The audit's exit code 3 means "something disclosed is -> absent on a single observation", and it blocks rather than passing because one look -> cannot tell a real removal from a transient. Resolving it is this scheduler's job: -> a second observation, on a host that keeps its own file, is what turns a single sighting -> into an established absence. With nothing scheduled, exit 3 has nothing that resolves -> it, and the workflow comment in `.github/workflows/verify-published-artifacts.yml` used -> to describe the leg as though it ran. That comment is corrected. -> -> **A guard that reports an absence it cannot distinguish from a missing check is the -> defect this estate keeps finding.** Writing the design down is not building it, and a -> design document reads as a deployment to anyone who finds it without this box. -> -> The figures below are re-derived and current. Carrying it is a decision nobody has taken. - -Under `scripts/`, which `netlify.toml` 404s, so this file is not published. That is -deliberate and it is the same ruling that keeps the audit itself unserved. - -## The file - -| | | -|---|---| -| **file** | `scripts/served-page-audit.mjs` | -| **AUDIT_VERSION** | `3.6.0` | -| **AUDIT_SHA256** (self-hash, recorded in the file) | `624de5d4eec128c41bd68a17d686a233ae3eb99e181a40e12a4e3a09422f3ba6` | -| **sha256 of the file on disk** | `403081a3c94d5e62d378963d924f7834db38eb1e548da8e6afd4e6ff3a2d702a` | -| **size** | 20,007 bytes | -| **confirmed under** | Node **v22.22.3** on darwin | -| **needs** | a Node with global `fetch` (18+). No npm install, no dependencies, no checkout. | - -**Two different hashes, and they answer different questions.** `AUDIT_SHA256` is computed -over the file with its own hash line normalised away, so it does not change when the -recorded value is written in. It is what `--version` prints and what tells you two copies -are the same file. The plain sha256 of the bytes on disk is what you check a transfer with. - -### Carry it, do not fetch it - -The scheduler holds its own copy. It must not pull the script from the zone it is -watching: a monitor that downloads itself from its own subject cannot report that the -subject is compromised. - -### Confirm the copy arrived intact - -```bash -shasum -a 256 served-page-audit.mjs -# expect 403081a3c94d5e62d378963d924f7834db38eb1e548da8e6afd4e6ff3a2d702a - -node served-page-audit.mjs --version -# expect version 3.6.0 and recorded == computed -``` - -`recorded != computed` means the copy was edited and its header was not updated. Both -agreeing but differing from the value above means it is a different version of the file. - -## The daily invocation - -```bash -node served-page-audit.mjs https://observerprotocol.org/check \ - --observations=/var/lib/op-audit/observations.json -``` - -**Nothing is disclosed, and that is the invocation rather than an omission.** With no -`--disclose` the audit asserts that the page carries its own two scripts and nothing else, -so the absence of the analytics injection is *proven on every run* instead of having been -asserted once in a paragraph. The day anything is added at the edge, this goes red without -anyone having to remember what used to be there. - -`--observations` is a small JSON file the audit reads and writes. It is how a later run -corroborates what an earlier one saw. Give it a path that survives between runs. - -### Exit codes, which are the whole interface - -| code | meaning | what to do | -|---|---|---| -| `0` | what the page carries is exactly what is expected and disclosed | nothing | -| `1` | a mismatch, established | look. See below for the one that is expected. | -| `2` | could not reach the page | **not a pass.** Nobody looked. Alert differently from 1. | -| `3` | something disclosed is absent, on a single observation | **not a pass and not the signal.** It will resolve itself into 0 or 1 on the next run. | - -Do not collapse 2 and 3 into "failed", and do not treat either as "passed". A scheduler -that maps everything non-zero to one alarm throws away the distinction this file exists -to draw. - -## What happened, and what the invocation used to be - -Until 17 August 2026 this ran with `--disclose=cloudflareinsights`, because the CDN was -adding an analytics script to every HTML response on the zone and `/check` disclosed it. -The setting was disabled on 17 August. Two runs an hour apart, on 17 and 18 August, both -found it absent; on the second the audit reported `absent and corroborated` and exited 1, -which was the intended signal rather than a break. - -The repair was three edits made together on the website side: the disclosure paragraph -came off `/check`, the strong sentence went back to its full form, and `--disclose` came -out of this invocation. Nothing here should now return 1 in the disclosed-but-absent -direction, because nothing is disclosed. - -**If a red result appears, it means something was added.** That is what the invocation is -now shaped to find. - -### Why 60 minutes, and why corroboration at all - -Absence has two causes and one observation cannot separate them: the setting was changed, -or the edge transiently did not inject. The first is the thing being waited for; the -second means nothing. Corroboration across time is the cheapest thing that tells them -apart. - -It is also why a clean run **does not by itself** authorise the website change. That needs -all three of: - -1. the operator states the toggle was flipped -2. two runs at least 60 minutes apart, both corroborating the absence -3. the CDN's own status page showing the dashboard incident resolved - -Condition 3 is not ceremony. While the dashboard is in an incident the toggle cannot be -flipped at all, so an absence observed during one is evidence about the incident and not -about the setting. - -## What this establishes, and what it does not - -It reads markup. It executes nothing. **So it reports what was delivered, not what is -performed.** It sees every script the page carries, external or inline, and it cannot see -what any of them does once it runs. A runtime request made by a script that is already -disclosed is outside it. - -An earlier version read only external references, and an inline script issuing a request -on load came back clean and printed that the page loaded nothing. That is why inline is -adjudicated now, and why this paragraph is here rather than left to be discovered. - -## If the page legitimately changes - -The audit carries the sha256 of `check.html`'s own inline scripts, so that a **removal** of -the page's verifier fails as loudly as an injection. Those hashes go stale when the page -is edited, and the tempting repair is to delete the entry, which silently removes the -positive assertion. - -Do not. In the repository, `scripts/check-audit-expectations.mjs` recomputes them and fails -the build with the new values to paste in. Carry the updated file to Atlas afterwards and -re-check both hashes above. diff --git a/scripts/check-measured-figures.mjs b/scripts/check-measured-figures.mjs index 06ea90b..bc84cf1 100644 --- a/scripts/check-measured-figures.mjs +++ b/scripts/check-measured-figures.mjs @@ -74,7 +74,7 @@ for (const f of readdirSync(resultsDir)) { const htmlFiles = []; (function walk(dir) { for (const e of readdirSync(dir)) { - if (e === 'node_modules' || e === '.git' || e === 'results') continue; + if (e === '_site' || e === 'node_modules' || e === '.git' || e === 'results') continue; const p = join(dir, e); if (statSync(p).isDirectory()) walk(p); else if (e.endsWith('.html')) htmlFiles.push(p); diff --git a/scripts/check-outbound-links.mjs b/scripts/check-outbound-links.mjs index 236fab9..2ab265f 100644 --- a/scripts/check-outbound-links.mjs +++ b/scripts/check-outbound-links.mjs @@ -52,7 +52,7 @@ const TIMEOUT_MS = 20000; const htmlFiles = []; (function walk(dir) { for (const e of readdirSync(dir)) { - if (e === 'node_modules' || e === '.git' || e === 'results') continue; + if (e === '_site' || e === 'node_modules' || e === '.git' || e === 'results') continue; const p = join(dir, e); if (statSync(p).isDirectory()) walk(p); else if (e.endsWith('.html')) htmlFiles.push(p); diff --git a/scripts/check-shared-copy.mjs b/scripts/check-shared-copy.mjs index 4c67a16..e08f6bb 100644 --- a/scripts/check-shared-copy.mjs +++ b/scripts/check-shared-copy.mjs @@ -229,7 +229,7 @@ console.log(`Shared copy is consistent across ${checked} block(s).`); // Every HTML file in the repo, so a new page cannot carry the claim unnoticed. function allHtml(dir = root, acc = []) { for (const e of readdirSync(dir)) { - if (e === 'node_modules' || e === '.git') continue; + if (e === '_site' || e === 'node_modules' || e === '.git') continue; const full = join(dir, e); if (statSync(full).isDirectory()) allHtml(full, acc); else if (e.endsWith('.html')) acc.push(full); diff --git a/scripts/sentences-at-risk.mjs b/scripts/sentences-at-risk.mjs index c2e6c0f..0b86e77 100644 --- a/scripts/sentences-at-risk.mjs +++ b/scripts/sentences-at-risk.mjs @@ -129,7 +129,7 @@ for (const f of readdirSync(resultsDir).filter((n) => n.endsWith('.json'))) { const pages = []; (function walkDir(d) { for (const e of readdirSync(d)) { - if (e === 'dist' || e === 'node_modules' || e.startsWith('.')) continue; + if (e === '_site' || e === 'dist' || e === 'node_modules' || e.startsWith('.')) continue; const p = join(d, e); try { if (readdirSync(p).length >= 0) { walkDir(p); continue; } } catch { /* a file */ } if (e.endsWith('.html')) pages.push(p); @@ -187,7 +187,7 @@ const scalars = (txt, path) => { const servedDocs = []; (function walkDocs(d) { for (const e of readdirSync(d, { withFileTypes: true })) { - if (e.name === 'dist' || e.name === 'node_modules' || e.name.startsWith('.')) continue; + if (e.name === '_site' || e.name === 'dist' || e.name === 'node_modules' || e.name.startsWith('.')) continue; const p2 = join(d, e.name); if (e.isDirectory()) walkDocs(p2); else if (e.name.endsWith('.html') || e.name.endsWith('.md')) servedDocs.push(p2); diff --git a/scripts/sweep-sentences.mjs b/scripts/sweep-sentences.mjs index 31ee28b..553c5eb 100644 --- a/scripts/sweep-sentences.mjs +++ b/scripts/sweep-sentences.mjs @@ -52,7 +52,7 @@ export function sweepSource(src, subject, verbs, strip = keepNewlines) { function htmlFiles(dir, out = []) { for (const e of readdirSync(dir)) { - if (e === 'node_modules' || e === '.git') continue; + if (e === '_site' || e === 'node_modules' || e === '.git') continue; const p = join(dir, e); if (statSync(p).isDirectory()) htmlFiles(p, out); else if (e.endsWith('.html')) out.push(p); diff --git a/sitemap.xml b/sitemap.xml index 64c189d..568e041 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -46,7 +46,7 @@ https://observerprotocol.org/hermes-agents.html - 2026-08-24 + 2026-08-25 https://observerprotocol.org/institutional/ @@ -54,11 +54,11 @@ https://observerprotocol.org/integrate/ - 2026-08-23 + 2026-08-25 https://observerprotocol.org/ows/ - 2026-08-09 + 2026-08-25 https://observerprotocol.org/papers/ diff --git a/tools/build-site.sh b/tools/build-site.sh new file mode 100755 index 0000000..f992baf --- /dev/null +++ b/tools/build-site.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# ─── build-site.sh: the publish directory is BUILT, and shipping a file is a decision ─────── +# +# Until 2026-08-25 netlify.toml said `publish = "."`, so the publish directory was the +# repository and every tracked file shipped to observerprotocol.org unless a forced-404 rule +# named it. That was a denylist: a new .md, .csv, .sh or .py was public the moment it merged, +# and nothing looked broken when it happened. Three ArcadiaB social exports, an internal repo +# map and a site-upgrade guide went through exactly that gap (measured 2026-08-06). +# +# This script builds `_site/` from an ALLOWLIST and netlify.toml publishes `_site/`. A file +# reaches the domain only if it is (a) a web file at the repository root, by extension, or +# (b) inside a directory named below. A new root file of any other kind, or a new directory, +# is not served until someone adds it here, in a commit, on purpose. +# +# WHAT THIS DOES NOT DECIDE. A file placed inside an allowlisted directory ships, whatever it +# is: verify-samples/README.md ships on purpose, and a notes.md dropped into credentials/ would +# ship by the same rule. The directory list is the decision boundary, not the file. +# +# WHAT IT REPORTS. Every tracked file it did NOT ship is printed, so the build log says what +# was withheld rather than leaving it to be noticed. And it refuses outright if anything of a +# non-web kind (csv, py, sh, mjs, toml, lockfile, env, key material) reached the output, so a +# mistake in the allowlist fails the build instead of shipping. +# +# Usage: tools/build-site.sh [OUT] OUT defaults to /_site (untracked, .gitignore'd) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${1:-$ROOT/_site}" + +# ─── THE ALLOWLIST ──────────────────────────────────────────────────────────────────────── +# Directories copied whole (regular files only, dotfiles skipped, node_modules never). +DIRS=( + .well-known + blog + credentials + genesis + institutional + integrate + ows + papers + results + schemas + spec + status-lists + test-issuers + verify-samples +) +# Root-level files, by extension. Nothing else at the root ships. +ROOT_EXT='html|css|png|jpg|jpeg|gif|svg|ico|webp|xml|txt' +# Extensions that must never appear anywhere in the output. A second guard, not the mechanism. +REFUSE_EXT='csv|py|sh|mjs|cjs|toml|yml|yaml|env|pem|key|lock' + +case "$OUT" in "$ROOT"/*) ;; *) echo "build-site: OUT must be inside the repository: $OUT" >&2; exit 2;; esac +rm -rf "$OUT" +mkdir -p "$OUT" + +shipped=0 +# (a) root files by extension +for f in "$ROOT"/*; do + [ -f "$f" ] || continue + b="$(basename "$f")" + case "$b" in .*) continue;; esac + if printf '%s' "$b" | grep -Eq "\.($ROOT_EXT)$"; then + cp -p "$f" "$OUT/$b"; shipped=$((shipped+1)) + fi +done +# (b) allowlisted directories +for d in "${DIRS[@]}"; do + if [ ! -d "$ROOT/$d" ]; then echo "build-site: allowlisted directory absent: $d" >&2; exit 2; fi + while IFS= read -r -d '' f; do + rel="${f#"$ROOT"/}" + case "$rel" in */node_modules/*|node_modules/*) continue;; esac + mkdir -p "$OUT/$(dirname "$rel")" + cp -p "$f" "$OUT/$rel"; shipped=$((shipped+1)) + done < <(find "$ROOT/$d" -type f ! -name '.*' -print0) +done + +# ─── REFUSE non-web kinds in the output ──────────────────────────────────────────────────── +bad="$(cd "$OUT" && find . -type f | grep -E "\.($REFUSE_EXT)$" || true)" +if [ -n "$bad" ]; then + echo "build-site: REFUSED, non-web files reached the output:" >&2 + printf ' %s\n' $bad >&2 + exit 1 +fi + +# ─── REPORT what was withheld ────────────────────────────────────────────────────────────── +echo "build-site: shipped $shipped file(s) to ${OUT#"$ROOT"/}/" +if git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + withheld=0 + while IFS= read -r t; do + [ -f "$OUT/$t" ] || { [ $withheld -eq 0 ] && echo "build-site: tracked and NOT shipped:"; echo " $t"; withheld=$((withheld+1)); } + done < <(git -C "$ROOT" ls-files) + echo "build-site: withheld $withheld tracked file(s)." +else + echo "build-site: not a git checkout here, so the withheld list is not derivable; nothing is reported." +fi