diff --git a/.github/workflows/deploy-tools.yml b/.github/workflows/deploy-tools.yml index 60a04a1..9c3f29b 100644 --- a/.github/workflows/deploy-tools.yml +++ b/.github/workflows/deploy-tools.yml @@ -10,6 +10,10 @@ # # Not to be confused with build-pages.yml, the reusable workflow that the # ote-template forks call to publish THEIR feed sites. +# +# workers/* are NOT deployed here: a Cloudflare Worker is neither static nor +# served from Pages. `workers/validator` ships through deploy-validator.yml, +# and it carries the validator's own page with it. name: Deploy tools site on: @@ -38,10 +42,26 @@ jobs: - name: Assemble site run: | - mkdir -p _site/editor _site/preview _site/publish _site/embed _site/embed/latest + mkdir -p _site/editor _site/preview _site/publish _site/validator _site/embed _site/embed/latest cp -R apps/editor/dist/. _site/editor/ cp -R apps/preview/dist/. _site/preview/ cp -R apps/publish/dist/. _site/publish/ + # The validator itself is NOT served from Pages: workers/validator + # serves it at validator.opentechevents.org, page and fetch endpoint + # on one origin (which is what lets it need no CORS). This path stays + # as a redirect so links already shared keep working and there is one + # canonical URL for permalinks. + cat > _site/validator/index.html <<'EOF' + + + + OTE validator has moved + + +

The OTE validator now lives at + validator.opentechevents.org.

+ + EOF if [ -d apps/embed/versions ]; then cp -R apps/embed/versions/. _site/embed/ fi @@ -61,6 +81,7 @@ jobs: diff --git a/.github/workflows/deploy-validator.yml b/.github/workflows/deploy-validator.yml new file mode 100644 index 0000000..f89d507 --- /dev/null +++ b/.github/workflows/deploy-validator.yml @@ -0,0 +1,64 @@ +# Deploys workers/validator to Cloudflare: the validator page AND its +# SSRF-guarded fetch endpoint, on one origin +# (validator.opentechevents.org). +# +# Separate from deploy-tools.yml on purpose. That workflow publishes static +# bundles to this repo's GitHub Pages; a Worker is neither static nor served +# from Pages, and this one carries the page with it as its `assets` binding. +# GitHub Pages keeps serving /validator/ as a redirect to the canonical URL. +# +# One-time prerequisite: a CLOUDFLARE_API_TOKEN repository secret with the +# "Edit Cloudflare Workers" template plus read access to the +# opentechevents.org zone (the custom domains are declared in wrangler.jsonc, +# so a deploy re-asserts them). +name: Deploy validator + +on: + push: + branches: [main] + paths: + - "apps/validator/**" + - "workers/validator/**" + - "packages/discover-feed/**" + - "packages/validate/**" + - ".github/workflows/deploy-validator.yml" + workflow_dispatch: + +concurrency: + group: validator + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + # The Worker's `assets` directory is apps/validator/dist, so the page has + # to exist before wrangler runs. `pnpm build` builds every workspace + # package the bundle imports as well. + - run: pnpm build + + - name: Deploy + run: pnpm --filter @opentechevents/validator-service exec wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Smoke check + run: | + set -euo pipefail + base=https://validator.opentechevents.org + curl -fsS "$base/health" > /dev/null + # The endpoint's whole purpose is refusing these; a deploy that + # stopped refusing them is worse than a deploy that failed. + for probe in "file:///etc/passwd" "http://169.254.169.254/"; do + code=$(curl -s -o /dev/null -w '%{http_code}' "$base/fetch?url=$(printf %s "$probe" | jq -sRr @uri)") + test "$code" = "400" || { echo "SSRF probe $probe answered $code, expected 400"; exit 1; } + done diff --git a/CLAUDE.md b/CLAUDE.md index 1be5b0b..8f49777 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,19 @@ Central monorepo for the OTE organizer kit. Read DESIGN.md before any task. `pnpm build` has run. That error means the workspace is unbuilt, not broken — never "fix" it by touching imports. CI never hits it: it builds first. - Convention: connectors never invent data; absent field = absent + warning. +- **Visual language: every UI here follows opentechevents.org.** The tools are + reached from that site and carry its name, so a visitor must never feel + handed off to a different product. Take the tokens from its stylesheet + (`https://opentechevents.org/styles.css`) rather than inventing a palette: + ink `#10131a` / soft `#4a5265` / faint `#6f7787`, accent `#2b5bd7` with + `#eaf0fe` soft and `#1e46ab` for hover, line `#e3e6ea`, alt background + `#f6f7f9`, dark `#10131a`, ok `#0f8a5f`, warn `#b06d00`; radius 10px, wrap + 1120px, 17px/1.65 body in the system sans stack, mono for code. Reuse its + components rather than re-designing them: sticky translucent header with the + `OTE` brand mark, `.btn`/`.btn-primary`/`.btn-ghost`, white cards on + `--line` borders, uppercase pill badges, dark code blocks with a caption + bar, dark footer. `apps/validator/styles.css` is the current reference + implementation. When the main site's palette moves, move it here too. - Versioned public assets: `apps/embed` is a consumer-facing Web Component. Read `apps/embed/CLAUDE.md` before changing it. Changes to public widget behavior should use semantic versioning in `apps/embed/package.json`, update @@ -31,6 +44,14 @@ Central monorepo for the OTE organizer kit. Read DESIGN.md before any task. - `apps/editor` has its own `CLAUDE.md` — dev-workflow gotchas (static files aren't watched), a recurring CSS `:not([hidden])` pitfall, and browser-testing notes specific to that app. Read it before editor work. +- `apps/validator` is built here but **served by `workers/validator`** at + `validator.opentechevents.org`, page and `/fetch` endpoint on one origin (so + it needs no CORS and its CSP is `connect-src 'self'`). It is the one tool + without a `?repo=` context, hence its own hostname instead of a path under + `tools.opentechevents.org`, which keeps redirecting to it. Read + `apps/validator/README.md` before touching it: two failure modes there are + invisible to the test suite (the detached global `fetch`, and ajv needing + `'unsafe-eval'` in the page CSP). - `apps/publish` (Broadcast) has its own `CLAUDE.md` too — the pinned-event rule, the generated/guided/planned ladder that lets its destination catalogue be wide without becoming a wall of promises, and the diff --git a/README.md b/README.md index 4581693..7359c78 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Design rationale lives in [DESIGN.md](DESIGN.md); the spec lives in | [`@opentechevents/import-ics`](packages/import-ics/) | iCalendar (`.ics`) → partial OTE event documents (review-and-complete). | | [`@opentechevents/import-jsonld`](packages/import-jsonld/) | schema.org Event JSON-LD in an HTML page → partial OTE event documents. | | [`@opentechevents/build-feed`](packages/build-feed/) | `events/*.json` + `ote.config.json` → validated `feed.json` + `feed.ics` + `feed.xml`. | +| [`@opentechevents/discover-feed`](packages/discover-feed/) | Reference implementation of feed discovery: response bytes + content-type + URL → candidate feed URLs. No network. | All connectors are pure functions with a thin CLI on top. They never invent data: a field absent in the input stays absent in the output. @@ -39,13 +40,32 @@ improvements can be traced package by package. | [`preview`](apps/preview/) | Static feed previewer for OTE organizer forks. | | [`publish`](apps/publish/) | "Broadcast" console: one event → every channel it can be published to. schema.org snippet, widget and subscribe links work today; directories, newsletters and social posts are declared and unbuilt. | | [`embed`](apps/embed/) | Embeddable `` web component: drop an OTE feed into any website. | +| [`validator`](apps/validator/) | Is this document a valid OTE feed or event? Three input modes (URL, file, paste), linkable results, errors pointed at the exact line. Served at `validator.opentechevents.org` by `workers/validator`, not from Pages. | | [`dashboard-checks`](apps/dashboard-checks/) | Client-side setup checks + template-update banner for OTE organizer dashboards. | `editor`, `preview`, `publish` and `embed` are built and deployed together by -`deploy-tools.yml`; `dashboard-checks.js` is served as a standalone file. -Once the `tools.opentechevents.org` custom domain is configured (see -`.github/workflows/deploy-tools.yml`), they're reachable at -`tools.opentechevents.org/editor`, `/preview` and `/embed`. +`deploy-tools.yml` to `tools.opentechevents.org//`; +`dashboard-checks.js` is served as a standalone file. Those four take a +`?repo=owner/name` context from an organizer's fork, which is what the shared +host expresses. + +The `validator` does not: it serves anyone with a JSON document, so it gets +its own hostname, `validator.opentechevents.org`, deployed with the Worker +that its URL mode needs anyway (`deploy-validator.yml`). The Pages path +`/validator/` remains as a redirect. + +## Workers + +| Worker | What it does | +| --- | --- | +| [`validator`](workers/validator/) | Serves `validator.opentechevents.org`: the validator page **and** its fetch endpoint on one origin. The **only** component with network access. | + +The endpoint exists for one mode of one tool: the validator cannot fetch a +third-party feed from the browser, because community feeds send no CORS +headers. This is not the "CORS proxy for reading platforms" that DESIGN.md +rules out — it fetches a document the user already has the URL of, in order to +validate it, and stores nothing. Serving the page from the same Worker means +the page never makes a cross-origin request at all. ## Reusable workflows diff --git a/apps/editor/package.json b/apps/editor/package.json index 7a6b7d6..48490e9 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -14,6 +14,7 @@ "dependencies": { "@opentechevents/build-feed": "workspace:*", "@opentechevents/embed": "workspace:*", + "@opentechevents/feed-urls": "workspace:*", "@opentechevents/import-ics": "workspace:*", "@opentechevents/import-jsonld": "workspace:*", "@opentechevents/preview-feed": "workspace:*", diff --git a/apps/editor/src/lib/repo.ts b/apps/editor/src/lib/repo.ts index 139c590..b2a2bf2 100644 --- a/apps/editor/src/lib/repo.ts +++ b/apps/editor/src/lib/repo.ts @@ -3,6 +3,8 @@ * Pure functions; the actual fetching lives in main.ts. */ +import { pagesUrls, type PagesOrigin } from "@opentechevents/feed-urls"; + import type { ListedEvent, OteEvent } from "./types.js"; // owner: GitHub user/org (alnum + inner hyphens); name: repo name charset. @@ -27,16 +29,20 @@ export function editorContextFromSearch(search: string): EditorContext { export interface RepoFetchPlan { configUrl: string; contentsUrl: string; - pagesFeedUrl: string; + /** Every place the published feed might be; try them in order. */ + pagesFeedUrls: string[]; repoApiUrl: string; } -export function repoFetchPlan(context: EditorContext): RepoFetchPlan | null { +export function repoFetchPlan( + context: EditorContext, + from: PagesOrigin = {}, +): RepoFetchPlan | null { if (context.mode === "generator") return null; return { configUrl: rawConfigUrl(context.repo), contentsUrl: contentsApiUrl(context.repo), - pagesFeedUrl: pagesFeedUrl(context.repo), + pagesFeedUrls: pagesFeedUrls(context.repo, from), repoApiUrl: repoApiUrl(context.repo), }; } @@ -57,15 +63,27 @@ export function repoApiUrl(repo: string): string { } /** - * feed.json on the fork's GitHub Pages site. Fallback listing source when the - * contents API is unavailable (rate limit); breaks on custom domains, which - * is accepted — the API path is the primary one. + * The canonical `owner.github.io/name/feed.json`. Kept as its own function + * because `repoFromPagesFeedUrl` below is its exact inverse; fetching code + * wants `pagesFeedUrls`, which also covers custom domains. */ export function pagesFeedUrl(repo: string): string { const [owner, name] = repo.split("/"); return `https://${owner}.github.io/${name}/feed.json`; } +/** + * feed.json on the fork's GitHub Pages site — every candidate, best first. + * Fallback listing source when the contents API is unavailable (rate limit). + * + * A fork on a custom domain answers the `github.io` URL with a redirect that + * carries no CORS header, so that candidate alone would strand exactly those + * organizers; `@opentechevents/feed-urls` explains how the domain is guessed. + */ +export function pagesFeedUrls(repo: string, from: PagesOrigin = {}): string[] { + return pagesUrls(repo, "feed.json", from); +} + /** * Best-effort owner/repo from a GitHub Pages feed URL — the exact shape * pagesFeedUrl builds above, inverted. Returns null for anything else diff --git a/apps/editor/src/main.ts b/apps/editor/src/main.ts index 103ae80..ae5b8a9 100644 --- a/apps/editor/src/main.ts +++ b/apps/editor/src/main.ts @@ -150,6 +150,15 @@ async function fetchJson(url: string): Promise { } } +/** The first URL that answers with JSON, or null when none of them does. */ +async function fetchFirstJson(urls: string[]): Promise { + for (const url of urls) { + const json = await fetchJson(url); + if (json !== null) return json; + } + return null; +} + /** Form state key → the field id its errors and "touched" state hang from. */ function fieldIdForKey(key: keyof FormState): string { switch (key) { @@ -245,6 +254,9 @@ async function startEditor(repo: string | null): Promise { const repoKey = repo ?? "__standalone__"; const fetchPlan = repoFetchPlan( repo === null ? { mode: "generator" } : { mode: "repo", repo }, + // The dashboard that linked here is the only hint a browser gets about a + // fork's custom Pages domain — see @opentechevents/feed-urls. + { referrer: document.referrer, origin: window.location.origin }, ); el("editor").hidden = false; updateRepoBanner(); @@ -1783,7 +1795,7 @@ async function startEditor(repo: string | null): Promise { ); } else { // Rate-limited, private or empty: try the published feed. - listed = parseFeedListing(await fetchJson(fetchPlan.pagesFeedUrl)); + listed = parseFeedListing(await fetchFirstJson(fetchPlan.pagesFeedUrls)); if (listed.length > 0) { addWarning( t( @@ -2914,7 +2926,9 @@ async function startEditor(repo: string | null): Promise { importCheckResult.textContent = t("importBanner.checking", "Checking…"); // Cache-busting query: Pages serves feed.json with long-lived caches. if (fetchPlan === null) return; - void fetchJson(`${fetchPlan.pagesFeedUrl}?t=${Date.now()}`).then((feed) => { + void fetchFirstJson( + fetchPlan.pagesFeedUrls.map((url) => `${url}?t=${Date.now()}`), + ).then((feed) => { if (feed === null) { importCheckResult.textContent = t( "importBanner.checkFailed", diff --git a/apps/editor/test/repo.test.ts b/apps/editor/test/repo.test.ts index fb6e7ab..17e1772 100644 --- a/apps/editor/test/repo.test.ts +++ b/apps/editor/test/repo.test.ts @@ -49,10 +49,24 @@ describe("editorContextFromSearch / repoFetchPlan", () => { "https://raw.githubusercontent.com/octocat/my-events/HEAD/ote.config.json", contentsUrl: "https://api.github.com/repos/octocat/my-events/contents/events", - pagesFeedUrl: "https://octocat.github.io/my-events/feed.json", + pagesFeedUrls: ["https://octocat.github.io/my-events/feed.json"], repoApiUrl: "https://api.github.com/repos/octocat/my-events", }); }); + + // A fork on a custom domain answers the github.io URL with a redirect that + // carries no CORS header, so the referrer's origin has to be tried too. + it("plans the linking dashboard's origin as a possible custom domain", () => { + const plan = repoFetchPlan( + { mode: "repo", repo: "ComBuildersES/events" }, + { referrer: "https://communitybuilders.dev/events/", origin: "https://tools.example" }, + ); + expect(plan?.pagesFeedUrls).toEqual([ + "https://communitybuilders.dev/events/feed.json", + "https://communitybuilders.dev/feed.json", + "https://ComBuildersES.github.io/events/feed.json", + ]); + }); }); describe("URL builders", () => { diff --git a/apps/preview/package.json b/apps/preview/package.json index b7c270d..6ee1e30 100644 --- a/apps/preview/package.json +++ b/apps/preview/package.json @@ -15,6 +15,7 @@ "@fullcalendar/daygrid": "^6.1.21", "@fullcalendar/list": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21", + "@opentechevents/feed-urls": "workspace:*", "@opentechevents/preview-feed": "workspace:*" }, "devDependencies": { diff --git a/apps/preview/src/main.ts b/apps/preview/src/main.ts index 5bea32a..9c003bf 100644 --- a/apps/preview/src/main.ts +++ b/apps/preview/src/main.ts @@ -1,5 +1,6 @@ import { Calendar, type EventInput } from "@fullcalendar/core"; import dayGridPlugin from "@fullcalendar/daygrid"; +import { forkFileUrls } from "@opentechevents/feed-urls"; import listPlugin from "@fullcalendar/list"; import timeGridPlugin from "@fullcalendar/timegrid"; import { @@ -75,13 +76,16 @@ function parseFeedParam(search: string): { url: URL; tab: FileKey } | null { } } -function pagesUrl(repo: string, filename: string): string { - const [owner, name] = repo.split("/"); - return `https://${owner}.github.io/${name}/${filename}`; -} - -function rawUrl(repo: string, filename: string): string { - return `https://raw.githubusercontent.com/${repo}/HEAD/${filename}`; +/** + * Every place this file might be, in order. More than the obvious Pages URL + * because a fork on a custom domain answers that one with a CORS-less + * redirect — see `@opentechevents/feed-urls`. + */ +function fileUrls(repo: string, filename: string): string[] { + return forkFileUrls(repo, filename, { + referrer: document.referrer, + origin: window.location.origin, + }); } function siblingFeedUrl(feedUrl: URL, filename: string): string { @@ -120,13 +124,10 @@ async function loadFile( if (state.directUrl) { result = await fetchText(state.directUrl).catch(() => null); } else if (repo) { - const pages = pagesUrl(repo, state.filename); - const pagesResult = await fetchText(pages).catch(() => null); - finalUrl = pages; - result = pagesResult; - if (result === null || !result.ok) { - finalUrl = rawUrl(repo, state.filename); - result = await fetchText(finalUrl).catch(() => null); + for (const url of fileUrls(repo, state.filename)) { + finalUrl = url; + result = await fetchText(url).catch(() => null); + if (result?.ok) break; } } diff --git a/apps/publish/package.json b/apps/publish/package.json index b2ba95c..b183011 100644 --- a/apps/publish/package.json +++ b/apps/publish/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@opentechevents/export-jsonld": "workspace:*", + "@opentechevents/feed-urls": "workspace:*", "@opentechevents/validate": "workspace:*" }, "devDependencies": { diff --git a/apps/publish/src/lib/feed-source.ts b/apps/publish/src/lib/feed-source.ts index c0f9215..cd0e396 100644 --- a/apps/publish/src/lib/feed-source.ts +++ b/apps/publish/src/lib/feed-source.ts @@ -1,3 +1,5 @@ +import { forkFileUrls, httpUrl, type PagesOrigin } from "@opentechevents/feed-urls"; + /** * Where this tool gets a feed from. The dashboard links here as * `?repo=owner/name` (the DESIGN.md convention every central tool follows); @@ -10,18 +12,6 @@ export type FeedSource = const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/; -/** An http(s) URL, or null for anything else — javascript:, data:, file:, junk. */ -function httpUrl(value: string | undefined): string | null { - if (!value) return null; - try { - const url = new URL(value); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.toString(); - } catch { - return null; - } -} - /** * Reads `?repo=` / `?feed=` from a query string. `repo` wins when both are * set — it is the richer context — but the `feed` URL is kept on the source @@ -37,54 +27,17 @@ export function parseFeedSource(search: string): FeedSource | null { return feed ? { kind: "url", url: feed } : null; } -/** Where the organizer came from, used to guess a custom Pages domain. */ -export interface FeedUrlContext { - /** `document.referrer` — the dashboard that linked here, if it sent one. */ - referrer?: string; - /** `location.origin` of this tool, so its own pages are never candidates. */ - origin?: string; -} - /** - * The URLs to try, in order. - * - * A fork's feed normally lives on its GitHub Pages site, but three things go - * wrong with the obvious `owner.github.io/name/feed.json`: - * - * 1. **Custom domains.** When Pages serves a repo from one, the `github.io` - * URL answers `301` to that domain — and the redirect itself carries no - * `Access-Control-Allow-Origin`, so the browser blocks the whole fetch - * before it ever reaches the (perfectly CORS-open) destination. There is - * no way to read the `Location` from script, so the domain has to arrive - * some other way: `?feed=` from the dashboard, or the origin of the - * dashboard that linked here (`document.referrer`, which browsers trim to - * the bare origin cross-origin — hence trying both `/name/feed.json` and - * `/feed.json` under it). - * 2. **Pages not enabled yet**, or still building, while the file is already - * committed — `raw.githubusercontent` on the default branch covers that, - * exactly as `apps/preview` does it. - * 3. A generated feed is never on the default branch at all, so 2 is a - * fallback, not a guarantee. + * The URLs to try, in order: an explicit `?feed=` first — the dashboard knows + * its own address better than we can guess it — then whatever + * `@opentechevents/feed-urls` derives from the repo, which is more than one + * string because of custom domains. That package documents why. */ -export function feedUrls(source: FeedSource, context: FeedUrlContext = {}): string[] { +export function feedUrls(source: FeedSource, from: PagesOrigin = {}): string[] { if (source.kind === "url") return [source.url]; - const [owner, name] = source.repo.split("/"); const candidates = [ - source.url, - ...referrerCandidates(name!, context), - `https://${owner}.github.io/${name}/feed.json`, - `https://raw.githubusercontent.com/${source.repo}/HEAD/feed.json`, - ].filter((url): url is string => url !== undefined); + ...(source.url ? [source.url] : []), + ...forkFileUrls(source.repo, "feed.json", from), + ]; return [...new Set(candidates)]; } - -function referrerCandidates(name: string, context: FeedUrlContext): string[] { - const referrer = httpUrl(context.referrer); - if (referrer === null) return []; - const origin = new URL(referrer).origin; - // Navigating inside this tool sets a referrer too; it is never a feed host. - if (origin === context.origin) return []; - // Project sites keep the repo name in the path, user/org sites and custom - // domains mapped to one repo do not. - return [`${origin}/${name}/feed.json`, `${origin}/feed.json`]; -} diff --git a/apps/validator/README.md b/apps/validator/README.md new file mode 100644 index 0000000..1c734ac --- /dev/null +++ b/apps/validator/README.md @@ -0,0 +1,122 @@ +# apps/validator + +The public answer to *"is this OTE document valid?"* — a static page at +`tools.opentechevents.org/validator/`, nothing to install. + +Vanilla TypeScript + DOM, no framework, same shape as `apps/preview`: +`build.mjs` bundles `src/main.ts` into `dist/main.js` and copies +`index.html`/`styles.css` next to it. + +```sh +pnpm --filter @opentechevents/validator dev # esbuild watch + static server +pnpm --filter @opentechevents/validator test +``` + +## Three modes, two of them offline + +| Mode | Where it runs | +| --- | --- | +| Upload a file | Entirely in the tab. The file is never uploaded. | +| Paste JSON | Entirely in the tab. Nothing is sent anywhere. | +| From a URL | Through `/fetch` on the same origin, served by `workers/validator` — the only component with network access. | + +Upload and paste keep working with the Worker down — there is a test that +deletes `globalThis.fetch` and validates a fixture anyway. That is not a +detail: it means an organizer can check a document they are not ready to +publish, and that an outage degrades one mode instead of the tool. + +## Reuses `@opentechevents/validate` verbatim + +The page runs the same validator as the CLI, CI and `apps/editor`. A validator +with a second opinion about what is valid would leave the format without a +referee, which is the whole reason this page exists. + +## Two verdicts, never merged + +**Discovery** ("I found your feed, here") and **validation** ("it is valid") +are rendered as separate steps. If they were one, an organizer whose `` +has a typo would read *"my JSON is broken"* and go fix the wrong thing. A page +that declares no feed produces "no OTE feed discovered", not "invalid"; a page +declaring several stops and asks which one. + +## MUST and SHOULD, never merged either + +Schema violations make a document **invalid**. Unmet recommendations +(`checkFeedRecommended` / `checkEventRecommended`) leave it valid but harder to +find, filter and subscribe to. They are listed in separate sections with +different wording, because mixing them makes the tool useless for the second +case and alarming for the first. + +## Errors point at a line + +`lib/locate.ts` converts the validator's readable paths (`events[3].location`) +into JSON Pointers and indexes the source text once to find where each pointer +sits, so every finding carries `line:column` and highlights that line in the +document view. A finding whose property is *missing* falls back to its nearest +existing ancestor — the object the user has to open. + +## Nothing remote is ever rendered as HTML + +Every string that came from a fetched document reaches the DOM through +`textContent`. A feed whose event name is `` shows +those characters. `index.html` carries a strict CSP with no `unsafe-inline`, +and `workers/fetch-url` independently answers `nosniff` + `default-src 'none'` +so the endpoint cannot be used to serve someone's feed as a page. + +The document is displayed and validated. It is never executed. + +## The fetch endpoint is same-origin in production + +`build.mjs` leaves `OTE_FETCH_ENDPOINT` empty by default, which makes the page +call a relative `/fetch` — no cross-origin request, and a CSP that says +`connect-src 'self'`. Set the variable to an absolute origin only when page +and endpoint genuinely live apart, which in practice means `pnpm dev`: + +```sh +OTE_FETCH_ENDPOINT=https://ote-validator.hhkaos.workers.dev PORT=8000 \ + pnpm --filter @opentechevents/validator dev +``` + +`build.mjs` substitutes the value into **both** the bundle and the CSP's +`connect-src`, so the two cannot drift — set only one and the page calls an +endpoint its own CSP blocks, which fails at runtime in the one mode that needs +a network. + +Local URL mode also requires that origin to be in the Worker's +`ALLOWED_ORIGINS` (`localhost:8000` already is). Uploading and pasting need +none of this. + +## Permalinks + +`?doc=` re-runs the whole thing: fetch, discover, validate. That is the +form that gets pasted into an issue when a feed is broken, and the reason the +Worker exists at all. A result badge for READMEs is a later step. + +## Two traps no unit test here can catch + +Both cost a live debugging round trip; both are invisible to vitest. + +**Never pass the global `fetch` by reference.** `fetchImpl: fetch` throws +`TypeError: Illegal invocation` when called — the browser requires `window` as +the receiver, and the Workers runtime is stricter still. Node's fetch does not +care, so every test passed against code that could not make a single request. +Both sides now wrap it (`browserFetch` in `src/main.ts`, `boundFetch` in +`workers/fetch-url/src/index.ts`). Do not "simplify" either back. + +**ajv compiles schemas with `new Function`.** A CSP without `'unsafe-eval'` +makes `@opentechevents/validate` throw at import time, which means `main.ts` +never finishes evaluating and the page registers no listeners at all: every +button silently does nothing, in all three modes. `boot-errors.js` exists to +make that state announce itself. The durable fix is build-time standalone +validators, which would let the CSP drop `'unsafe-eval'` again. + +When touching either area, load the page in a real browser before believing +the test suite. + +## Dev workflow gotcha + +Like `apps/editor`: `pnpm dev` only rebuilds `dist/main.js` on save. +`index.html` and `styles.css` are copied into `dist/` once, at startup — and +`index.html` is *rewritten*, not copied, since the CSP placeholder has to be +substituted. After editing either, re-run `node build.mjs` (or restart `pnpm +dev`) rather than copying the file by hand. diff --git a/apps/validator/boot-errors.js b/apps/validator/boot-errors.js new file mode 100644 index 0000000..61b356e --- /dev/null +++ b/apps/validator/boot-errors.js @@ -0,0 +1,32 @@ +// Makes a dead page say so. +// +// If main.js throws while its module graph evaluates — a CSP that blocks +// something it needs, a browser extension, a bad deploy — no listener is ever +// registered and the page just sits there: buttons that do nothing, no +// message, nothing in the UI pointing at the cause. That failure cost a +// debugging round trip once; this file exists so it announces itself instead. +// +// A classic script (no build step, no imports) loaded BEFORE the module, so +// its handlers are already installed when the module fails. +(function () { + "use strict"; + + function show(detail) { + var box = document.getElementById("status"); + if (!box) return; + box.textContent = + "This page failed to load properly, so validation is not available: " + + detail + + " — reloading may help; if it does not, please report it."; + box.dataset.tone = "error"; + box.hidden = false; + } + + window.addEventListener("error", function (event) { + show(event.message || String(event.error)); + }); + + window.addEventListener("unhandledrejection", function (event) { + show(String((event.reason && event.reason.message) || event.reason)); + }); +})(); diff --git a/apps/validator/build.mjs b/apps/validator/build.mjs new file mode 100644 index 0000000..8eecea7 --- /dev/null +++ b/apps/validator/build.mjs @@ -0,0 +1,60 @@ +// Same shape as apps/preview's build: esbuild bundles src/main.ts into +// dist/main.js and the static files are copied next to it. +// +// One addition: the fetcher Worker's origin is substituted into BOTH the +// bundle (as __FETCH_ENDPOINT__) and index.html's CSP connect-src. Doing it +// in one place is the point — a page that can call an endpoint its own CSP +// blocks fails only at runtime, in the one mode that needs a network. +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; + +import * as esbuild from "esbuild"; + +const serve = process.argv.includes("--serve"); + +// Empty means same origin, which is the production shape: workers/validator +// serves this page AND its /fetch endpoint, so the page calls a relative path +// and no cross-origin request happens at all. Set OTE_FETCH_ENDPOINT to an +// absolute origin only when the two are genuinely apart — notably `pnpm dev`, +// where esbuild serves the page on localhost while the fetcher lives on +// Cloudflare. +const FETCH_ENDPOINT = process.env.OTE_FETCH_ENDPOINT ?? ""; + +const options = { + entryPoints: ["src/main.ts"], + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + outfile: "dist/main.js", + sourcemap: true, + minify: !serve, + logLevel: "info", + define: { + __FETCH_ENDPOINT__: JSON.stringify(FETCH_ENDPOINT), + }, +}; + +mkdirSync("dist", { recursive: true }); +for (const file of ["styles.css", "boot-errors.js"]) copyFileSync(file, `dist/${file}`); +// The placeholder carries its own leading space so that removing it (the +// same-origin case) leaves `connect-src 'self'` rather than a stray token. +writeFileSync( + "dist/index.html", + readFileSync("index.html", "utf8").replaceAll( + " __FETCH_ENDPOINT__", + FETCH_ENDPOINT ? ` ${FETCH_ENDPOINT}` : "", + ), +); + +if (serve) { + const ctx = await esbuild.context(options); + await ctx.watch(); + const port = Number(process.env.PORT) || undefined; + const server = await ctx.serve({ + servedir: "dist", + ...(port !== undefined && { port }), + }); + console.log(`Validator running at http://localhost:${server.port}/`); +} else { + await esbuild.build(options); +} diff --git a/apps/validator/index.html b/apps/validator/index.html new file mode 100644 index 0000000..690507c --- /dev/null +++ b/apps/validator/index.html @@ -0,0 +1,174 @@ + + + + + + + + OTE validator + + + + + + + +
+
+
+

Tools

+

OTE validator

+

+ Is this document a valid OpenTechEvents feed or event? Paste a URL, drop a file, or + paste the JSON. Nothing to install. +

+
+
+ +
+ + +
+
+ +
+ + +
+

+ A home page works: the feed is discovered from + <link rel="alternate" type="application/ote+json"> in its head. + This mode is the only one that uses a server — a browser cannot fetch a third-party + feed without CORS. +

+
+
+ + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + + + diff --git a/apps/validator/package.json b/apps/validator/package.json new file mode 100644 index 0000000..f2f26f7 --- /dev/null +++ b/apps/validator/package.json @@ -0,0 +1,24 @@ +{ + "name": "@opentechevents/validator", + "version": "0.1.0", + "description": "Static web validator for OTE documents: validate by URL, file upload or paste", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs", + "dev": "node build.mjs --serve", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@opentechevents/discover-feed": "workspace:*", + "@opentechevents/validate": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "esbuild": "^0.28.1", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + } +} diff --git a/apps/validator/src/lib/locate.ts b/apps/validator/src/lib/locate.ts new file mode 100644 index 0000000..0aa2e35 --- /dev/null +++ b/apps/validator/src/lib/locate.ts @@ -0,0 +1,234 @@ +/** + * Turning "this field is wrong" into "this *line* is wrong". + * + * A validation error the user cannot find in their own file is a riddle, so + * every finding is rendered against the source text. That needs two things + * the validator does not give us: a JSON Pointer (it reports readable paths + * like `events[0].startDate`) and the position of that pointer in the bytes + * the user pasted. Both live here. + * + * The index is built by scanning the source once, not by re-parsing per + * error: a feed with 200 findings would otherwise walk the document 200 + * times. + */ + +/** Where a value sits in the source text. Lines and columns are 1-based. */ +export interface SourcePosition { + offset: number; + line: number; + column: number; +} + +/** + * Converts `@opentechevents/validate`'s readable path into a JSON Pointer + * (RFC 6901): `events[0].location.address` → `/events/0/location/address`. + * + * `(document)` — that package's name for the root — becomes the empty + * pointer. The conversion is best-effort by construction: the readable form + * is lossy for keys that themselves contain `.` or `[`, which no OTE field + * does. When it cannot be mapped the caller simply gets no line number, never + * a wrong one. + */ +export function pathToPointer(path: string): string { + if (!path || path === "(document)") return ""; + const segments: string[] = []; + for (const part of path.split(".")) { + const match = /^([^[\]]*)((?:\[\d+\])*)$/.exec(part); + if (!match) return ""; + if (match[1]) segments.push(match[1]); + for (const index of match[2].matchAll(/\[(\d+)\]/g)) segments.push(index[1]); + } + return segments.map((segment) => `/${escapePointerSegment(segment)}`).join(""); +} + +function escapePointerSegment(segment: string): string { + return segment.replace(/~/g, "~0").replace(/\//g, "~1"); +} + +/** Renders a pointer for display; the root gets a name rather than "". */ +export function formatPointer(pointer: string): string { + return pointer === "" ? "(document root)" : pointer; +} + +interface Cursor { + index: number; + line: number; + column: number; +} + +/** + * A JSON scanner that records where every value starts. + * + * Deliberately its own scanner rather than a parser library: it must accept + * exactly what `JSON.parse` accepts (the document has already been parsed by + * the time this runs), report positions, and never execute anything from a + * document that is, by definition, a stranger's. + */ +class Indexer { + private readonly positions = new Map(); + private readonly cursor: Cursor; + + constructor(private readonly source: string) { + this.cursor = { index: 0, line: 1, column: 1 }; + } + + index(): Map { + this.skipWhitespace(); + this.value(""); + return this.positions; + } + + private at(): string { + return this.source[this.cursor.index] ?? ""; + } + + private advance(count = 1): void { + for (let i = 0; i < count && this.cursor.index < this.source.length; i++) { + if (this.source[this.cursor.index] === "\n") { + this.cursor.line++; + this.cursor.column = 1; + } else { + this.cursor.column++; + } + this.cursor.index++; + } + } + + private skipWhitespace(): void { + while (/[\s]/.test(this.at()) && this.at() !== "") this.advance(); + } + + private position(): SourcePosition { + return { offset: this.cursor.index, line: this.cursor.line, column: this.cursor.column }; + } + + private value(pointer: string): void { + this.skipWhitespace(); + this.positions.set(pointer, this.position()); + const char = this.at(); + if (char === "{") return this.object(pointer); + if (char === "[") return this.array(pointer); + if (char === '"') { + this.string(); + return; + } + // Number, true, false, null: consume until a structural character. + while (this.at() !== "" && !/[\s,\]}]/.test(this.at())) this.advance(); + } + + private object(pointer: string): void { + this.advance(); // { + this.skipWhitespace(); + if (this.at() === "}") { + this.advance(); + return; + } + for (;;) { + this.skipWhitespace(); + const key = this.string(); + this.skipWhitespace(); + if (this.at() === ":") this.advance(); + this.value(`${pointer}/${escapePointerSegment(key)}`); + this.skipWhitespace(); + if (this.at() === ",") { + this.advance(); + continue; + } + if (this.at() === "}") this.advance(); + return; + } + } + + private array(pointer: string): void { + this.advance(); // [ + this.skipWhitespace(); + if (this.at() === "]") { + this.advance(); + return; + } + for (let i = 0; ; i++) { + this.value(`${pointer}/${i}`); + this.skipWhitespace(); + if (this.at() === ",") { + this.advance(); + continue; + } + if (this.at() === "]") this.advance(); + return; + } + } + + /** Consumes a string literal and returns its decoded value. */ + private string(): string { + if (this.at() !== '"') return ""; + this.advance(); + let out = ""; + while (this.at() !== "" && this.at() !== '"') { + if (this.at() === "\\") { + this.advance(); + const escape = this.at(); + if (escape === "u") { + const hex = this.source.slice(this.cursor.index + 1, this.cursor.index + 5); + out += String.fromCharCode(parseInt(hex, 16)); + this.advance(5); + continue; + } + const simple: Record = { + '"': '"', + "\\": "\\", + "/": "/", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + }; + out += simple[escape] ?? escape; + this.advance(); + continue; + } + out += this.at(); + this.advance(); + } + this.advance(); // closing quote + return out; + } +} + +/** Maps every JSON Pointer in the document to where its value starts. */ +export function indexPositions(source: string): Map { + return new Indexer(source).index(); +} + +/** + * Position of one pointer, falling back to the nearest existing ancestor. + * + * The fallback is what makes "is missing required property" useful: the + * pointer for a property that is not there cannot be in the index, but its + * parent object is, and that is the place the user has to look. + */ +export function locatePointer( + positions: Map, + pointer: string, +): SourcePosition | null { + let current = pointer; + for (;;) { + const found = positions.get(current); + if (found) return found; + if (current === "") return null; + const cut = current.lastIndexOf("/"); + current = cut <= 0 ? "" : current.slice(0, cut); + } +} + +/** Line and column of a raw character offset — for JSON syntax errors. */ +export function positionOfOffset(source: string, offset: number): SourcePosition { + const clamped = Math.max(0, Math.min(offset, source.length)); + const before = source.slice(0, clamped); + const lastBreak = before.lastIndexOf("\n"); + return { + offset: clamped, + line: before.split("\n").length, + column: clamped - lastBreak, + }; +} diff --git a/apps/validator/src/lib/report.ts b/apps/validator/src/lib/report.ts new file mode 100644 index 0000000..eb41296 --- /dev/null +++ b/apps/validator/src/lib/report.ts @@ -0,0 +1,189 @@ +/** + * Source text in, verdict out. + * + * Two rules shape everything here: + * + * 1. **`@opentechevents/validate` is reused verbatim.** The validator must not + * hold a second opinion about what is valid — if this page and CI disagree, + * the format has no referee, which is the entire reason this tool exists. + * 2. **MUST and SHOULD are never mixed.** Schema failures mean *invalid*; + * unmet recommendations mean *valid, but findable by fewer people*. + * Blending them makes the tool useless for the second case and cruel for + * the first. + */ + +import { detectDocumentKind, type OteDocumentKind } from "@opentechevents/discover-feed"; +import { + checkEventRecommended, + checkFeedRecommended, + specVersion, + validateEvent, + validateFeed, + type ValidationError, +} from "@opentechevents/validate"; + +import { + indexPositions, + locatePointer, + pathToPointer, + positionOfOffset, + type SourcePosition, +} from "./locate.js"; + +/** Which schema a document is checked against. */ +export type DocumentKind = "feed" | "event"; + +/** One finding, addressed to a place in the user's own file. */ +export interface Finding { + /** Readable path from the validator, e.g. `events[0].startDate`. */ + path: string; + /** RFC 6901 pointer for the same place; "" is the document root. */ + pointer: string; + message: string; + /** Where to look in the source. Null when the pointer cannot be located. */ + position: SourcePosition | null; +} + +export type Report = + | { status: "empty" } + | { status: "too-large"; message: string } + | { status: "too-deep"; message: string } + | { status: "parse-error"; message: string; position: SourcePosition } + | { + status: "validated"; + /** Schema actually applied. */ + kind: DocumentKind; + /** What the document's shape suggested, before any user override. */ + detected: OteDocumentKind; + /** True when nothing MUST-level failed. Recommendations do not affect it. */ + valid: boolean; + /** MUST: schema violations. A document with any of these is invalid. */ + errors: Finding[]; + /** SHOULD: unmet spec recommendations. Never make a document invalid. */ + recommendations: Finding[]; + /** Spec version the bundled validator implements. */ + specVersion: string; + }; + +/** + * Same ceiling the fetcher enforces, applied again here because the paste and + * upload modes never go through the fetcher. + */ +export const MAX_SOURCE_BYTES = 5 * 1024 * 1024; + +/** + * Nesting ceiling, checked by scanning the text *before* `JSON.parse` sees + * it: a deeply nested document is the cheap way to blow a parser's stack, and + * OTE's own schema nests a handful of levels, nowhere near this. + */ +export const MAX_DEPTH = 64; + +/** Deepest bracket nesting in the source, ignoring brackets inside strings. */ +export function maxDepth(source: string): number { + let depth = 0; + let deepest = 0; + let inString = false; + let escaped = false; + for (const char of source) { + if (inString) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"') inString = true; + else if (char === "{" || char === "[") deepest = Math.max(deepest, ++depth); + else if (char === "}" || char === "]") depth--; + } + return deepest; +} + +/** Pulls a source position out of the engine's own SyntaxError message. */ +function parseErrorPosition(source: string, error: unknown): SourcePosition { + const message = error instanceof Error ? error.message : ""; + const lineColumn = /line (\d+) column (\d+)/.exec(message); + if (lineColumn) { + const line = Number(lineColumn[1]); + const column = Number(lineColumn[2]); + const offset = + source.split("\n").slice(0, line - 1).reduce((sum, text) => sum + text.length + 1, 0) + + column - + 1; + return { offset, line, column }; + } + const position = /position (\d+)/.exec(message); + return positionOfOffset(source, position ? Number(position[1]) : 0); +} + +function toFindings( + errors: ValidationError[], + positions: Map, +): Finding[] { + return errors.map(({ path, message }) => { + const pointer = pathToPointer(path); + return { path, pointer, message, position: locatePointer(positions, pointer) }; + }); +} + +export interface ReportOptions { + /** Overrides shape detection when the user corrects it by hand. */ + kind?: DocumentKind; +} + +/** + * Validates a document given as text. + * + * Pure: no network, no DOM, no clock. The upload and paste modes call this + * directly in the browser, which is what makes them work with the fetcher + * down — and what keeps a document nobody wants to share off the network. + */ +export function buildReport(source: string, options: ReportOptions = {}): Report { + if (source.trim() === "") return { status: "empty" }; + + if (source.length > MAX_SOURCE_BYTES) { + return { + status: "too-large", + message: `This document is larger than the ${Math.round(MAX_SOURCE_BYTES / (1024 * 1024))} MB this page validates.`, + }; + } + + if (maxDepth(source) > MAX_DEPTH) { + return { + status: "too-deep", + message: `This document nests deeper than ${MAX_DEPTH} levels, which no OTE document does.`, + }; + } + + let json: unknown; + try { + json = JSON.parse(source); + } catch (error) { + return { + status: "parse-error", + message: error instanceof Error ? error.message : "This is not valid JSON.", + position: parseErrorPosition(source, error), + }; + } + + const detected = detectDocumentKind(json); + // An unrecognizable shape still gets validated — against the feed schema, + // whose errors ("missing events", "missing title") are what an ambiguous + // document usually needs to hear. The UI shows the guess and lets the user + // switch, because the document being debugged is exactly the one whose + // shape is unclear. + const kind: DocumentKind = options.kind ?? (detected === "event" ? "event" : "feed"); + + const positions = indexPositions(source); + const validity = kind === "feed" ? validateFeed(json) : validateEvent(json); + const recommended = kind === "feed" ? checkFeedRecommended(json) : checkEventRecommended(json); + + return { + status: "validated", + kind, + detected, + valid: validity.valid, + errors: toFindings(validity.errors, positions), + recommendations: toFindings(recommended.errors, positions), + specVersion, + }; +} diff --git a/apps/validator/src/lib/resolve.ts b/apps/validator/src/lib/resolve.ts new file mode 100644 index 0000000..3e3cebd --- /dev/null +++ b/apps/validator/src/lib/resolve.ts @@ -0,0 +1,193 @@ +/** + * URL mode: from a URL the user pasted to the document to validate. + * + * The user usually pastes the community's **home page**, not the feed file, + * and that has to work — the spec's primary discovery mechanism is the + * `` in the head. So this is two steps, and the UI shows them as two: + * + * 1. discovery — did we find a feed, and where? + * 2. validation — is that feed valid? + * + * Collapsing them tells an organizer whose `` has a typo that their + * JSON is broken, which is the wrong bug to go fix. + * + * All network access goes through `workers/fetch-url`; this module never + * calls a third-party origin itself (it could not — no CORS). + */ + +import { + discover, + type DiscoverOptions, + type FeedCandidate, + type MediaTypeNote, +} from "@opentechevents/discover-feed"; + +/** The envelope `workers/fetch-url` answers with. */ +export type FetchEnvelope = + | { + ok: true; + finalUrl: string; + status: number; + contentType: string | null; + bytes: number; + redirects: string[]; + body: string; + } + | { ok: false; code: string; message: string }; + +export interface ResolveDeps { + /** + * Base URL of the fetch endpoint. Empty string — the production case — + * means same origin: `workers/validator` serves both this page and + * `/fetch`, so the request is relative and never crosses an origin. + */ + endpoint: string; + fetchImpl: typeof fetch; + options?: DiscoverOptions; +} + +/** Where the document being validated came from — the discovery verdict. */ +export type Provenance = + /** The URL the user gave was itself the document. */ + | { via: "direct"; url: string; note: MediaTypeNote } + /** An HTML page declared exactly one feed, and this is it. */ + | { via: "link"; pageUrl: string; url: string; note: MediaTypeNote } + /** The feed was embedded in the page as `` must render as those characters, or this page + * becomes a stored-XSS vehicle for whoever controls a feed. + */ + +import type { FeedCandidate } from "@opentechevents/discover-feed"; + +import { formatPointer } from "./lib/locate.js"; +import { buildReport, type DocumentKind, type Finding, type Report } from "./lib/report.js"; +import { followCandidate, resolveUrl, type Provenance, type Resolution } from "./lib/resolve.js"; + +/** Injected at build time (see build.mjs); the CSP in index.html must match. */ +declare const __FETCH_ENDPOINT__: string; + +const FETCH_ENDPOINT = __FETCH_ENDPOINT__; + +/** + * The global `fetch`, wrapped rather than passed by reference. + * + * `fetch` detached from `window` throws "Illegal invocation" the moment it is + * called — the browser requires its receiver. Node's fetch does not care, so + * no unit test catches this; it only appears in a real tab, as a request that + * never leaves. The Worker hit the identical trap (see workers/fetch-url's + * `boundFetch`). Keep the wrapper on both sides. + */ +const browserFetch: typeof fetch = (input, init) => fetch(input, init); + +const $ = (id: string): T => { + const element = document.getElementById(id); + if (!element) throw new Error(`missing element #${id}`); + return element as T; +}; + +const modeTabs = document.querySelectorAll(".tab[data-mode]"); +const panels = { + url: $("panel-url"), + file: $("panel-file"), + paste: $("panel-paste"), +}; +const urlForm = $("url-form"); +const urlInput = $("url-input"); +const fileInput = $("file-input"); +const pasteInput = $("paste-input"); +const pasteButton = $("paste-validate"); +const statusBox = $("status"); +const discoveryBox = $("discovery"); +const candidatesBox = $("candidates"); +const verdictBox = $("verdict"); +const resultsBox = $("results"); +const kindRow = $("kind-row"); +const kindSelect = $("kind-select"); +const errorsBox = $("errors"); +const recommendationsBox = $("recommendations"); +const sourceBox = $("source"); +const permalinkBox = $("permalink"); +const permalinkInput = $("permalink-input"); + +/** Everything currently on screen, so a kind override can re-render it. */ +let current: { source: string; label: string; provenance?: Provenance } | null = null; + +function setMode(mode: "url" | "file" | "paste"): void { + for (const tab of modeTabs) { + const active = tab.dataset.mode === mode; + tab.setAttribute("aria-selected", String(active)); + } + for (const [name, panel] of Object.entries(panels)) panel.hidden = name !== mode; +} + +function clearResults(): void { + for (const box of [discoveryBox, candidatesBox, verdictBox, errorsBox, recommendationsBox, sourceBox]) { + box.replaceChildren(); + box.hidden = true; + } + // The results grid and the kind override only exist once there is a + // verdict: an empty two-column layout and a control for correcting a + // detection that has not happened yet are just furniture. + resultsBox.hidden = true; + kindRow.hidden = true; + permalinkBox.hidden = true; +} + +function setStatus(text: string, tone: "info" | "error" = "info"): void { + statusBox.textContent = text; + statusBox.dataset.tone = tone; + statusBox.hidden = text === ""; +} + +function element( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + // textContent, always: this is where somebody else's feed reaches the DOM. + if (text !== undefined) node.textContent = text; + return node; +} + +/* ------------------------------------------------------------------ * + * Step 1: discovery + * ------------------------------------------------------------------ */ + +function mediaTypeLine(provenance: Provenance): string | null { + if (provenance.via === "embedded") return null; + switch (provenance.note.kind) { + case "ote": + return `Served as ${provenance.note.mediaType}.`; + case "generic-json": + return `Served as ${provenance.note.mediaType} — valid to parse, but it does not announce OTE. The spec has not settled on application/ote+json vs. application/feed+json yet.`; + case "missing": + return "Served without a content type; treated as JSON because it parses as JSON."; + } +} + +function renderDiscovery(provenance: Provenance, redirects: string[]): void { + discoveryBox.replaceChildren(); + discoveryBox.hidden = false; + discoveryBox.append(element("h2", undefined, "1. Discovery")); + + const list = element("dl", "facts"); + const fact = (term: string, value: string) => { + list.append(element("dt", undefined, term), element("dd", undefined, value)); + }; + + switch (provenance.via) { + case "direct": + discoveryBox.append(element("p", "ok", "This URL is the OTE document itself.")); + fact("Document", provenance.url); + break; + case "link": + discoveryBox.append( + element("p", "ok", "Found a feed declared by this page's ."), + ); + fact("Page", provenance.pageUrl); + fact("Feed", provenance.url); + break; + case "embedded": + discoveryBox.append( + element("p", "ok", "Found a feed embedded in this page as ", + license: "CC0-1.0", + updatedAt: "not-an-instant", + events: [], + }); + const report = validated(buildReport(source)); + expect(report.valid).toBe(false); + expect(report.errors.some((error) => error.message.includes(""); + }); + + it("runs without any network at all", () => { + // The acceptance criterion "upload and paste work with the Worker down": + // nothing in this path may reach for fetch. + const original = globalThis.fetch; + // @ts-expect-error — deliberately removing fetch for the duration. + delete globalThis.fetch; + try { + expect(validated(buildReport(fixture("valid/feed.json"))).valid).toBe(true); + } finally { + globalThis.fetch = original; + } + }); +}); diff --git a/apps/validator/test/resolve.test.ts b/apps/validator/test/resolve.test.ts new file mode 100644 index 0000000..0e40bf5 --- /dev/null +++ b/apps/validator/test/resolve.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; + +import { followCandidate, resolveUrl } from "../src/lib/resolve.js"; + +const ENDPOINT = "https://fetch.example"; + +const FEED = '{"specVersion":"0.3.0","title":"Comunidad","license":"CC0-1.0","updatedAt":"2026-07-06T10:00:00Z","events":[]}'; + +const page = (head: string) => `${head}`; + +/** Stands in for the fetcher Worker: URL → the envelope it would answer. */ +function fakeWorker( + documents: Record, +): typeof fetch { + return (async (input: RequestInfo | URL) => { + const requested = new URL(input.toString()); + const target = requested.searchParams.get("url") ?? ""; + const document = documents[target]; + if (!document) { + return new Response( + JSON.stringify({ ok: false, code: "upstream-error", message: `That URL answered 404.` }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + ok: true, + finalUrl: target, + status: 200, + contentType: document.contentType, + bytes: document.body.length, + redirects: [], + body: document.body, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof fetch; +} + +const deps = (fetchImpl: typeof fetch) => ({ endpoint: ENDPOINT, fetchImpl }); + +describe("resolveUrl", () => { + it("validates a URL that is already the document", async () => { + const resolution = await resolveUrl( + "https://comunidad.example/feed.json", + deps( + fakeWorker({ + "https://comunidad.example/feed.json": { + contentType: "application/ote+json", + body: FEED, + }, + }), + ), + ); + expect(resolution).toMatchObject({ + outcome: "document", + text: FEED, + provenance: { via: "direct", note: { kind: "ote" } }, + }); + }); + + it("discovers the feed of a home page and reports both hops", async () => { + const resolution = await resolveUrl( + "https://comunidad.example/", + deps( + fakeWorker({ + "https://comunidad.example/": { + contentType: "text/html", + body: page(''), + }, + "https://comunidad.example/feed.json": { + contentType: "application/ote+json", + body: FEED, + }, + }), + ), + ); + expect(resolution).toMatchObject({ + outcome: "document", + provenance: { + via: "link", + pageUrl: "https://comunidad.example/", + url: "https://comunidad.example/feed.json", + }, + }); + }); + + it("asks the user to choose when a page declares several feeds", async () => { + const resolution = await resolveUrl( + "https://comunidad.example/", + deps( + fakeWorker({ + "https://comunidad.example/": { + contentType: "text/html", + body: page( + '' + + '', + ), + }, + }), + ), + ); + expect(resolution).toMatchObject({ outcome: "candidates" }); + if (resolution.outcome !== "candidates") return; + expect(resolution.candidates).toHaveLength(2); + }); + + it("says 'not discovered' for a page without a link — never 'invalid'", async () => { + const resolution = await resolveUrl( + "https://comunidad.example/", + deps( + fakeWorker({ + "https://comunidad.example/": { + contentType: "text/html", + body: page("Comunidad"), + }, + }), + ), + ); + expect(resolution.outcome).toBe("not-found"); + }); + + it("passes the fetcher's own refusal through, SSRF codes included", async () => { + const refusing = (async () => + new Response( + JSON.stringify({ ok: false, code: "blocked-address", message: "not publicly routable" }), + { status: 400, headers: { "content-type": "application/json" } }, + )) as unknown as typeof fetch; + const resolution = await resolveUrl("http://169.254.169.254/", deps(refusing)); + expect(resolution).toMatchObject({ outcome: "error", code: "blocked-address" }); + }); + + it("explains that the other two modes still work when the fetcher is down", async () => { + const down = (async () => { + throw new TypeError("Failed to fetch"); + }) as unknown as typeof fetch; + const resolution = await resolveUrl("https://comunidad.example/feed.json", deps(down)); + expect(resolution).toMatchObject({ outcome: "error", code: "fetcher-unreachable" }); + if (resolution.outcome !== "error") return; + expect(resolution.message).toContain("pasting JSON"); + }); + + it("reports a that points at something that is not a feed as a discovery failure", async () => { + const resolution = await resolveUrl( + "https://comunidad.example/", + deps( + fakeWorker({ + "https://comunidad.example/": { + contentType: "text/html", + body: page(''), + }, + "https://comunidad.example/feed.ics": { + contentType: "text/calendar", + body: "BEGIN:VCALENDAR", + }, + }), + ), + ); + expect(resolution).toMatchObject({ outcome: "error", code: "link-not-a-feed" }); + }); +}); + +describe("followCandidate", () => { + it("uses an embedded feed without going back to the network", async () => { + const never = (async () => { + throw new Error("should not fetch"); + }) as unknown as typeof fetch; + const resolution = await followCandidate( + { + url: "https://comunidad.example/#ote-feed-1", + mediaType: "application/ote+json", + title: "", + source: "embedded", + inlineDocument: FEED, + }, + "https://comunidad.example/", + deps(never), + ); + expect(resolution).toMatchObject({ outcome: "document", text: FEED, provenance: { via: "embedded" } }); + }); + + it("fetches the chosen candidate and keeps the page it came from", async () => { + const resolution = await followCandidate( + { + url: "https://comunidad.example/en.json", + mediaType: "application/ote+json", + title: "English", + source: "link", + }, + "https://comunidad.example/", + deps( + fakeWorker({ + "https://comunidad.example/en.json": { + contentType: "application/feed+json", + body: FEED, + }, + }), + ), + ); + expect(resolution).toMatchObject({ + outcome: "document", + provenance: { + via: "link", + pageUrl: "https://comunidad.example/", + url: "https://comunidad.example/en.json", + note: { kind: "ote", mediaType: "application/feed+json" }, + }, + }); + }); +}); diff --git a/apps/validator/tsconfig.json b/apps/validator/tsconfig.json new file mode 100644 index 0000000..b991648 --- /dev/null +++ b/apps/validator/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src", "test"] +} diff --git a/eslint.config.js b/eslint.config.js index 9d39c33..f5657fd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,6 +16,7 @@ export default tseslint.config( process: "readonly", URL: "readonly", AbortController: "readonly", + AbortSignal: "readonly", Buffer: "readonly", clearTimeout: "readonly", fetch: "readonly", @@ -24,8 +25,8 @@ export default tseslint.config( }, }, { - // Served browser script: self-contained classic script, no modules. - files: ["apps/dashboard-checks/dashboard-checks.js"], + // Served browser scripts: self-contained classic scripts, no modules. + files: ["apps/dashboard-checks/dashboard-checks.js", "apps/validator/boot-errors.js"], languageOptions: { globals: { window: "readonly", diff --git a/packages/discover-feed/README.md b/packages/discover-feed/README.md new file mode 100644 index 0000000..db8d6e4 --- /dev/null +++ b/packages/discover-feed/README.md @@ -0,0 +1,71 @@ +# @opentechevents/discover-feed + +Internal, workspace-only package. Answers *"where is the OTE feed of this +URL?"* — the reference implementation of the spec's discovery mechanism +([opentechevents-spec#6](https://github.com/OpenTechEvents/opentechevents-spec/issues/6), +[spec/v0.3 § Discovery](https://github.com/OpenTechEvents/opentechevents-spec/blob/main/spec/v0.3/README.md#discovery-how-a-feed-is-found-from-a-website)). + +```ts +import { discover } from "@opentechevents/discover-feed"; + +const result = discover({ + url: "https://comunidad.example/", // after redirects + contentType: "text/html; charset=utf-8", + body: html, +}); + +switch (result.outcome) { + case "document": // the response IS the feed → validate result.text + case "candidates": // the page declares feeds → let the user pick, then fetch + case "not-found": // an HTML page that declares no feed + case "unsupported":// neither JSON nor HTML +} +``` + +## No network, on purpose + +Every function takes bytes + content-type + base URL and returns a decision. +The fetching lives in `workers/fetch-url`, the only component in this +monorepo with network access. Two reasons: + +- These rules are testable without mocking a single socket, and the tests + read as spec cases rather than as HTTP plumbing. +- The Worker stays what it is — *"given a URL, return the bytes"* — instead of + accumulating spec logic behind an SSRF boundary, where every change is a + security review. + +The crawler and the discovery bot will want the same rules later; that is why +this is its own package and not a file inside `apps/validator`. + +## Decisions worth knowing + +**The media type is not decided yet.** `application/ote+json` vs. reusing +`application/feed+json` is still open in the spec, so both are accepted and +the caller is told which one was actually served (`note.kind`). Generic +`application/json` is parsed too — plenty of static hosts serve a feed that +way — but it is reported as `generic-json`, since the type announces nothing. +Hardcoding one winner would make every consumer stale the day #6 closes. + +**Every declared feed is listed, never just the first.** A page with a +Spanish and an English feed gets both ``s returned; picking silently +would validate a document the user did not mean. + +**"Not found" is its own outcome, not a validation error.** A page with a +typo'd `` must not read as "your JSON is broken". Consumers are +expected to render discovery and validation as two separate verdicts. + +**Non-http(s) hrefs are dropped here**, before any fetcher sees them: a +third-party page must not be able to aim the fetcher at `file:///etc/passwd`. +That is defense in depth — `workers/fetch-url` refuses those schemes too. + +`/.well-known/ote-feed` and `', + ); + expect(discoverFromHtml(html, "https://comunidad.example/")).toEqual([]); + const [candidate] = discoverFromHtml(html, "https://comunidad.example/", { embedded: true }); + expect(candidate).toMatchObject({ + source: "embedded", + inlineDocument: '{"specVersion":"0.3.0"}', + }); + }); +}); + +describe("parseEmbeddedFeeds", () => { + it("ignores scripts of any other type", () => { + const html = page("", ''); + expect(parseEmbeddedFeeds(html)).toEqual([]); + }); +}); + +describe("discover", () => { + const json = '{"specVersion":"0.3.0","events":[]}'; + + it("treats a JSON response as the document", () => { + const result = discover({ + url: "https://comunidad.example/feed.json", + contentType: "application/ote+json", + body: json, + }); + expect(result).toEqual({ + outcome: "document", + text: json, + mediaType: "application/ote+json", + note: { kind: "ote", mediaType: "application/ote+json" }, + }); + }); + + it("reports which media type it found for generic JSON", () => { + const result = discover({ + url: "https://comunidad.example/feed.json", + contentType: "application/json", + body: json, + }); + expect(result).toMatchObject({ + outcome: "document", + note: { kind: "generic-json", mediaType: "application/json" }, + }); + }); + + it("falls back to the bytes when the server sends no content-type", () => { + expect( + discover({ url: "https://comunidad.example/feed.json", contentType: null, body: json }), + ).toMatchObject({ outcome: "document", note: { kind: "missing" } }); + }); + + it("discovers the feed of a community home page", () => { + const result = discover({ + url: "https://comunidad.example/", + contentType: "text/html; charset=utf-8", + body: page(OTE_LINK), + }); + expect(result).toMatchObject({ + outcome: "candidates", + candidates: [{ url: "https://comunidad.example/feed.json" }], + }); + }); + + it("says 'no feed found', not 'invalid', for a page without a link", () => { + const result = discover({ + url: "https://comunidad.example/", + contentType: "text/html", + body: page("Comunidad"), + }); + expect(result.outcome).toBe("not-found"); + }); + + it("offers /.well-known/ote-feed only behind the flag", () => { + const input = { + url: "https://comunidad.example/pagina/", + contentType: "text/html", + body: page(""), + }; + expect(discover(input)).not.toHaveProperty("wellKnownUrl"); + expect(discover({ ...input, options: { wellKnown: true } })).toMatchObject({ + wellKnownUrl: "https://comunidad.example/.well-known/ote-feed", + }); + }); + + it("rejects what is neither JSON nor HTML", () => { + expect( + discover({ + url: "https://comunidad.example/eventos.ics", + contentType: "text/calendar", + body: "BEGIN:VCALENDAR", + }), + ).toMatchObject({ outcome: "unsupported" }); + }); +}); + +describe("wellKnownFeedUrl", () => { + it("is origin-relative, not path-relative", () => { + expect(wellKnownFeedUrl("https://comunidad.example/a/b/c.html")).toBe( + "https://comunidad.example/.well-known/ote-feed", + ); + }); +}); + +describe("detectDocumentKind", () => { + it("reads the document's shape, since v0.3 has no discriminator", () => { + expect(detectDocumentKind({ specVersion: "0.3.0", events: [] })).toBe("feed"); + expect(detectDocumentKind({ name: "Meetup", startDate: "2026-06-11T18:30" })).toBe("event"); + expect(detectDocumentKind({ title: "Feed", updatedAt: "2026-07-06T10:00:00Z" })).toBe("feed"); + expect(detectDocumentKind([])).toBe("unknown"); + expect(detectDocumentKind({ specVersion: "0.3.0" })).toBe("unknown"); + }); +}); diff --git a/packages/discover-feed/tsconfig.build.json b/packages/discover-feed/tsconfig.build.json new file mode 100644 index 0000000..a89af8a --- /dev/null +++ b/packages/discover-feed/tsconfig.build.json @@ -0,0 +1,11 @@ +// The emitting config, used by `pnpm build`: src/ only, so tests never reach +// dist/. Its sibling tsconfig.json covers src + test and only checks types. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/discover-feed/tsconfig.json b/packages/discover-feed/tsconfig.json new file mode 100644 index 0000000..ca97e4e --- /dev/null +++ b/packages/discover-feed/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/packages/feed-urls/README.md b/packages/feed-urls/README.md new file mode 100644 index 0000000..19d114b --- /dev/null +++ b/packages/feed-urls/README.md @@ -0,0 +1,34 @@ +# @opentechevents/feed-urls + +Internal, workspace-only package. Turns a `?repo=owner/name` context into the +URLs a browser tool can actually fetch a fork's published files from. + +```ts +import { forkFileUrls } from "@opentechevents/feed-urls"; + +const candidates = forkFileUrls("owner/name", "feed.json", { + referrer: document.referrer, + origin: location.origin, +}); +// try them in order; the first 2xx wins +``` + +## Why this is not one string + +A fork whose GitHub Pages site is served from a **custom domain** answers +`owner.github.io/name/feed.json` with a `301` to that domain, and the redirect +response carries no `Access-Control-Allow-Origin`. CORS is enforced on every +response in a redirect chain, so the browser blocks the fetch before it ever +reaches the destination — which does send `ACAO: *`. Script cannot read the +redirect's `Location`, so the domain has to arrive from outside the fetch: +either as an explicit URL from the dashboard (`?feed=`), or as the origin of +the dashboard that linked here (`document.referrer`, which browsers trim to +the bare origin cross-origin — hence trying both `//` and +`/`). + +`raw.githubusercontent` on the default branch is a further fallback, not a +rescue: a feed generated by `build-pages.yml` is never committed there. + +All exports are pure functions: no DOM, no fetch. The caller reads +`document.referrer` / `location.origin` and passes them in. Consumers: +`apps/publish`, `apps/preview`, `apps/editor`. diff --git a/packages/feed-urls/package.json b/packages/feed-urls/package.json new file mode 100644 index 0000000..f9654e9 --- /dev/null +++ b/packages/feed-urls/package.json @@ -0,0 +1,28 @@ +{ + "name": "@opentechevents/feed-urls", + "version": "0.1.0", + "description": "Where a fork's published files live: GitHub Pages candidates that survive custom domains", + "license": "MIT", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "sideEffects": false, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/feed-urls/src/index.ts b/packages/feed-urls/src/index.ts new file mode 100644 index 0000000..31a65ef --- /dev/null +++ b/packages/feed-urls/src/index.ts @@ -0,0 +1,85 @@ +/** + * Where a fork's published files live. + * + * Every central tool is opened as `?repo=owner/name` and has to turn that + * into a URL it can `fetch()`. The obvious answer — + * `owner.github.io/name/` — is wrong for any fork whose GitHub Pages + * site is served from a custom domain: Pages answers that URL with a `301` + * to the domain, and **the redirect itself carries no + * `Access-Control-Allow-Origin`**. CORS is enforced on every response in a + * redirect chain, so the browser blocks the fetch before it reaches the + * destination, even though the destination sends `ACAO: *`. Script cannot + * read the redirect's `Location` either, so the domain has to arrive from + * outside the fetch. + * + * Two ways it can, in order of reliability: + * + * 1. The dashboard passes the absolute URL it already knows (`?feed=`). + * 2. The origin of the dashboard that linked here, from `document.referrer`. + * Browsers trim it to the bare origin cross-origin, so the path has to be + * guessed: project sites keep the repo name in it, user/org sites and + * custom domains mapped to one repo do not. + * + * Pure functions, DOM-free: the caller reads `document.referrer` and + * `location.origin` and passes them in. + */ + +/** Where the organizer came from, used to guess a custom Pages domain. */ +export interface PagesOrigin { + /** `document.referrer` — the dashboard that linked here, if it sent one. */ + referrer?: string; + /** `location.origin` of the tool itself, so its own pages never qualify. */ + origin?: string; +} + +/** An http(s) URL, or null for anything else — javascript:, data:, file:, junk. */ +export function httpUrl(value: string | undefined | null): string | null { + if (!value) return null; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.toString(); + } catch { + return null; + } +} + +/** + * The candidate URLs for `file` on the fork's Pages site, most likely first: + * the custom domain the referrer hints at (if any), then the canonical + * `github.io` one. + */ +export function pagesUrls(repo: string, file: string, from: PagesOrigin = {}): string[] { + const [owner, name] = repo.split("/"); + return dedupe([ + ...referrerUrls(name ?? "", file, from), + `https://${owner}.github.io/${name}/${file}`, + ]); +} + +/** + * Every URL worth trying for `file`, in order: the Pages candidates above, + * then `raw.githubusercontent` on the default branch — Pages may not be + * enabled yet (or may still be building) while the file is already + * committed. The raw fallback is not a guarantee: a file generated by the + * publish workflow is never on the default branch at all. + */ +export function forkFileUrls(repo: string, file: string, from: PagesOrigin = {}): string[] { + return dedupe([ + ...pagesUrls(repo, file, from), + `https://raw.githubusercontent.com/${repo}/HEAD/${file}`, + ]); +} + +function referrerUrls(name: string, file: string, from: PagesOrigin): string[] { + const referrer = httpUrl(from.referrer); + if (referrer === null) return []; + const origin = new URL(referrer).origin; + // Navigating inside a tool sets a referrer too; it is never a feed host. + if (origin === from.origin) return []; + return [`${origin}/${name}/${file}`, `${origin}/${file}`]; +} + +function dedupe(urls: string[]): string[] { + return [...new Set(urls)]; +} diff --git a/packages/feed-urls/test/index.test.ts b/packages/feed-urls/test/index.test.ts new file mode 100644 index 0000000..7e15027 --- /dev/null +++ b/packages/feed-urls/test/index.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { forkFileUrls, httpUrl, pagesUrls } from "../src/index.js"; + +describe("httpUrl", () => { + it("keeps http and https and rejects everything else", () => { + expect(httpUrl("https://example.org/feed.json")).toBe("https://example.org/feed.json"); + expect(httpUrl("http://example.org/feed.json")).toBe("http://example.org/feed.json"); + expect(httpUrl("javascript:alert(1)")).toBeNull(); + expect(httpUrl("data:text/json,{}")).toBeNull(); + expect(httpUrl("not a url")).toBeNull(); + expect(httpUrl(undefined)).toBeNull(); + expect(httpUrl("")).toBeNull(); + }); +}); + +describe("pagesUrls", () => { + it("is just the github.io URL without a referrer", () => { + expect(pagesUrls("owner/name", "feed.json")).toEqual([ + "https://owner.github.io/name/feed.json", + ]); + }); + + // The reason this module exists: the github.io URL 301s to the custom + // domain and the redirect carries no CORS header, so it can never succeed. + it("puts the referrer's origin first, as a possible custom domain", () => { + expect( + pagesUrls("ComBuildersES/events", "feed.ics", { + referrer: "https://communitybuilders.dev/events/", + origin: "https://tools.example", + }), + ).toEqual([ + "https://communitybuilders.dev/events/feed.ics", + "https://communitybuilders.dev/feed.ics", + // The owner is used verbatim: hostnames are case-insensitive anyway. + "https://ComBuildersES.github.io/events/feed.ics", + ]); + }); + + it("ignores a referrer from the tool itself", () => { + expect( + pagesUrls("owner/name", "feed.json", { + referrer: "https://tools.example/publish/", + origin: "https://tools.example", + }), + ).toEqual(["https://owner.github.io/name/feed.json"]); + }); + + it("never repeats a candidate when the referrer is the Pages site", () => { + expect( + pagesUrls("owner/name", "feed.json", { + referrer: "https://owner.github.io/name/", + origin: "https://tools.example", + }), + ).toEqual(["https://owner.github.io/name/feed.json", "https://owner.github.io/feed.json"]); + }); + + it("ignores a referrer that is not an http(s) URL", () => { + expect(pagesUrls("owner/name", "feed.json", { referrer: "about:blank" })).toEqual([ + "https://owner.github.io/name/feed.json", + ]); + }); +}); + +describe("forkFileUrls", () => { + it("adds the default branch after the Pages candidates", () => { + expect(forkFileUrls("owner/name", "feed.xml")).toEqual([ + "https://owner.github.io/name/feed.xml", + "https://raw.githubusercontent.com/owner/name/HEAD/feed.xml", + ]); + }); +}); diff --git a/packages/feed-urls/tsconfig.build.json b/packages/feed-urls/tsconfig.build.json new file mode 100644 index 0000000..a89af8a --- /dev/null +++ b/packages/feed-urls/tsconfig.build.json @@ -0,0 +1,11 @@ +// The emitting config, used by `pnpm build`: src/ only, so tests never reach +// dist/. Its sibling tsconfig.json covers src + test and only checks types. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/feed-urls/tsconfig.json b/packages/feed-urls/tsconfig.json new file mode 100644 index 0000000..ca97e4e --- /dev/null +++ b/packages/feed-urls/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 791e5a2..921783d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@opentechevents/embed': specifier: workspace:* version: link:../embed + '@opentechevents/feed-urls': + specifier: workspace:* + version: link:../../packages/feed-urls '@opentechevents/import-ics': specifier: workspace:* version: link:../../packages/import-ics @@ -121,6 +124,9 @@ importers: '@fullcalendar/timegrid': specifier: ^6.1.21 version: 6.1.21(@fullcalendar/core@6.1.21) + '@opentechevents/feed-urls': + specifier: workspace:* + version: link:../../packages/feed-urls '@opentechevents/preview-feed': specifier: workspace:* version: link:../../packages/preview-feed @@ -140,6 +146,9 @@ importers: '@opentechevents/export-jsonld': specifier: workspace:* version: link:../../packages/export-jsonld + '@opentechevents/feed-urls': + specifier: workspace:* + version: link:../../packages/feed-urls '@opentechevents/validate': specifier: workspace:* version: link:../../packages/validate @@ -163,6 +172,28 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@7.3.6(@types/node@22.20.1)) + apps/validator: + dependencies: + '@opentechevents/discover-feed': + specifier: workspace:* + version: link:../../packages/discover-feed + '@opentechevents/validate': + specifier: workspace:* + version: link:../../packages/validate + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@7.3.6(@types/node@22.20.1)) + packages/build-feed: dependencies: '@opentechevents/export-ics': @@ -185,6 +216,18 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@7.3.6(@types/node@22.20.1)) + packages/discover-feed: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@7.3.6(@types/node@22.20.1)) + packages/export-ics: dependencies: '@opentechevents/validate': @@ -242,6 +285,18 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@7.3.6(@types/node@22.20.1)) + packages/feed-urls: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@7.3.6(@types/node@22.20.1)) + packages/import-ics: dependencies: '@opentechevents/validate': @@ -321,6 +376,21 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@7.3.6(@types/node@22.20.1)) + workers/validator: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20250109.0 + version: 4.20260702.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@7.3.6(@types/node@22.20.1)) + wrangler: + specifier: ^4.0.0 + version: 4.126.0(@cloudflare/workers-types@4.20260702.1) + packages: '@asamuzakjp/css-color@6.0.5': @@ -335,6 +405,56 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260825.1': + resolution: {integrity: sha512-oHu38dwaUuzAilyTb0QkQ1YxU/kzqzIQybCvQKAhiK1CGtQS9h0MmjIZYogv3g8cFGGY2k+Wxs0wV9hHK8z78g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260825.1': + resolution: {integrity: sha512-ak5zh8YGEjxQQ78bVo7gzU+tcg2fSFxMIjOPZtWk56a/rIYLbGu6ECcliqnYfMUlragg68H0JuVpfdr3BR5Alw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260825.1': + resolution: {integrity: sha512-bNQvzz6NemWAwixDRz1fQa5T+E5lS4xpB7A/H/72ULxrjVpHmq8CGFPSbdmRp3dvgBjZTgp7wHdGLISLSVd9Gg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260825.1': + resolution: {integrity: sha512-a5E61YsnNCHHQMnmYsbVXInzeYqFqAMwm/wo16dWW4klXDr6T1bm7u1h5G7ZkxVM7+rtc69oe0yVHjDEqzpYVg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260825.1': + resolution: {integrity: sha512-EokzVY2suzRSzeURi2HpHnySR5mo6aF5V1klFIqFOZp2YJyXXTI8AvQgYzhlmGTZY3LNL41jjkUQunOM2OErgQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -371,6 +491,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -616,6 +739,168 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -632,9 +917,21 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@opentechevents/schema@0.3.0': resolution: {integrity: sha512-4lW8hs+tk0ZziUBOHexr0+PIfQm5P7TD11tUJgN1tGI5ldvYpmvRisY8gcZm0lBTVtjACeZ8xQH5OA89gEX09A==} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -773,6 +1070,13 @@ packages: cpu: [x64] os: [win32] + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -939,6 +1243,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + brace-expansion@5.0.7: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} @@ -958,6 +1265,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -985,6 +1296,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devalue@5.9.0: resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} @@ -995,6 +1310,9 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} @@ -1172,6 +1490,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + leaflet@1.9.4: resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} @@ -1204,6 +1526,10 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + miniflare@5.20260825.0-alpha: + resolution: {integrity: sha512-ZwlF6LuX43ilx9EwMRDKHenoGXiNdcKSyGx5aPaJhuozujVZasb2lRR7t3ojJZSnVzwDPsBBYCu8lLnc+/KIgQ==} + engines: {node: '>=22.0.0'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1250,6 +1576,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1293,6 +1622,10 @@ packages: engines: {node: '>=10'} hasBin: true + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1318,6 +1651,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + svelte@5.56.8: resolution: {integrity: sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==} engines: {node: '>=18'} @@ -1361,6 +1698,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1380,10 +1720,17 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + undici@8.10.0: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1502,6 +1849,33 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + workerd@1.20260825.1: + resolution: {integrity: sha512-ccS6TEaaRxgONKawiYGnFYUVGfr2pmN1b7mrNtw0ADVhFaYsEIVoCHnQ4UjhM9EJDzuaNgAWFY82nzVrjWpuOA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.126.0: + resolution: {integrity: sha512-glDq/nxaeQwue0XMIeupfwlu2jVxnFs+wjDFT71DQuURN5LsVdCwu0Af/1ep10U5xr1rKPGhHDEDkKG9nxE/dg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260825.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1513,6 +1887,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} @@ -1537,6 +1917,35 @@ snapshots: dependencies: css-tree: 3.2.1 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260825.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260825.1 + + '@cloudflare/workerd-darwin-64@1.20260825.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260825.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260825.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260825.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260825.1': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.0': {} '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -1561,6 +1970,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -1714,6 +2128,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1733,8 +2253,25 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@opentechevents/schema@0.3.0': {} + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -1810,6 +2347,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@standard-schema/spec@1.1.0': {} '@sveltejs/acorn-typescript@1.0.12(acorn@8.18.0)': @@ -2009,6 +2550,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + blake3-wasm@2.1.5: {} + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -2023,6 +2566,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2049,6 +2594,8 @@ snapshots: deep-is@0.1.4: {} + detect-libc@2.1.2: {} + devalue@5.9.0: {} dompurify@3.4.13: @@ -2057,6 +2604,8 @@ snapshots: entities@8.0.0: {} + error-stack-parser-es@1.0.5: {} + es-module-lexer@2.3.1: {} esbuild@0.28.1: @@ -2269,6 +2818,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + leaflet@1.9.4: {} levn@0.4.1: @@ -2294,6 +2845,18 @@ snapshots: mdn-data@2.27.1: {} + miniflare@5.20260825.0-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260825.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -2335,6 +2898,8 @@ snapshots: path-key@3.1.1: {} + path-to-regexp@6.3.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -2392,6 +2957,38 @@ snapshots: semver@7.8.5: {} + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2408,6 +3005,8 @@ snapshots: std-env@4.2.0: {} + supports-color@10.2.2: {} + svelte@5.56.8(@typescript-eslint/types@8.65.0): dependencies: '@jridgewell/remapping': 2.3.5 @@ -2460,6 +3059,9 @@ snapshots: dependencies: typescript: 6.0.3 + tslib@2.8.1: + optional: true + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2479,8 +3081,14 @@ snapshots: undici-types@6.21.0: {} + undici@7.29.0: {} + undici@8.10.0: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2560,10 +3168,50 @@ snapshots: word-wrap@1.2.5: {} + workerd@1.20260825.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260825.1 + '@cloudflare/workerd-darwin-arm64': 1.20260825.1 + '@cloudflare/workerd-linux-64': 1.20260825.1 + '@cloudflare/workerd-linux-arm64': 1.20260825.1 + '@cloudflare/workerd-windows-64': 1.20260825.1 + + wrangler@4.126.0(@cloudflare/workers-types@4.20260702.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260825.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260825.0-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260825.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} yocto-queue@0.1.0: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + zimmerframe@1.1.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 851e77d..752016d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,19 @@ packages: - packages/* - apps/* + # Deployed to Cloudflare, not to the tools site: apps/* are static bundles + # published to GitHub Pages, and a Worker is neither static nor published + # there. See workers/fetch-url/README.md. + - workers/* allowBuilds: esbuild: true + # wrangler's local runtime (workers/fetch-url). Its install script downloads + # a platform binary that nothing in CI needs: the Worker's tests are plain + # vitest over pure functions and a fake fetch, and `wrangler deploy` bundles + # with esbuild. Left off so a routine `pnpm install` runs one less install + # script. Turn it on locally if you want `pnpm --filter @opentechevents/fetch-url dev` + # (wrangler dev), which does need workerd. + workerd: false # TEMPORARY supply-chain exemption. @opentechevents/schema 0.3.0 was published # on 2026-08-05 (by us, from OpenTechEvents/opentechevents-spec) and packages/ # validate needed it the same day, younger than pnpm's minimum release age. diff --git a/workers/validator/README.md b/workers/validator/README.md new file mode 100644 index 0000000..97b7033 --- /dev/null +++ b/workers/validator/README.md @@ -0,0 +1,134 @@ +# @opentechevents/validator-service + +Cloudflare Worker serving **validator.opentechevents.org**: the validator page +and its fetch endpoint, on one origin. Also **the only component in this +monorepo with network access**. + +The page comes from the `assets` binding (`apps/validator/dist`, built first by +this package's `deploy` script); the runtime serves it directly and only +non-file paths reach the script: + +``` +GET https://validator.opentechevents.org/ → the page +GET https://validator.opentechevents.org/fetch?url=… → the fetcher +GET https://validator.opentechevents.org/health → limits + +GET /fetch?url=https%3A%2F%2Fcomunidad.example%2Ffeed.json + +200 {"ok":true,"finalUrl":"…","status":200,"contentType":"application/ote+json", + "bytes":812,"redirects":[],"body":"{…}"} +400 {"ok":false,"code":"blocked-address","message":"…"} +``` + +## One origin, so CORS stops existing + +Page and endpoint could have been separate deployments. Sharing an origin +removes a whole class of configuration: the page issues a *relative* request, +no cross-origin check happens, and its CSP says `connect-src 'self'` instead +of naming a host that must be kept in sync with an allowlist. `ALLOWED_ORIGINS` +now only covers callers that genuinely are elsewhere — the local dev server and +the legacy Pages path. + +## Why the fetch endpoint exists + +The validator's *upload a file* and *paste JSON* modes run entirely in the +browser against `@opentechevents/validate` — they need no backend and keep +working with this Worker down. The **URL mode** cannot: a browser may not +fetch a third-party document without CORS headers, and community feeds do not +send them. This is the smallest thing that unblocks that mode. + +No database, no accounts, no authentication, no persistence. That is a design +decision, not a simplification: it removes half of the OWASP Top 10 by +construction — no SQL injection without SQL, no broken access control with +nothing to access — and concentrates the remaining risk in one place. + +## That remaining risk is SSRF + +A public endpoint that fetches whatever URL a stranger passes *is*, by +default, a proxy into whatever the server can reach. Every rule below lives in +`src/ssrf.ts` as a pure function with a test: + +- **Scheme allowlist**: `http` and `https` only. `file:`, `gopher:`, `ftp:`, + `data:`, `blob:` refused. +- **Resolve first, judge the resolved IP** — never the hostname. The attacker + owns their DNS zone, so `feed.attacker.example` can be an A record for + `127.0.0.1`. Blocked: `127/8`, `10/8`, `172.16/12`, `192.168/16`, + `169.254/16` (`169.254.169.254` is the cloud metadata endpoint and the + classic target), CGNAT, multicast/reserved, `::1`, `fc00::/7`, `fe80::/10`, + and IPv4-mapped spellings of all of them. One private answer rejects the + whole name. +- **Redirects by hand**, max 3, each hop revalidated. A public URL that + answers `302 → 169.254.169.254` defeats a check that ran once at the start. +- **No credentials**: URLs with embedded userinfo are refused rather than + silently stripped; the outgoing request carries only `accept` and + `user-agent`. No cookies, no authorization, nothing identifying the caller. +- **Non-HTTP ports** an HTTP client can still reach (25, 6379, 3306, …) are + refused. + +**Known residual risk — DNS rebinding.** A Worker has no socket-level API to +pin the connection to the address it just validated, so a resolver answer that +changes between check and connect is not fully excluded. What is left is +bounded by the runtime: Cloudflare's egress is the public internet, not a LAN +with anything on the other side. The rules above stay mandatory regardless — +the runtime reduces the impact of a mistake, not the need to write them. + +## Resource exhaustion + +- **5 MB cap applied while streaming**, never after reading the body: + `Content-Length` is an assertion, not a fact. A 50 MB response is cut at the + cap without being buffered (there is a test for exactly that). +- The cap counts **decompressed** bytes, which is what a decompression bomb + inflates. +- **Timeouts**: 5 s per hop, 10 s for the whole chain. +- **Rate limiting per IP** via the optional `RATE_LIMITER` binding + (`wrangler.jsonc`). The Worker runs without it — production should not. + +## Output is data, never markup + +Responses are `application/json` with `nosniff`, `Content-Security-Policy: +default-src 'none'; sandbox` and `no-store`. The remote document travels as a +JSON *string* inside the envelope, so pointing a browser straight at this +endpoint cannot render or run somebody else's feed. The validator renders it +as text (`textContent`), never as HTML. + +CORS is answered only for the origins in `ALLOWED_ORIGINS`. That is not a +security boundary — `curl` ignores CORS — it just keeps unrelated pages off +this endpoint's fetch budget. + +## Development + +```sh +pnpm --filter @opentechevents/validator-service test # vitest, no network +pnpm --filter @opentechevents/validator-service typecheck +pnpm --filter @opentechevents/validator-service run deploy # builds the page, then wrangler deploy +``` + +`run deploy`, not `deploy`: pnpm has a built-in `deploy` command of its own, +and `pnpm --filter … deploy` hits that instead of this package's script. + +Deployed by `.github/workflows/deploy-validator.yml` on push to main, which +needs a `CLOUDFLARE_API_TOKEN` repository secret. Hostnames: +`validator.opentechevents.org` (canonical), `fetch.opentechevents.org` (the +endpoint addressed as an API) and the `workers.dev` URL as a fallback. Custom +domains are declared in `wrangler.jsonc`, so a deploy re-asserts them; they +require opentechevents.org's zone to live in Cloudflare DNS. + +`handleRequest(request, env, { fetchImpl, resolve })` takes its network as +parameters, which is why the SSRF tests — the ones that matter here — run +against a fake fetch and a fake resolver instead of touching anything real. + +`wrangler dev` additionally needs `workerd`, whose install script is disabled +in `pnpm-workspace.yaml`'s `allowBuilds` (nothing in CI needs it). Flip it to +`true` locally if you want the local runtime. + +## Why the page is not on GitHub Pages + +The other tools are: `apps/*` are static bundles published by +`deploy-tools.yml` under `tools.opentechevents.org//`, and they take a +`?repo=` context from the organizer's fork. The validator does not — it serves +anyone with a JSON document, which is why it earns its own hostname — and it +needs a server for URL mode anyway. Shipping the page with the Worker that +already had to exist costs one deploy and buys the same-origin property above. + +`tools.opentechevents.org/validator/` stays as a redirect so shared links keep +working and permalinks have one canonical form. diff --git a/workers/validator/package.json b/workers/validator/package.json new file mode 100644 index 0000000..6f0e049 --- /dev/null +++ b/workers/validator/package.json @@ -0,0 +1,21 @@ +{ + "name": "@opentechevents/validator-service", + "version": "0.1.0", + "description": "Cloudflare Worker serving the OTE validator: the static page and the SSRF-guarded fetch endpoint, on one origin", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json --noEmit", + "deploy": "pnpm --filter @opentechevents/validator run build && wrangler deploy", + "dev": "wrangler dev", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0", + "typescript": "^6.0.3", + "vitest": "^4.1.10", + "wrangler": "^4.0.0" + } +} diff --git a/workers/validator/src/dns.ts b/workers/validator/src/dns.ts new file mode 100644 index 0000000..923bc1a --- /dev/null +++ b/workers/validator/src/dns.ts @@ -0,0 +1,46 @@ +/** + * DNS resolution over HTTPS. + * + * A Worker has no socket-level resolver API, so the only way to see the + * addresses a hostname points at — before deciding whether to fetch it — is to + * ask a resolver over HTTP. `checkHost` (ssrf.ts) judges the answer. + */ + +const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query"; +const RECORD_TYPES = ["A", "AAAA"] as const; + +interface DohAnswer { + Answer?: Array<{ type: number; data: string }>; +} + +/** RR type numbers for A and AAAA — other answer records (CNAME) are ignored. */ +const ADDRESS_RR_TYPES = new Set([1, 28]); + +/** + * Builds a resolver backed by DNS-over-HTTPS. `fetchImpl` is injected so + * tests never leave the process. + */ +export function dohResolver( + fetchImpl: typeof fetch, + endpoint: string = DOH_ENDPOINT, + timeoutMs = 3000, +): (hostname: string) => Promise { + return async (hostname: string) => { + const lookups = RECORD_TYPES.map(async (type) => { + const url = `${endpoint}?name=${encodeURIComponent(hostname)}&type=${type}`; + const signal = AbortSignal.timeout(timeoutMs); + const response = await fetchImpl(url, { + headers: { accept: "application/dns-json" }, + signal, + }); + if (!response.ok) return []; + const body = (await response.json()) as DohAnswer; + return (body.Answer ?? []) + .filter((answer) => ADDRESS_RR_TYPES.has(answer.type)) + .map((answer) => answer.data.trim()); + }); + + const results = await Promise.all(lookups); + return [...new Set(results.flat())]; + }; +} diff --git a/workers/validator/src/fetch-document.ts b/workers/validator/src/fetch-document.ts new file mode 100644 index 0000000..3c37eaf --- /dev/null +++ b/workers/validator/src/fetch-document.ts @@ -0,0 +1,207 @@ +/** + * The fetch itself: one URL in, bytes out, under every limit the endpoint + * promises. Redirects are followed by hand so each hop goes back through the + * SSRF checks, and the body is capped while it streams — `Content-Length` is + * an assertion by the other side, not a fact. + */ + +import { checkHost, checkUrl, type Rejection, type Resolver } from "./ssrf.js"; + +export interface FetchLimits { + /** Hard ceiling on the decoded body. Anything larger is refused, not truncated. */ + maxBytes: number; + /** Redirect hops followed, each one revalidated. */ + maxRedirects: number; + /** Ceiling for a single hop. */ + hopTimeoutMs: number; + /** Ceiling for the whole chain, redirects included. */ + totalTimeoutMs: number; +} + +export const DEFAULT_LIMITS: FetchLimits = { + maxBytes: 5 * 1024 * 1024, + maxRedirects: 3, + hopTimeoutMs: 5_000, + totalTimeoutMs: 10_000, +}; + +export interface FetchDeps { + fetchImpl: typeof fetch; + resolve: Resolver; + limits?: Partial; +} + +export interface FetchedDocument { + ok: true; + /** The URL the bytes actually came from, after redirects. */ + finalUrl: string; + status: number; + contentType: string | null; + /** Decoded as UTF-8. Never HTML-escaped here: the caller must not render it. */ + body: string; + bytes: number; + /** Hops followed, for the UI to show what it really fetched. */ + redirects: string[]; +} + +/** Codes that describe a failed fetch rather than a refused URL. */ +export type FetchRejectionCode = "upstream-error" | "too-large" | "timeout" | "too-many-redirects"; + +export interface FetchFailure { + ok: false; + /** Status the endpoint answers with; the refusal reason is in `code`. */ + status: number; + code: Rejection["code"] | FetchRejectionCode; + message: string; +} + +export type FetchResult = FetchedDocument | FetchFailure; + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +function failure( + code: Rejection["code"] | FetchRejectionCode, + message: string, + status = 400, +): FetchFailure { + return { ok: false, status, code, message }; +} + +/** + * Reads at most `maxBytes` from the stream, then stops and reports. + * + * Two things this buys, both of which reading `response.text()` would lose: + * a 50 MB answer never lands in memory, and a compressed body that expands + * past the cap is caught — the runtime decompresses transparently, so the + * bytes counted here are the *decompressed* ones, which is exactly the number + * a decompression bomb inflates. + */ +async function readCapped( + body: ReadableStream, + maxBytes: number, +): Promise<{ ok: true; text: string; bytes: number } | { ok: false }> { + const reader = body.getReader(); + const decoder = new TextDecoder("utf-8"); + const chunks: string[] = []; + let bytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + return { ok: false }; + } + chunks.push(decoder.decode(value, { stream: true })); + } + } finally { + reader.releaseLock(); + } + chunks.push(decoder.decode()); + return { ok: true, text: chunks.join(""), bytes }; +} + +/** + * Fetches a URL under the SSRF and resource rules. + * + * Every hop is checked before it is made: scheme, credentials, port, and the + * *resolved addresses* of the hostname. Doing this per hop is the point — a + * perfectly public URL that answers `302 → http://169.254.169.254/` defeats + * any check that only ran on the URL the user typed. + * + * Known residual risk: a Worker cannot pin the connection to the IP it + * validated (there is no socket-level API for it), so a resolver answer that + * changes between the check and the connection — DNS rebinding — is not fully + * excluded. What remains is bounded by the runtime: Cloudflare's egress is + * the public internet, not a LAN with anything to reach on the other side. + * The checks stay mandatory anyway; the runtime limits the blast radius of a + * mistake, it does not replace the rules. + */ +export async function fetchDocument(rawUrl: string, deps: FetchDeps): Promise { + const limits = { ...DEFAULT_LIMITS, ...deps.limits }; + const deadline = Date.now() + limits.totalTimeoutMs; + const redirects: string[] = []; + let current = rawUrl; + + for (let hop = 0; hop <= limits.maxRedirects; hop++) { + const checked = checkUrl(current); + if (!checked.ok) return { ok: false, status: 400, code: checked.code, message: checked.message }; + + const host = await checkHost(checked.url, deps.resolve); + if (!host.ok) return { ok: false, status: 400, code: host.code, message: host.message }; + + const remaining = Math.min(limits.hopTimeoutMs, deadline - Date.now()); + if (remaining <= 0) return failure("timeout", "The upstream server took too long.", 504); + + let response: Response; + try { + response = await deps.fetchImpl(checked.url.toString(), { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(remaining), + // A fixed, honest, anonymous request: no cookies, no authorization, + // nothing identifying whoever asked for this URL. + headers: { + accept: + "application/ote+json, application/feed+json, application/json;q=0.9, text/html;q=0.8, */*;q=0.1", + "user-agent": "OTE-Validator/0.1 (+https://tools.opentechevents.org/validator/)", + }, + }); + } catch (error) { + const timedOut = error instanceof Error && error.name === "TimeoutError"; + return timedOut + ? failure("timeout", "The upstream server took too long.", 504) + : failure("upstream-error", "That URL could not be fetched.", 502); + } + + if (REDIRECT_STATUSES.has(response.status)) { + const location = response.headers.get("location"); + if (!location) return failure("upstream-error", "Redirect without a Location header.", 502); + let next: string; + try { + next = new URL(location, checked.url).toString(); + } catch { + return failure("upstream-error", "Redirect to a location that is not a URL.", 502); + } + redirects.push(next); + current = next; + continue; + } + + if (response.status >= 400) { + return failure( + "upstream-error", + `That URL answered ${response.status}.`, + // Upstream's fault, not the caller's: report it as a bad gateway and + // let the UI show the real status in the message. + 502, + ); + } + + if (!response.body) { + return { ok: true, finalUrl: checked.url.toString(), status: response.status, contentType: response.headers.get("content-type"), body: "", bytes: 0, redirects }; + } + + const read = await readCapped(response.body, limits.maxBytes); + if (!read.ok) { + return failure( + "too-large", + `That document is larger than the ${Math.round(limits.maxBytes / (1024 * 1024))} MB this endpoint fetches.`, + 413, + ); + } + + return { + ok: true, + finalUrl: checked.url.toString(), + status: response.status, + contentType: response.headers.get("content-type"), + body: read.text, + bytes: read.bytes, + redirects, + }; + } + + return failure("too-many-redirects", `More than ${limits.maxRedirects} redirects.`, 400); +} diff --git a/workers/validator/src/index.ts b/workers/validator/src/index.ts new file mode 100644 index 0000000..984dc6f --- /dev/null +++ b/workers/validator/src/index.ts @@ -0,0 +1,177 @@ +/** + * The validator's service: the static page and its fetch endpoint, on one + * origin — and the only component of ote-tools with network access. + * + * The page itself is served by the runtime from the `assets` binding + * (`wrangler.jsonc`); this script only sees what is not a file: `/fetch` and + * `/health`. Sharing an origin is a design decision, not packaging + * convenience: the page needs no CORS at all, and its CSP can say + * `connect-src 'self'`. + * + * The fetch endpoint exists for exactly one reason: a browser cannot fetch a + * third-party feed without CORS headers, and community feeds do not send + * them. So the URL mode of the validator needs *something* server-side. This + * is the smallest something that works: **given a URL, return the bytes**. + * + * No database, no accounts, no authentication, no persistence. That is a + * design decision, not a shortcut — it deletes half of the OWASP Top 10 by + * construction (no SQL injection without SQL, no broken access control with + * nothing to access) and concentrates what is left in one place: SSRF, which + * ssrf.ts is entirely about. + * + * The upload and paste modes of the validator do not come anywhere near this + * Worker; they run fully in the browser and keep working with it down. + */ + +import { dohResolver } from "./dns.js"; +import { DEFAULT_LIMITS, fetchDocument, type FetchResult } from "./fetch-document.js"; + +export interface Env { + /** + * Comma-separated origins allowed to call this endpoint from a browser. + * Not a security boundary (curl ignores CORS) — it keeps other people's + * pages from quietly using our fetch budget. + */ + ALLOWED_ORIGINS?: string; + /** + * Optional Cloudflare rate-limiting binding, keyed by client IP. Absent in + * tests and in `wrangler dev`; the endpoint works without it. + */ + RATE_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> }; +} + +/** + * Only origins that are NOT this Worker belong here: the page it serves is + * same-origin, so it never triggers a CORS check in the first place. + */ +const DEFAULT_ALLOWED_ORIGINS = [ + "https://tools.opentechevents.org", + "https://opentechevents.github.io", + "http://localhost:8000", + "http://127.0.0.1:8000", +]; + +/** + * Response headers that hold whether or not anything went wrong. The body of + * a response from here is a JSON envelope carrying *someone else's document* + * as a string, so it must never be sniffed into HTML and never be allowed to + * load or run anything if a browser is pointed straight at this endpoint. + */ +const SAFETY_HEADERS: Record = { + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'; sandbox", + "referrer-policy": "no-referrer", + "cache-control": "no-store", +}; + +function allowedOrigins(env: Env): string[] { + const configured = env.ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()).filter(Boolean); + return configured && configured.length > 0 ? configured : DEFAULT_ALLOWED_ORIGINS; +} + +function corsHeaders(request: Request, env: Env): Record { + const origin = request.headers.get("origin"); + if (!origin || !allowedOrigins(env).includes(origin)) return {}; + return { + "access-control-allow-origin": origin, + "access-control-allow-methods": "GET, OPTIONS", + "access-control-allow-headers": "content-type", + "access-control-max-age": "86400", + vary: "origin", + }; +} + +function json(body: unknown, status: number, extra: Record): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...SAFETY_HEADERS, ...extra }, + }); +} + +/** Shapes a fetch outcome into the envelope the validator consumes. */ +export function toResponseBody(result: FetchResult): unknown { + if (result.ok) { + return { + ok: true, + finalUrl: result.finalUrl, + status: result.status, + contentType: result.contentType, + bytes: result.bytes, + redirects: result.redirects, + body: result.body, + }; + } + return { ok: false, code: result.code, message: result.message }; +} + +/** + * Handles one request. Dependencies are parameters so the tests — including + * the SSRF ones, which are the tests that matter here — run with a fake + * network and a fake resolver instead of reaching anything real. + */ +export async function handleRequest( + request: Request, + env: Env, + deps: { fetchImpl: typeof fetch; resolve: (hostname: string) => Promise }, +): Promise { + const cors = corsHeaders(request, env); + + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); + if (request.method !== "GET") { + return json({ ok: false, code: "method-not-allowed", message: "Use GET." }, 405, cors); + } + + const url = new URL(request.url); + + if (url.pathname === "/health") { + return json({ ok: true, limits: DEFAULT_LIMITS }, 200, cors); + } + + // "/" is the page, served from the assets binding before this script runs; + // only the API paths reach here. + if (url.pathname !== "/fetch") { + return json({ ok: false, code: "not-found", message: "Unknown endpoint." }, 404, cors); + } + + const target = url.searchParams.get("url"); + if (!target) { + return json({ ok: false, code: "invalid-url", message: "Pass ?url=…" }, 400, cors); + } + + if (env.RATE_LIMITER) { + const key = request.headers.get("cf-connecting-ip") ?? "unknown"; + const { success } = await env.RATE_LIMITER.limit({ key }); + if (!success) { + return json( + { ok: false, code: "rate-limited", message: "Too many requests; try again shortly." }, + 429, + cors, + ); + } + } + + const result = await fetchDocument(target, { fetchImpl: deps.fetchImpl, resolve: deps.resolve }); + return json(toResponseBody(result), result.ok ? 200 : result.status, cors); +} + +/** + * The global `fetch`, wrapped rather than passed by reference. + * + * Handing `fetch` itself to something that later calls it as a plain value + * detaches it from its receiver, and the Workers runtime rejects that with + * "Illegal invocation: function called with incorrect `this` reference" — at + * request time, on every outbound call. Node's fetch tolerates it, so no unit + * test catches this; it only showed up against the deployed Worker. Keep the + * wrapper. + */ +const boundFetch: typeof fetch = (input, init) => fetch(input, init); + +export default { + fetch(request: Request, env: Env): Promise { + return handleRequest(request, env, { + fetchImpl: boundFetch, + resolve: dohResolver(boundFetch), + }); + }, +}; diff --git a/workers/validator/src/ssrf.ts b/workers/validator/src/ssrf.ts new file mode 100644 index 0000000..2feddf8 --- /dev/null +++ b/workers/validator/src/ssrf.ts @@ -0,0 +1,249 @@ +/** + * The SSRF boundary. + * + * This Worker is a public endpoint that fetches a URL a stranger chose. That + * is, by default, a proxy into whatever the server can reach — so the rules + * below are the point of the whole component, not a hardening pass bolted on + * afterwards. Everything here is a pure function over a URL and a set of + * resolved IPs, so each rule is a test case rather than a claim in a comment. + */ + +/** Why a URL was refused. Codes are stable; messages are for humans. */ +export type RejectionCode = + | "invalid-url" + | "blocked-scheme" + | "blocked-credentials" + | "blocked-port" + | "blocked-address" + | "dns-failure"; + +export interface Rejection { + code: RejectionCode; + message: string; +} + +export type UrlCheck = { ok: true; url: URL } | { ok: false } & Rejection; + +/** Only these two. `file:`, `gopher:`, `ftp:`, `data:` and the rest are refused. */ +const ALLOWED_PROTOCOLS = ["http:", "https:"] as const; + +/** + * Ports that are not HTTP but are reachable by an HTTP client — the classic + * SSRF pivot into SMTP, Redis, memcached… A feed lives on 80/443 or on a + * development port; nothing legitimate needs port 25. + */ +const BLOCKED_PORTS = new Set([ + 22, 23, 25, 465, 587, // ssh, telnet, smtp + 110, 143, 993, 995, // pop/imap + 445, 3306, 5432, 6379, 9200, 11211, 27017, // smb, databases, caches +]); + +/** Parses a dotted-quad into its four octets, or null if it is not one. */ +export function parseIpv4(host: string): [number, number, number, number] | null { + const parts = host.split("."); + if (parts.length !== 4) return null; + const octets: number[] = []; + for (const part of parts) { + // Reject "01" and "0x7f": those forms are how a blocklist gets bypassed. + if (!/^\d{1,3}$/.test(part)) return null; + if (part.length > 1 && part.startsWith("0")) return null; + const value = Number(part); + if (value > 255) return null; + octets.push(value); + } + return octets as [number, number, number, number]; +} + +/** + * Blocks everything that is not a public unicast IPv4 address: + * loopback (127/8), private (10/8, 172.16/12, 192.168/16), link-local + * (169.254/16 — `169.254.169.254` is the cloud metadata endpoint and the + * canonical SSRF target), CGNAT, benchmarking, multicast, reserved, 0/8 and + * the broadcast address. + */ +export function isBlockedIpv4(host: string): boolean { + const octets = parseIpv4(host); + if (!octets) return false; + const [a, b] = octets; + if (a === 0) return true; + if (a === 10) return true; + if (a === 127) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 0) return true; // 192.0.0/24 + 192.0.2/24 (TEST-NET-1) + if (a === 192 && b === 168) return true; + if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking + if (a === 198 && b === 51) return true; // TEST-NET-2 + if (a === 203 && b === 0) return true; // TEST-NET-3 + if (a >= 224) return true; // multicast, reserved, 255.255.255.255 + return false; +} + +/** Expands an IPv6 address to its eight 16-bit groups, or null if malformed. */ +export function parseIpv6(host: string): number[] | null { + let text = host.trim().replace(/^\[|\]$/g, ""); + const zone = text.indexOf("%"); + if (zone !== -1) text = text.slice(0, zone); + if (!/^[0-9a-fA-F:.]+$/.test(text)) return null; + + // An IPv4-mapped tail (::ffff:127.0.0.1) is rewritten into two groups so + // the mapped address is checked as the IPv4 address it really is. + const lastColon = text.lastIndexOf(":"); + const tail = text.slice(lastColon + 1); + if (tail.includes(".")) { + const octets = parseIpv4(tail); + if (!octets) return null; + const hi = ((octets[0] << 8) | octets[1]).toString(16); + const lo = ((octets[2] << 8) | octets[3]).toString(16); + text = `${text.slice(0, lastColon + 1)}${hi}:${lo}`; + } + + const halves = text.split("::"); + if (halves.length > 2) return null; + const parse = (part: string): number[] | null => { + if (part === "") return []; + const groups: number[] = []; + for (const group of part.split(":")) { + if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null; + groups.push(parseInt(group, 16)); + } + return groups; + }; + const head = parse(halves[0]); + const rest = halves.length === 2 ? parse(halves[1]) : []; + if (!head || !rest) return null; + if (halves.length === 2) { + const fill = 8 - head.length - rest.length; + if (fill < 0) return null; + return [...head, ...Array(fill).fill(0), ...rest]; + } + return head.length === 8 ? head : null; +} + +/** + * Blocks loopback (`::1`), unspecified (`::`), unique-local (`fc00::/7`), + * link-local (`fe80::/10`) and IPv4-mapped forms of anything IPv4 blocks — + * `::ffff:169.254.169.254` reaches the same metadata service. + */ +export function isBlockedIpv6(host: string): boolean { + const groups = parseIpv6(host); + if (!groups) return false; + const isZeroPrefix = groups.slice(0, 5).every((g) => g === 0); + if (isZeroPrefix && groups[5] === 0xffff) { + const mapped = [ + groups[6] >> 8, + groups[6] & 0xff, + groups[7] >> 8, + groups[7] & 0xff, + ].join("."); + return isBlockedIpv4(mapped); + } + if (groups.every((g) => g === 0)) return true; // :: + if (isZeroPrefix && groups[5] === 0 && groups[6] === 0 && groups[7] === 1) return true; // ::1 + const first = groups[0]; + if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 + if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 + if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast + return false; +} + +/** True for any address this Worker must never connect to. */ +export function isBlockedIp(address: string): boolean { + return isBlockedIpv4(address) || isBlockedIpv6(address); +} + +/** True when the host is a literal IP rather than a name to resolve. */ +export function isIpLiteral(host: string): boolean { + return parseIpv4(host) !== null || parseIpv6(host) !== null; +} + +/** + * Hostnames that resolve inside the network by convention rather than by DNS + * record, and which therefore never appear in the resolver's answer. + */ +const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]); + +/** Syntactic checks: scheme, credentials, port, obvious internal names. */ +export function checkUrl(raw: string): UrlCheck { + let url: URL; + try { + url = new URL(raw); + } catch { + return { ok: false, code: "invalid-url", message: "Not a URL." }; + } + + if (!(ALLOWED_PROTOCOLS as readonly string[]).includes(url.protocol)) { + return { + ok: false, + code: "blocked-scheme", + message: `Only http and https are fetched; "${url.protocol}" is not.`, + }; + } + + // Credentials in the URL would be sent to the target as this fetch's + // identity. Every fetch here is anonymous, so a URL that carries them is + // refused rather than silently stripped. + if (url.username || url.password) { + return { + ok: false, + code: "blocked-credentials", + message: "URLs with embedded credentials are not fetched.", + }; + } + + const host = url.hostname.toLowerCase().replace(/\.$/, ""); + if (BLOCKED_HOSTNAMES.has(host) || host.endsWith(".localhost") || host.endsWith(".internal")) { + return { ok: false, code: "blocked-address", message: "That host is not publicly routable." }; + } + + if (url.port && BLOCKED_PORTS.has(Number(url.port))) { + return { ok: false, code: "blocked-port", message: `Port ${url.port} is not fetched.` }; + } + + if (isIpLiteral(host) && isBlockedIp(host)) { + return { ok: false, code: "blocked-address", message: "That address is not publicly routable." }; + } + + return { ok: true, url }; +} + +/** Resolves a hostname to IP addresses. Injected so tests need no network. */ +export type Resolver = (hostname: string) => Promise; + +export type HostCheck = { ok: true; addresses: string[] } | { ok: false } & Rejection; + +/** + * The check that actually matters: **resolve first, then judge the resolved + * addresses** — never the hostname. The attacker owns their DNS zone, so + * `feed.attacker.example` can be an A record for `127.0.0.1`, and a + * name-based allowlist would wave it through. Every returned address must be + * public; one private answer rejects the whole name. + */ +export async function checkHost(url: URL, resolve: Resolver): Promise { + const host = url.hostname.toLowerCase().replace(/\.$/, ""); + if (isIpLiteral(host)) { + return isBlockedIp(host) + ? { ok: false, code: "blocked-address", message: "That address is not publicly routable." } + : { ok: true, addresses: [host] }; + } + + let addresses: string[]; + try { + addresses = await resolve(host); + } catch { + return { ok: false, code: "dns-failure", message: "That hostname could not be resolved." }; + } + + if (addresses.length === 0) { + return { ok: false, code: "dns-failure", message: "That hostname resolves to no address." }; + } + if (addresses.some((address) => isBlockedIp(address))) { + return { + ok: false, + code: "blocked-address", + message: "That hostname resolves to an address that is not publicly routable.", + }; + } + return { ok: true, addresses }; +} diff --git a/workers/validator/test/fetch-document.test.ts b/workers/validator/test/fetch-document.test.ts new file mode 100644 index 0000000..024776e --- /dev/null +++ b/workers/validator/test/fetch-document.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; + +import { fetchDocument } from "../src/fetch-document.js"; + +const PUBLIC_IP = "93.184.216.34"; + +/** Resolver that answers "public" for everything except the names given. */ +function resolver(map: Record = {}) { + return async (hostname: string) => map[hostname] ?? [PUBLIC_IP]; +} + +/** A fetch stub driven by a URL → Response table. */ +function fakeFetch(routes: Record Response>): typeof fetch { + return (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + const route = routes[url]; + if (!route) throw new Error(`unexpected fetch: ${url}`); + return route(); + }) as unknown as typeof fetch; +} + +const json = (body: string) => + new Response(body, { status: 200, headers: { "content-type": "application/ote+json" } }); + +const redirect = (to: string, status = 302) => + new Response(null, { status, headers: { location: to } }); + +/** A body of `chunks` × 1 MB, produced lazily so the test never holds it all. */ +function hugeStream(chunks: number): Response { + const megabyte = new Uint8Array(1024 * 1024).fill(0x61); + let sent = 0; + const stream = new ReadableStream({ + pull(controller) { + if (sent++ >= chunks) return controller.close(); + controller.enqueue(megabyte); + }, + }); + return new Response(stream, { status: 200, headers: { "content-type": "application/json" } }); +} + +describe("fetchDocument", () => { + it("returns the bytes, the final URL and the content-type", async () => { + const result = await fetchDocument("https://comunidad.example/feed.json", { + fetchImpl: fakeFetch({ "https://comunidad.example/feed.json": () => json('{"a":1}') }), + resolve: resolver(), + }); + expect(result).toMatchObject({ + ok: true, + finalUrl: "https://comunidad.example/feed.json", + contentType: "application/ote+json", + body: '{"a":1}', + redirects: [], + }); + }); + + it("sends no cookies, no authorization and no caller identity", async () => { + const spy = vi.fn(async (_url: string, _init?: RequestInit) => json("{}")); + await fetchDocument("https://comunidad.example/feed.json", { + fetchImpl: spy as unknown as typeof fetch, + resolve: resolver(), + }); + const init = spy.mock.calls[0][1]!; + const headers = init.headers as Record; + expect(Object.keys(headers).sort()).toEqual(["accept", "user-agent"]); + expect(init.redirect).toBe("manual"); + }); + + it("revalidates every redirect hop: public URL → private IP is refused", async () => { + const result = await fetchDocument("https://comunidad.example/feed", { + fetchImpl: fakeFetch({ + "https://comunidad.example/feed": () => redirect("http://169.254.169.254/latest/meta-data/"), + }), + resolve: resolver(), + }); + expect(result).toMatchObject({ ok: false, code: "blocked-address" }); + }); + + it("also refuses a redirect to a hostname that resolves privately", async () => { + const result = await fetchDocument("https://comunidad.example/feed", { + fetchImpl: fakeFetch({ + "https://comunidad.example/feed": () => redirect("https://inside.attacker.example/"), + }), + resolve: resolver({ "inside.attacker.example": ["127.0.0.1"] }), + }); + expect(result).toMatchObject({ ok: false, code: "blocked-address" }); + }); + + it("follows a bounded number of redirects and reports the chain", async () => { + const result = await fetchDocument("https://comunidad.example/a", { + fetchImpl: fakeFetch({ + "https://comunidad.example/a": () => redirect("/b"), + "https://comunidad.example/b": () => json('{"ok":true}'), + }), + resolve: resolver(), + }); + expect(result).toMatchObject({ + ok: true, + finalUrl: "https://comunidad.example/b", + redirects: ["https://comunidad.example/b"], + }); + }); + + it("gives up past the redirect limit instead of looping", async () => { + const result = await fetchDocument("https://comunidad.example/loop", { + fetchImpl: fakeFetch({ "https://comunidad.example/loop": () => redirect("/loop") }), + resolve: resolver(), + limits: { maxRedirects: 2 }, + }); + expect(result).toMatchObject({ ok: false, code: "too-many-redirects" }); + }); + + it("cuts a 50 MB response at the cap without buffering it", async () => { + const result = await fetchDocument("https://comunidad.example/huge.json", { + fetchImpl: fakeFetch({ "https://comunidad.example/huge.json": () => hugeStream(50) }), + resolve: resolver(), + }); + expect(result).toMatchObject({ ok: false, code: "too-large", status: 413 }); + }); + + it("applies the cap to decoded bytes, so a small declared size cannot lie", async () => { + // Content-Length says 10; the stream delivers 2 MB. The cap is enforced + // on what actually arrives. + const response = hugeStream(2); + Object.defineProperty(response.headers, "get", { + value: (name: string) => (name === "content-length" ? "10" : null), + }); + const result = await fetchDocument("https://comunidad.example/lies.json", { + fetchImpl: fakeFetch({ "https://comunidad.example/lies.json": () => response }), + resolve: resolver(), + limits: { maxBytes: 1024 }, + }); + expect(result).toMatchObject({ ok: false, code: "too-large" }); + }); + + it("reports upstream errors as upstream, not as a bad request", async () => { + const result = await fetchDocument("https://comunidad.example/missing.json", { + fetchImpl: fakeFetch({ + "https://comunidad.example/missing.json": () => new Response("nope", { status: 404 }), + }), + resolve: resolver(), + }); + expect(result).toMatchObject({ ok: false, code: "upstream-error", status: 502 }); + expect((result as { message: string }).message).toContain("404"); + }); + + it("refuses file: before any network call happens", async () => { + const spy = vi.fn(); + const result = await fetchDocument("file:///etc/passwd", { + fetchImpl: spy as unknown as typeof fetch, + resolve: resolver(), + }); + expect(result).toMatchObject({ ok: false, code: "blocked-scheme" }); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/workers/validator/test/handler.test.ts b/workers/validator/test/handler.test.ts new file mode 100644 index 0000000..82cee0e --- /dev/null +++ b/workers/validator/test/handler.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; + +import { handleRequest, type Env } from "../src/index.js"; + +const ORIGIN = "https://tools.opentechevents.org"; + +const okFetch = (async () => + new Response('{"specVersion":"0.3.0","events":[]}', { + status: 200, + headers: { "content-type": "application/ote+json" }, + })) as unknown as typeof fetch; + +function call( + target: string, + overrides: { + env?: Env; + fetchImpl?: typeof fetch; + resolve?: (hostname: string) => Promise; + origin?: string | null; + } = {}, +) { + const url = `https://validator.example/fetch?url=${encodeURIComponent(target)}`; + const origin = overrides.origin === undefined ? ORIGIN : overrides.origin; + const request = new Request(url, { headers: origin ? { origin } : {} }); + return handleRequest(request, overrides.env ?? {}, { + fetchImpl: overrides.fetchImpl ?? okFetch, + resolve: overrides.resolve ?? (async () => ["93.184.216.34"]), + }); +} + +describe("the four SSRF cases this endpoint exists to refuse", () => { + it("file:// is rejected", async () => { + const response = await call("file:///etc/passwd"); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ ok: false, code: "blocked-scheme" }); + }); + + it("the cloud metadata address is rejected", async () => { + const response = await call("http://169.254.169.254/latest/meta-data/"); + await expect(response.json()).resolves.toMatchObject({ ok: false, code: "blocked-address" }); + }); + + it("a hostname resolving to 127.0.0.1 is rejected", async () => { + const response = await call("https://feed.attacker.example/", { + resolve: async () => ["127.0.0.1"], + }); + await expect(response.json()).resolves.toMatchObject({ ok: false, code: "blocked-address" }); + }); + + it("a public URL redirecting to a private IP is rejected", async () => { + const fetchImpl = (async (input: RequestInfo | URL) => + input.toString() === "https://comunidad.example/feed" + ? new Response(null, { status: 302, headers: { location: "http://10.0.0.5/admin" } }) + : new Response("secret", { status: 200 })) as unknown as typeof fetch; + const response = await call("https://comunidad.example/feed", { fetchImpl }); + await expect(response.json()).resolves.toMatchObject({ ok: false, code: "blocked-address" }); + }); +}); + +describe("handleRequest", () => { + it("returns the document in a JSON envelope", async () => { + const response = await call("https://comunidad.example/feed.json"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + finalUrl: "https://comunidad.example/feed.json", + contentType: "application/ote+json", + body: '{"specVersion":"0.3.0","events":[]}', + }); + }); + + it("never lets a remote body be sniffed or rendered as HTML", async () => { + const evil = (async () => + new Response("", { + status: 200, + headers: { "content-type": "text/html" }, + })) as unknown as typeof fetch; + const response = await call("https://comunidad.example/", { fetchImpl: evil }); + expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("content-security-policy")).toContain("default-src 'none'"); + // The markup survives as data inside the envelope; it is never the response itself. + await expect(response.json()).resolves.toMatchObject({ + body: "", + }); + }); + + it("answers CORS only for allowed origins", async () => { + const allowed = await call("https://comunidad.example/feed.json"); + expect(allowed.headers.get("access-control-allow-origin")).toBe(ORIGIN); + + const stranger = await call("https://comunidad.example/feed.json", { + origin: "https://somebody-else.example", + }); + expect(stranger.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("honours a configured origin allowlist", async () => { + const response = await call("https://comunidad.example/feed.json", { + env: { ALLOWED_ORIGINS: "https://staging.example" }, + origin: "https://staging.example", + }); + expect(response.headers.get("access-control-allow-origin")).toBe("https://staging.example"); + }); + + it("rate-limits per IP when the binding is present", async () => { + const limit = vi.fn(async () => ({ success: false })); + const request = new Request("https://validator.example/fetch?url=https%3A%2F%2Fa.example%2F", { + headers: { origin: ORIGIN, "cf-connecting-ip": "203.0.113.9" }, + }); + const response = await handleRequest(request, { RATE_LIMITER: { limit } }, { + fetchImpl: okFetch, + resolve: async () => ["93.184.216.34"], + }); + expect(response.status).toBe(429); + expect(limit).toHaveBeenCalledWith({ key: "203.0.113.9" }); + }); + + it("requires ?url and only answers GET", async () => { + const missing = await handleRequest(new Request("https://validator.example/fetch"), {}, { + fetchImpl: okFetch, + resolve: async () => ["93.184.216.34"], + }); + expect(missing.status).toBe(400); + + const posted = await handleRequest( + new Request("https://validator.example/fetch?url=https://a.example/", { method: "POST" }), + {}, + { fetchImpl: okFetch, resolve: async () => ["93.184.216.34"] }, + ); + expect(posted.status).toBe(405); + }); + + it("leaves / to the assets binding rather than treating it as the API", async () => { + const response = await handleRequest(new Request("https://validator.example/"), {}, { + fetchImpl: okFetch, + resolve: async () => ["93.184.216.34"], + }); + // In production this path never reaches the script: the runtime serves + // index.html from `assets` first. If it does reach here, it is not an API + // call and must not be answered as one. + expect(response.status).toBe(404); + }); + + it("has a health endpoint that publishes its limits", async () => { + const response = await handleRequest(new Request("https://validator.example/health"), {}, { + fetchImpl: okFetch, + resolve: async () => ["93.184.216.34"], + }); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + limits: { maxBytes: 5 * 1024 * 1024, maxRedirects: 3 }, + }); + }); +}); diff --git a/workers/validator/test/ssrf.test.ts b/workers/validator/test/ssrf.test.ts new file mode 100644 index 0000000..b13df58 --- /dev/null +++ b/workers/validator/test/ssrf.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { checkHost, checkUrl, isBlockedIp, isIpLiteral, parseIpv6 } from "../src/ssrf.js"; + +const publicResolver = async () => ["93.184.216.34"]; + +describe("checkUrl", () => { + it("allows plain http(s)", () => { + expect(checkUrl("https://comunidad.example/feed.json").ok).toBe(true); + expect(checkUrl("http://comunidad.example/feed.json").ok).toBe(true); + }); + + it("refuses every scheme but http(s)", () => { + for (const url of [ + "file:///etc/passwd", + "gopher://comunidad.example/", + "ftp://comunidad.example/feed.json", + "data:application/json,{}", + "blob:https://comunidad.example/x", + ]) { + expect(checkUrl(url)).toMatchObject({ ok: false, code: "blocked-scheme" }); + } + }); + + it("refuses URLs carrying credentials instead of stripping them", () => { + expect(checkUrl("https://user:pass@comunidad.example/feed.json")).toMatchObject({ + ok: false, + code: "blocked-credentials", + }); + }); + + it("refuses non-HTTP ports that an HTTP client can still reach", () => { + expect(checkUrl("http://comunidad.example:25/")).toMatchObject({ ok: false, code: "blocked-port" }); + expect(checkUrl("http://comunidad.example:6379/")).toMatchObject({ ok: false, code: "blocked-port" }); + expect(checkUrl("http://comunidad.example:8080/").ok).toBe(true); + }); + + it("refuses literal private and metadata addresses", () => { + for (const url of [ + "http://127.0.0.1/", + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.5/", + "http://192.168.1.1/", + "http://172.16.9.9/", + "http://[::1]/", + "http://[fe80::1]/", + "http://[fc00::1]/", + "http://[::ffff:127.0.0.1]/", + ]) { + expect(checkUrl(url)).toMatchObject({ ok: false, code: "blocked-address" }); + } + }); + + it("refuses hostnames that resolve internally by convention", () => { + expect(checkUrl("http://localhost:3000/")).toMatchObject({ ok: false, code: "blocked-address" }); + expect(checkUrl("http://metadata.google.internal/")).toMatchObject({ + ok: false, + code: "blocked-address", + }); + }); + + it("is not fooled by octal, decimal or zero-padded spellings of 127.0.0.1", () => { + // The WHATWG URL parser normalizes all of these to the dotted quad before + // this code sees them, which is why the blocklist can work on that one + // canonical form — and why parseIpv4 refuses the weird spellings outright + // instead of trying to reimplement the normalization. + for (const url of ["http://0177.0.0.1/", "http://2130706433/", "http://127.000.000.001/"]) { + expect(checkUrl(url)).toMatchObject({ ok: false, code: "blocked-address" }); + } + expect(isIpLiteral("0177.0.0.1")).toBe(false); + expect(isBlockedIp("127.000.000.001")).toBe(false); + }); + + it("rejects what is not a URL at all", () => { + expect(checkUrl("not a url")).toMatchObject({ ok: false, code: "invalid-url" }); + }); +}); + +describe("parseIpv6", () => { + it("expands compressed forms and IPv4-mapped tails", () => { + expect(parseIpv6("::1")).toEqual([0, 0, 0, 0, 0, 0, 0, 1]); + expect(parseIpv6("::ffff:169.254.169.254")).toEqual([0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe]); + expect(parseIpv6("2001:db8::1")).toEqual([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1]); + expect(parseIpv6("nope")).toBeNull(); + }); +}); + +describe("checkHost", () => { + it("judges the resolved address, not the hostname", async () => { + // The attacker owns their own DNS zone: a perfectly ordinary name with an + // A record for 127.0.0.1 is the whole trick. + const result = await checkHost(new URL("https://feed.attacker.example/"), async () => [ + "127.0.0.1", + ]); + expect(result).toMatchObject({ ok: false, code: "blocked-address" }); + }); + + it("rejects a name where any answer is private, not just the first", async () => { + const result = await checkHost(new URL("https://mixed.example/"), async () => [ + "93.184.216.34", + "10.1.2.3", + ]); + expect(result).toMatchObject({ ok: false, code: "blocked-address" }); + }); + + it("passes a public name through", async () => { + expect(await checkHost(new URL("https://comunidad.example/"), publicResolver)).toEqual({ + ok: true, + addresses: ["93.184.216.34"], + }); + }); + + it("reports resolution failures as such", async () => { + const thrown = await checkHost(new URL("https://nope.example/"), async () => { + throw new Error("SERVFAIL"); + }); + expect(thrown).toMatchObject({ ok: false, code: "dns-failure" }); + const empty = await checkHost(new URL("https://nope.example/"), async () => []); + expect(empty).toMatchObject({ ok: false, code: "dns-failure" }); + }); +}); diff --git a/workers/validator/tsconfig.json b/workers/validator/tsconfig.json new file mode 100644 index 0000000..604ee5a --- /dev/null +++ b/workers/validator/tsconfig.json @@ -0,0 +1,18 @@ +// Workers runtime types instead of Node's: this package is the one thing here +// that is not a Node program. @cloudflare/workers-types and @types/node both +// declare fetch/Request/Response, so `types` names exactly one of them — see +// the note in tsconfig.base.json about TypeScript 6 no longer auto-including +// every @types package. +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "noEmit": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src", "test"] +} diff --git a/workers/validator/wrangler.jsonc b/workers/validator/wrangler.jsonc new file mode 100644 index 0000000..c57e95d --- /dev/null +++ b/workers/validator/wrangler.jsonc @@ -0,0 +1,55 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "ote-validator", + "main": "src/index.ts", + "compatibility_date": "2026-08-01", + "observability": { "enabled": true }, + + // The page and its fetch endpoint on ONE origin. Static assets are served + // directly by the runtime; anything that is not an asset (i.e. /fetch and + // /health) falls through to the Worker. That is not a packaging + // convenience: same-origin means the page needs no CORS at all and its CSP + // can say `connect-src 'self'`, so the whole allowlist stops being a thing + // that can be misconfigured. + // + // The directory is built by `pnpm --filter @opentechevents/validator build`, + // which this package's `deploy` script runs first. + "assets": { + "directory": "../../apps/validator/dist", + "not_found_handling": "404-page" + }, + + // One hostname, deliberately. A second custom domain (fetch.opentechevents.org + // was tried) does not give the fetcher an "API name": assets are served + // before this script runs, so that hostname served the whole page too — two + // canonical URLs for one tool, which is what the /validator/ redirect exists + // to avoid. If an API name is ever genuinely wanted, it needs its own Worker + // without an assets binding, not another route onto this one. + "routes": [{ "pattern": "validator.opentechevents.org", "custom_domain": true }], + // Declaring `routes` makes wrangler disable the workers.dev URL by default. + // Keep it: it is what local development and CI smoke checks point at before + // DNS exists, and the fallback if a custom domain ever misbehaves. + "workers_dev": true, + + "vars": { + // Only origins that are NOT this Worker need to appear here — the page it + // serves is same-origin and never triggers CORS. These cover the local + // dev server (esbuild on :8000) and the legacy Pages path while its + // redirect propagates. + "ALLOWED_ORIGINS": "https://tools.opentechevents.org,http://localhost:8000,http://127.0.0.1:8000" + }, + + // Per-IP ceiling. The endpoint works without this binding (it is optional in + // Env), but production should never run without it: it is a public endpoint + // that makes outbound requests, so an unbounded caller is an amplifier. + "unsafe": { + "bindings": [ + { + "name": "RATE_LIMITER", + "type": "ratelimit", + "namespace_id": "1001", + "simple": { "limit": 30, "period": 60 } + } + ] + } +}