From ea15c25aae9fb56020fc7c2f2d129fcb285166e9 Mon Sep 17 00:00:00 2001 From: Raul Jimenez Ortega Date: Thu, 27 Aug 2026 18:43:21 +0200 Subject: [PATCH 1/5] refactor(feed-urls): extract fork URL candidates into a package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom-domain fix from 7e307e4 lived inside apps/publish, but editor and preview guess the same URLs from the same `?repo=` context and had their own narrower copies — one that still broke on custom domains. @opentechevents/feed-urls now owns the rule: a fork on a custom domain answers `owner.github.io/name/` with a 301 that carries no CORS header, so the browser blocks the fetch before reaching the destination that would have allowed it. Script cannot read the redirect's Location, so the domain has to arrive from outside the fetch — an explicit `?feed=` or the origin of the dashboard that linked here. Hence a list of candidates to try in order, not one string. Consumers change shape accordingly: editor's `pagesFeedUrl: string` becomes `pagesFeedUrls: string[]` behind a `fetchFirstJson` helper, and publish drops its local copy of the logic. --- apps/editor/package.json | 1 + apps/editor/src/lib/repo.ts | 30 +++++++-- apps/editor/src/main.ts | 18 +++++- apps/editor/test/repo.test.ts | 16 ++++- apps/preview/package.json | 1 + apps/preview/src/main.ts | 29 ++++----- apps/publish/package.json | 1 + apps/publish/src/lib/feed-source.ts | 67 +++----------------- packages/feed-urls/README.md | 34 +++++++++++ packages/feed-urls/package.json | 28 +++++++++ packages/feed-urls/src/index.ts | 85 ++++++++++++++++++++++++++ packages/feed-urls/test/index.test.ts | 72 ++++++++++++++++++++++ packages/feed-urls/tsconfig.build.json | 11 ++++ packages/feed-urls/tsconfig.json | 7 +++ pnpm-lock.yaml | 21 +++++++ 15 files changed, 341 insertions(+), 80 deletions(-) create mode 100644 packages/feed-urls/README.md create mode 100644 packages/feed-urls/package.json create mode 100644 packages/feed-urls/src/index.ts create mode 100644 packages/feed-urls/test/index.test.ts create mode 100644 packages/feed-urls/tsconfig.build.json create mode 100644 packages/feed-urls/tsconfig.json 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/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..2502172 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 @@ -242,6 +251,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': From 3c43f17acf909a3edaa90948096434366881d3fb Mon Sep 17 00:00:00 2001 From: Raul Jimenez Ortega Date: Thu, 27 Aug 2026 18:44:02 +0200 Subject: [PATCH 2/5] feat(validator): add the OTE validator, its discovery package and fetcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "is this document a valid OTE feed or event?" with nothing to install, in three modes — a URL, an uploaded file, pasted JSON — and gives the format an objective referee to link to from an issue instead of an argument. Implements issue #60. Three components, split by what each is allowed to do: apps/validator — the page, static, served at /validator/. Upload and paste run entirely in the tab against @opentechevents/validate (reused verbatim: a validator with a second opinion about validity would defeat the purpose), so they keep working with the fetcher down. Discovery and validation are rendered as two separate verdicts, because telling an organizer whose has a typo that their JSON is broken sends them to fix the wrong thing. MUST (schema) and SHOULD (recommended profile) stay in separate sections for the same reason. Findings carry a JSON Pointer plus line:column and highlight that line in the source view. packages/discover-feed — pure functions, no network: bytes + content-type + base URL in, candidate feed URLs out. The reference implementation of spec discovery, kept out of the app because the crawler and the bot will want it, and out of the Worker so spec rules never live behind an SSRF boundary. Media-type matching is lax on purpose while opentechevents-spec#6 is open: application/ote+json and application/feed+json are both accepted and the caller is told which one was served. workers/fetch-url — a Cloudflare Worker, the only component with network access, because a browser cannot fetch a third-party feed without CORS. No database, accounts, auth or persistence: that removes half of the OWASP Top 10 by construction and concentrates the rest in SSRF, which ssrf.ts is entirely about — scheme allowlist, resolve-first then judge the resolved IP (the attacker owns their DNS zone), per-hop revalidated redirects, no credentials, a 5 MB cap applied while streaming, timeouts and per-IP rate limiting. Nothing remote is ever rendered as HTML: every fetched string reaches the DOM through textContent, and the Worker answers JSON with nosniff and a locked-down CSP of its own. Two traps here are invisible to unit tests and cost live debugging, so both are commented at the call site and in the app README: passing the global fetch by reference throws "Illegal invocation" in a browser and in the Workers runtime while Node tolerates it, and ajv compiling schemas with new Function needs 'unsafe-eval' in the page CSP — without it the module throws at import and the page silently registers no listeners at all. boot-errors.js makes that failure state visible. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy-tools.yml | 17 +- README.md | 24 +- apps/validator/README.md | 110 +++ apps/validator/boot-errors.js | 32 + apps/validator/build.mjs | 57 ++ apps/validator/index.html | 132 ++++ apps/validator/package.json | 24 + apps/validator/src/lib/locate.ts | 234 +++++++ apps/validator/src/lib/report.ts | 189 ++++++ apps/validator/src/lib/resolve.ts | 189 ++++++ apps/validator/src/main.ts | 467 +++++++++++++ apps/validator/styles.css | 369 +++++++++++ apps/validator/test/locate.test.ts | 86 +++ apps/validator/test/report.test.ts | 109 +++ apps/validator/test/resolve.test.ts | 210 ++++++ apps/validator/tsconfig.json | 12 + eslint.config.js | 5 +- packages/discover-feed/README.md | 71 ++ packages/discover-feed/package.json | 28 + packages/discover-feed/src/html.ts | 86 +++ packages/discover-feed/src/index.ts | 244 +++++++ packages/discover-feed/src/media-types.ts | 56 ++ packages/discover-feed/test/index.test.ts | 246 +++++++ packages/discover-feed/tsconfig.build.json | 11 + packages/discover-feed/tsconfig.json | 7 + pnpm-lock.yaml | 627 ++++++++++++++++++ pnpm-workspace.yaml | 11 + workers/fetch-url/README.md | 109 +++ workers/fetch-url/package.json | 21 + workers/fetch-url/src/dns.ts | 46 ++ workers/fetch-url/src/fetch-document.ts | 207 ++++++ workers/fetch-url/src/index.ts | 164 +++++ workers/fetch-url/src/ssrf.ts | 249 +++++++ workers/fetch-url/test/fetch-document.test.ts | 155 +++++ workers/fetch-url/test/handler.test.ts | 144 ++++ workers/fetch-url/test/ssrf.test.ts | 121 ++++ workers/fetch-url/tsconfig.json | 18 + workers/fetch-url/wrangler.jsonc | 39 ++ 38 files changed, 4916 insertions(+), 10 deletions(-) create mode 100644 apps/validator/README.md create mode 100644 apps/validator/boot-errors.js create mode 100644 apps/validator/build.mjs create mode 100644 apps/validator/index.html create mode 100644 apps/validator/package.json create mode 100644 apps/validator/src/lib/locate.ts create mode 100644 apps/validator/src/lib/report.ts create mode 100644 apps/validator/src/lib/resolve.ts create mode 100644 apps/validator/src/main.ts create mode 100644 apps/validator/styles.css create mode 100644 apps/validator/test/locate.test.ts create mode 100644 apps/validator/test/report.test.ts create mode 100644 apps/validator/test/resolve.test.ts create mode 100644 apps/validator/tsconfig.json create mode 100644 packages/discover-feed/README.md create mode 100644 packages/discover-feed/package.json create mode 100644 packages/discover-feed/src/html.ts create mode 100644 packages/discover-feed/src/index.ts create mode 100644 packages/discover-feed/src/media-types.ts create mode 100644 packages/discover-feed/test/index.test.ts create mode 100644 packages/discover-feed/tsconfig.build.json create mode 100644 packages/discover-feed/tsconfig.json create mode 100644 workers/fetch-url/README.md create mode 100644 workers/fetch-url/package.json create mode 100644 workers/fetch-url/src/dns.ts create mode 100644 workers/fetch-url/src/fetch-document.ts create mode 100644 workers/fetch-url/src/index.ts create mode 100644 workers/fetch-url/src/ssrf.ts create mode 100644 workers/fetch-url/test/fetch-document.test.ts create mode 100644 workers/fetch-url/test/handler.test.ts create mode 100644 workers/fetch-url/test/ssrf.test.ts create mode 100644 workers/fetch-url/tsconfig.json create mode 100644 workers/fetch-url/wrangler.jsonc diff --git a/.github/workflows/deploy-tools.yml b/.github/workflows/deploy-tools.yml index 60a04a1..e49e723 100644 --- a/.github/workflows/deploy-tools.yml +++ b/.github/workflows/deploy-tools.yml @@ -1,6 +1,6 @@ # Deploys the central tools site to THIS repo's GitHub Pages: the editor, -# feed previewer, embeddable widget and publish tool are served under -# /editor, /preview, /embed and /publish (matching the +# feed previewer, validator, embeddable widget and publish tool are served +# under /editor, /preview, /validator, /embed and /publish (matching the # tools.opentechevents.org/?repo=… URLs from DESIGN.md; /import will # join them in a later phase). # @@ -10,6 +10,9 @@ # # 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/fetch-url` ships with `wrangler deploy`. name: Deploy tools site on: @@ -35,13 +38,20 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build + env: + # Origin of workers/fetch-url, baked into apps/validator's bundle AND + # into its CSP connect-src (build.mjs substitutes both). Set the + # repository variable to point the deployed page at a different + # Worker; the default is the production one. + OTE_FETCH_ENDPOINT: ${{ vars.OTE_FETCH_ENDPOINT || 'https://fetch.opentechevents.org' }} - 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/ + cp -R apps/validator/dist/. _site/validator/ if [ -d apps/embed/versions ]; then cp -R apps/embed/versions/. _site/embed/ fi @@ -61,6 +71,7 @@ jobs:
  • Event editor — create and edit OTE events without writing JSON
  • Feed previewer — inspect generated JSON, ICS and RSS exports
  • +
  • Validator — check any OTE feed or event by URL, file or paste
  • Broadcast — publish your events everywhere: structured data, widget, directories, posts
  • Embeddable widget — <ote-events>: drop an OTE feed into any website
diff --git a/README.md b/README.md index 4581693..9faa3bd 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,26 @@ 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. | | [`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`. +`editor`, `preview`, `publish`, `validator` 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`, `/validator` and `/embed`. + +## Workers + +| Worker | What it does | +| --- | --- | +| [`fetch-url`](workers/fetch-url/) | Cloudflare Worker, the **only** component with network access: given a URL, return the bytes, under SSRF and size limits. Deployed to Cloudflare, not to the tools site. | + +It 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. ## Reusable workflows diff --git a/apps/validator/README.md b/apps/validator/README.md new file mode 100644 index 0000000..b24d117 --- /dev/null +++ b/apps/validator/README.md @@ -0,0 +1,110 @@ +# 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 `workers/fetch-url`, 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 fetcher origin is baked in at build time + +`build.mjs` substitutes `OTE_FETCH_ENDPOINT` (default +`https://fetch.opentechevents.org`) into **both** the bundle and the CSP's +`connect-src` in `index.html`. Change it in one place only and the page will +call an endpoint its own CSP blocks — which fails at runtime, in the one mode +that needs a network. `deploy-tools.yml` sets it from the +`OTE_FETCH_ENDPOINT` repository variable. + +## 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..88f59c1 --- /dev/null +++ b/apps/validator/build.mjs @@ -0,0 +1,57 @@ +// 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"); + +// The endpoint that actually exists today. `fetch.opentechevents.org` is the +// intended name, but it needs opentechevents.org's zone moved to Cloudflare +// DNS first (a Workers custom domain cannot be a CNAME from another +// provider), and defaulting to a hostname that does not resolve makes every +// local `pnpm dev` fail in URL mode for no reason. Switch this the day that +// domain is live; CI overrides it through the OTE_FETCH_ENDPOINT variable +// either way. +const FETCH_ENDPOINT = + process.env.OTE_FETCH_ENDPOINT ?? "https://ote-fetch-url.hhkaos.workers.dev"; + +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}`); +writeFileSync( + "dist/index.html", + readFileSync("index.html", "utf8").replaceAll("__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..cc8d72e --- /dev/null +++ b/apps/validator/index.html @@ -0,0 +1,132 @@ + + + + + + + + OTE validator + + + +
+

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..6904e8c --- /dev/null +++ b/apps/validator/src/lib/resolve.ts @@ -0,0 +1,189 @@ +/** + * 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 fetcher Worker, e.g. `https://fetch.opentechevents.org`. */ + 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 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; + } + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 2502172..61b6805 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,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': @@ -194,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': @@ -342,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/fetch-url: + 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': @@ -356,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'} @@ -392,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'} @@ -637,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==} @@ -653,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] @@ -794,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==} @@ -960,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} @@ -979,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'} @@ -1006,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==} @@ -1016,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==} @@ -1193,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==} @@ -1225,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} @@ -1271,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==} @@ -1314,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'} @@ -1339,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'} @@ -1382,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'} @@ -1401,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==} @@ -1523,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'} @@ -1534,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==} @@ -1558,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)': @@ -1582,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 @@ -1735,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 @@ -1754,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 @@ -1831,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)': @@ -2030,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 @@ -2044,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 @@ -2070,6 +2594,8 @@ snapshots: deep-is@0.1.4: {} + detect-libc@2.1.2: {} + devalue@5.9.0: {} dompurify@3.4.13: @@ -2078,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: @@ -2290,6 +2818,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + leaflet@1.9.4: {} levn@0.4.1: @@ -2315,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 @@ -2356,6 +2898,8 @@ snapshots: path-key@3.1.1: {} + path-to-regexp@6.3.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -2413,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 @@ -2429,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 @@ -2481,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 @@ -2500,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 @@ -2581,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/fetch-url/README.md b/workers/fetch-url/README.md new file mode 100644 index 0000000..68d92e4 --- /dev/null +++ b/workers/fetch-url/README.md @@ -0,0 +1,109 @@ +# @opentechevents/fetch-url + +Cloudflare Worker. **The only component in this monorepo with network +access.** One job: given a URL, return the bytes. + +``` +GET https:///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":"…"} +``` + +## Why it 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/fetch-url test # vitest, no network +pnpm --filter @opentechevents/fetch-url typecheck +pnpm --filter @opentechevents/fetch-url run deploy # 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. + +Currently deployed at `https://ote-fetch-url.hhkaos.workers.dev`; the +`OTE_FETCH_ENDPOINT` repository variable points `apps/validator` at it until +`fetch.opentechevents.org` exists (which needs the zone moved to Cloudflare +DNS — a Workers custom domain cannot be a CNAME from another provider). + +`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. + +## Not in `apps/` + +`apps/*` are static bundles deployed to this repo's GitHub Pages under +`tools.opentechevents.org//`. A Worker is neither static nor deployed +there, so it lives in `workers/` — a separate workspace root in +`pnpm-workspace.yaml`. `deploy-tools.yml` does not touch it. diff --git a/workers/fetch-url/package.json b/workers/fetch-url/package.json new file mode 100644 index 0000000..c271939 --- /dev/null +++ b/workers/fetch-url/package.json @@ -0,0 +1,21 @@ +{ + "name": "@opentechevents/fetch-url", + "version": "0.1.0", + "description": "Cloudflare Worker that fetches a URL for the OTE validator: the only component with network access", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json --noEmit", + "deploy": "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/fetch-url/src/dns.ts b/workers/fetch-url/src/dns.ts new file mode 100644 index 0000000..923bc1a --- /dev/null +++ b/workers/fetch-url/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/fetch-url/src/fetch-document.ts b/workers/fetch-url/src/fetch-document.ts new file mode 100644 index 0000000..3c37eaf --- /dev/null +++ b/workers/fetch-url/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/fetch-url/src/index.ts b/workers/fetch-url/src/index.ts new file mode 100644 index 0000000..d2716c4 --- /dev/null +++ b/workers/fetch-url/src/index.ts @@ -0,0 +1,164 @@ +/** + * `fetch-url` — the only component of ote-tools with network access. + * + * It 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 }> }; +} + +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); + } + + if (url.pathname !== "/" && 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/fetch-url/src/ssrf.ts b/workers/fetch-url/src/ssrf.ts new file mode 100644 index 0000000..2feddf8 --- /dev/null +++ b/workers/fetch-url/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/fetch-url/test/fetch-document.test.ts b/workers/fetch-url/test/fetch-document.test.ts new file mode 100644 index 0000000..024776e --- /dev/null +++ b/workers/fetch-url/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/fetch-url/test/handler.test.ts b/workers/fetch-url/test/handler.test.ts new file mode 100644 index 0000000..e72a69e --- /dev/null +++ b/workers/fetch-url/test/handler.test.ts @@ -0,0 +1,144 @@ +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://fetch.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://fetch.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://fetch.example/fetch"), {}, { + fetchImpl: okFetch, + resolve: async () => ["93.184.216.34"], + }); + expect(missing.status).toBe(400); + + const posted = await handleRequest( + new Request("https://fetch.example/fetch?url=https://a.example/", { method: "POST" }), + {}, + { fetchImpl: okFetch, resolve: async () => ["93.184.216.34"] }, + ); + expect(posted.status).toBe(405); + }); + + it("has a health endpoint that publishes its limits", async () => { + const response = await handleRequest(new Request("https://fetch.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/fetch-url/test/ssrf.test.ts b/workers/fetch-url/test/ssrf.test.ts new file mode 100644 index 0000000..b13df58 --- /dev/null +++ b/workers/fetch-url/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/fetch-url/tsconfig.json b/workers/fetch-url/tsconfig.json new file mode 100644 index 0000000..604ee5a --- /dev/null +++ b/workers/fetch-url/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/fetch-url/wrangler.jsonc b/workers/fetch-url/wrangler.jsonc new file mode 100644 index 0000000..8ac1bcf --- /dev/null +++ b/workers/fetch-url/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "ote-fetch-url", + "main": "src/index.ts", + "compatibility_date": "2026-08-01", + "observability": { "enabled": true }, + // The endpoint's public name, declared here rather than clicked into the + // dashboard: `wrangler deploy` creates the DNS record and the certificate, + // and any redeploy re-asserts them. Requires opentechevents.org's zone to + // live in Cloudflare DNS — a Workers custom domain cannot be a CNAME from + // another provider. + "routes": [{ "pattern": "fetch.opentechevents.org", "custom_domain": true }], + // Declaring `routes` makes wrangler disable the workers.dev URL by default. + // Keep it: it is the endpoint local development points at, and the fallback + // if anything ever goes wrong with the custom domain's DNS. + "workers_dev": true, + "vars": { + // Browsers that may call this endpoint. Not a security boundary — curl + // ignores CORS — but it keeps unrelated pages off our fetch budget. The + // localhost entries let `pnpm --filter @opentechevents/validator dev` + // (PORT=8000) exercise URL mode against the deployed fetcher instead of + // needing a second Worker; they grant nothing a terminal did not already + // have. + "ALLOWED_ORIGINS": "https://tools.opentechevents.org,https://opentechevents.github.io,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 } + } + ] + } +} From c5ac754e3504872da930b156f30a7f862eb0a0be Mon Sep 17 00:00:00 2001 From: Raul Jimenez Ortega Date: Thu, 27 Aug 2026 19:17:56 +0200 Subject: [PATCH 3/5] feat(validator): follow opentechevents.org's visual language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tools are reached from that site and carry its name, so a visitor must not feel handed off to a different product. Tokens, type scale, header, buttons, cards and footer come from its stylesheet rather than from a parallel palette invented here: ink #10131a, accent #2b5bd7, radius 10, 1120px wrap, 17px/1.65 system sans. The document viewer reuses that site's dark code block — caption bar, mono, #d5dae4 on #10131a — because the JSON being validated is a code sample and now reads as one; the highlighted line uses the same #7fb3ff the site marks lines with. Two fixes to what the layout was doing wrong: - "Validate as" appears only once there is a verdict. It was offering to correct a detection that had not happened yet. - From 1000px the results split: findings left, document right and sticky. Pointing at line 42 is worth little if line 42 has scrolled away. Below that it stacks, findings first. Co-Authored-By: Claude Opus 5 --- apps/validator/index.html | 186 +++-- apps/validator/src/main.ts | 10 + apps/validator/styles.css | 663 +++++++++++++----- workers/{fetch-url => validator}/README.md | 0 workers/{fetch-url => validator}/package.json | 0 workers/{fetch-url => validator}/src/dns.ts | 0 .../src/fetch-document.ts | 0 workers/{fetch-url => validator}/src/index.ts | 0 workers/{fetch-url => validator}/src/ssrf.ts | 0 .../test/fetch-document.test.ts | 0 .../test/handler.test.ts | 0 .../test/ssrf.test.ts | 0 .../{fetch-url => validator}/tsconfig.json | 0 .../{fetch-url => validator}/wrangler.jsonc | 0 14 files changed, 622 insertions(+), 237 deletions(-) rename workers/{fetch-url => validator}/README.md (100%) rename workers/{fetch-url => validator}/package.json (100%) rename workers/{fetch-url => validator}/src/dns.ts (100%) rename workers/{fetch-url => validator}/src/fetch-document.ts (100%) rename workers/{fetch-url => validator}/src/index.ts (100%) rename workers/{fetch-url => validator}/src/ssrf.ts (100%) rename workers/{fetch-url => validator}/test/fetch-document.test.ts (100%) rename workers/{fetch-url => validator}/test/handler.test.ts (100%) rename workers/{fetch-url => validator}/test/ssrf.test.ts (100%) rename workers/{fetch-url => validator}/tsconfig.json (100%) rename workers/{fetch-url => validator}/wrangler.jsonc (100%) diff --git a/apps/validator/index.html b/apps/validator/index.html index cc8d72e..690507c 100644 --- a/apps/validator/index.html +++ b/apps/validator/index.html @@ -36,91 +36,133 @@ -
-

OTE validator

-

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

-
+ - + -
-
- -
- - +
+
+
+

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. -

- -
- - +
- +
+ - +
+
+ +
+ + +
+

+ 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. +

+
+
-
- - + - + -
- - -
+ - - - + + - + + +
-