feat(validator): OTE validator, discovery package and fetcher Worker - #61
Merged
Conversation
The custom-domain fix from 7e307e4 lived inside apps/publish, but editor and preview guess the same URLs from the same `?repo=` context and had their own narrower copies — one that still broke on custom domains. @opentechevents/feed-urls now owns the rule: a fork on a custom domain answers `owner.github.io/name/<file>` with a 301 that carries no CORS header, so the browser blocks the fetch before reaching the destination that would have allowed it. Script cannot read the redirect's Location, so the domain has to arrive from outside the fetch — an explicit `?feed=` or the origin of the dashboard that linked here. Hence a list of candidates to try in order, not one string. Consumers change shape accordingly: editor's `pagesFeedUrl: string` becomes `pagesFeedUrls: string[]` behind a `fetchFirstJson` helper, and publish drops its local copy of the logic.
…cher Answers "is this document a valid OTE feed or event?" with nothing to install, in three modes — a URL, an uploaded file, pasted JSON — and gives the format an objective referee to link to from an issue instead of an argument. Implements issue #60. Three components, split by what each is allowed to do: apps/validator — the page, static, served at /validator/. Upload and paste run entirely in the tab against @opentechevents/validate (reused verbatim: a validator with a second opinion about validity would defeat the purpose), so they keep working with the fetcher down. Discovery and validation are rendered as two separate verdicts, because telling an organizer whose <link> has a typo that their JSON is broken sends them to fix the wrong thing. MUST (schema) and SHOULD (recommended profile) stay in separate sections for the same reason. Findings carry a JSON Pointer plus line:column and highlight that line in the source view. packages/discover-feed — pure functions, no network: bytes + content-type + base URL in, candidate feed URLs out. The reference implementation of spec discovery, kept out of the app because the crawler and the bot will want it, and out of the Worker so spec rules never live behind an SSRF boundary. Media-type matching is lax on purpose while opentechevents-spec#6 is open: application/ote+json and application/feed+json are both accepted and the caller is told which one was served. workers/fetch-url — a Cloudflare Worker, the only component with network access, because a browser cannot fetch a third-party feed without CORS. No database, accounts, auth or persistence: that removes half of the OWASP Top 10 by construction and concentrates the rest in SSRF, which ssrf.ts is entirely about — scheme allowlist, resolve-first then judge the resolved IP (the attacker owns their DNS zone), per-hop revalidated redirects, no credentials, a 5 MB cap applied while streaming, timeouts and per-IP rate limiting. Nothing remote is ever rendered as HTML: every fetched string reaches the DOM through textContent, and the Worker answers JSON with nosniff and a locked-down CSP of its own. Two traps here are invisible to unit tests and cost live debugging, so both are commented at the call site and in the app README: passing the global fetch by reference throws "Illegal invocation" in a browser and in the Workers runtime while Node tolerates it, and ajv compiling schemas with new Function needs 'unsafe-eval' in the page CSP — without it the module throws at import and the page silently registers no listeners at all. boot-errors.js makes that failure state visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tools are reached from that site and carry its name, so a visitor must not feel handed off to a different product. Tokens, type scale, header, buttons, cards and footer come from its stylesheet rather than from a parallel palette invented here: ink #10131a, accent #2b5bd7, radius 10, 1120px wrap, 17px/1.65 system sans. The document viewer reuses that site's dark code block — caption bar, mono, #d5dae4 on #10131a — because the JSON being validated is a code sample and now reads as one; the highlighted line uses the same #7fb3ff the site marks lines with. Two fixes to what the layout was doing wrong: - "Validate as" appears only once there is a verdict. It was offering to correct a detection that had not happened yet. - From 1000px the results split: findings left, document right and sticky. Pointing at line 42 is worth little if line 42 has scrolled away. Below that it stacks, findings first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
validator.opentechevents.org replaces tools.opentechevents.org/validator/ as the canonical URL, and workers/validator (renamed from fetch-url) serves both the page — through an assets binding — and /fetch. Two reasons, one of them structural: The validator is the only tool with no `?repo=` context. The others read an organizer's fork from the query string, which is what the shared tools host expresses; this one serves anyone holding a JSON document, kit user or not, so a path under that host misfiled it. Sharing an origin deletes a class of configuration rather than managing it. The page issues a relative /fetch, so no cross-origin request happens, its CSP tightens to `connect-src 'self'`, and ALLOWED_ORIGINS shrinks to callers that genuinely are elsewhere (the dev server, the legacy path). The endpoint URL is no longer baked into the bundle and the CSP, where the two could drift apart. GitHub Pages keeps /validator/ as a redirect so links already shared survive and permalinks have one canonical form. Deployment moves to deploy-validator.yml, which builds the page, deploys the Worker and then re-runs the SSRF probes against the live endpoint — a deploy that stopped refusing file:// or the metadata address is worse than one that failed. Needs a CLOUDFLARE_API_TOKEN repository secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fetch.opentechevents.org was declared alongside validator.opentechevents.org to give the fetcher an "API name". It did not: assets are served before the Worker script runs, so that hostname served the whole page as well — two canonical URLs for one tool, which is precisely what keeping /validator/ as a redirect exists to prevent. An API hostname, if ever genuinely wanted, needs its own Worker without an assets binding rather than another route onto this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #60.
A public page that answers "is this document a valid OTE feed or event?" with nothing to install — the objective referee to link to from an issue instead of an argument.
What is here
apps/validator/validator/.packages/discover-feedworkers/fetch-urlThe first commit (
refactor(feed-urls)) is pre-existing work that was sitting uncommitted in the working tree — the custom-domain URL logic extracted out ofapps/publishinto its own package. Kept separate so it can be reviewed on its own.Decisions worth reviewing
@opentechevents/validatereused verbatim. A validator holding a second opinion about validity would leave the format without a referee.<link>gets "no OTE feed discovered", not "invalid". A page declaring several feeds stops and asks rather than picking the first.globalThis.fetchand validates a fixture anyway.application/ote+jsonandapplication/feed+jsonboth accepted, and the UI says which one was served.SSRF
The Worker is a public endpoint that fetches whatever URL a stranger passes, so
src/ssrf.tsis the point of the component: scheme allowlist, resolve first and judge the resolved IP (never the hostname — the attacker owns their DNS zone), per-hop revalidated redirects, no credentials forwarded, blocked non-HTTP ports, a 5 MB cap applied while streaming, timeouts and per-IP rate limiting.Verified against the deployed Worker, not only in tests:
file:///etc/passwdblocked-schemehttp://169.254.169.254/latest/meta-data/blocked-address127.0.0.1blocked-addressblocked-addressKnown residual risk, documented: a Worker cannot pin a connection to the IP it validated, so DNS rebinding is not fully excluded.
Two traps unit tests cannot catch
Both were found only by loading the real page, and both are commented at the call site:
fetchby reference throwsIllegal invocationin a browser and in the Workers runtime. Node tolerates it, so 63 tests passed against code that could not make a single request.new Function, so the page CSP needs'unsafe-eval'; without it the module throws at import and the page silently registers no listeners.boot-errors.jsnow makes that state announce itself. The durable fix is build-time standalone validators, which would let the CSP drop'unsafe-eval'.Deployment state
https://ote-fetch-url.hhkaos.workers.dev, plus thefetch.opentechevents.orgcustom domain, declared inwrangler.jsonc(live once the.orgdelegation to Cloudflare lands).OTE_FETCH_ENDPOINTrepository variable points the page at the Worker;deploy-tools.ymlbakes it into both the bundle and the CSPconnect-src, so the two cannot drift.localhost:8000is currently in the Worker's CORS allowlist for local development — say the word and it comes out.Checks
pnpm install --frozen-lockfile,lint,build,typecheckand the full suite (1066 tests across 18 projects) pass on this branch. Both commits are independently installable — the lockfile was regenerated for each.🤖 Generated with Claude Code