Skip to content

Commit d10223d

Browse files
hhkaosclaude
andcommitted
refactor(validator): serve the page and its endpoint from one origin
validator.opentechevents.org replaces tools.opentechevents.org/validator/ as the canonical URL, and workers/validator (renamed from fetch-url) serves both the page — through an assets binding — and /fetch. Two reasons, one of them structural: The validator is the only tool with no `?repo=` context. The others read an organizer's fork from the query string, which is what the shared tools host expresses; this one serves anyone holding a JSON document, kit user or not, so a path under that host misfiled it. Sharing an origin deletes a class of configuration rather than managing it. The page issues a relative /fetch, so no cross-origin request happens, its CSP tightens to `connect-src 'self'`, and ALLOWED_ORIGINS shrinks to callers that genuinely are elsewhere (the dev server, the legacy path). The endpoint URL is no longer baked into the bundle and the CSP, where the two could drift apart. GitHub Pages keeps /validator/ as a redirect so links already shared survive and permalinks have one canonical form. Deployment moves to deploy-validator.yml, which builds the page, deploys the Worker and then re-runs the SSRF probes against the live endpoint — a deploy that stopped refusing file:// or the metadata address is worse than one that failed. Needs a CLOUDFLARE_API_TOKEN repository secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c5ac754 commit d10223d

13 files changed

Lines changed: 279 additions & 92 deletions

File tree

.github/workflows/deploy-tools.yml

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Deploys the central tools site to THIS repo's GitHub Pages: the editor,
2-
# feed previewer, validator, embeddable widget and publish tool are served
3-
# under /editor, /preview, /validator, /embed and /publish (matching the
2+
# feed previewer, embeddable widget and publish tool are served under
3+
# /editor, /preview, /embed and /publish (matching the
44
# tools.opentechevents.org/<tool>?repo=… URLs from DESIGN.md; /import will
55
# join them in a later phase).
66
#
@@ -12,7 +12,8 @@
1212
# ote-template forks call to publish THEIR feed sites.
1313
#
1414
# workers/* are NOT deployed here: a Cloudflare Worker is neither static nor
15-
# served from Pages. `workers/fetch-url` ships with `wrangler deploy`.
15+
# served from Pages. `workers/validator` ships through deploy-validator.yml,
16+
# and it carries the validator's own page with it.
1617
name: Deploy tools site
1718

1819
on:
@@ -38,20 +39,29 @@ jobs:
3839
cache: pnpm
3940
- run: pnpm install --frozen-lockfile
4041
- run: pnpm build
41-
env:
42-
# Origin of workers/fetch-url, baked into apps/validator's bundle AND
43-
# into its CSP connect-src (build.mjs substitutes both). Set the
44-
# repository variable to point the deployed page at a different
45-
# Worker; the default is the production one.
46-
OTE_FETCH_ENDPOINT: ${{ vars.OTE_FETCH_ENDPOINT || 'https://fetch.opentechevents.org' }}
4742

4843
- name: Assemble site
4944
run: |
5045
mkdir -p _site/editor _site/preview _site/publish _site/validator _site/embed _site/embed/latest
5146
cp -R apps/editor/dist/. _site/editor/
5247
cp -R apps/preview/dist/. _site/preview/
5348
cp -R apps/publish/dist/. _site/publish/
54-
cp -R apps/validator/dist/. _site/validator/
49+
# The validator itself is NOT served from Pages: workers/validator
50+
# serves it at validator.opentechevents.org, page and fetch endpoint
51+
# on one origin (which is what lets it need no CORS). This path stays
52+
# as a redirect so links already shared keep working and there is one
53+
# canonical URL for permalinks.
54+
cat > _site/validator/index.html <<'EOF'
55+
<!doctype html>
56+
<html lang="en">
57+
<meta charset="utf-8">
58+
<title>OTE validator has moved</title>
59+
<link rel="canonical" href="https://validator.opentechevents.org/">
60+
<meta http-equiv="refresh" content="0; url=https://validator.opentechevents.org/">
61+
<p>The OTE validator now lives at
62+
<a href="https://validator.opentechevents.org/">validator.opentechevents.org</a>.</p>
63+
</html>
64+
EOF
5565
if [ -d apps/embed/versions ]; then
5666
cp -R apps/embed/versions/. _site/embed/
5767
fi
@@ -71,7 +81,7 @@ jobs:
7181
<ul>
7282
<li><a href="./editor/">Event editor</a> — create and edit OTE events without writing JSON</li>
7383
<li><a href="./preview/">Feed previewer</a> — inspect generated JSON, ICS and RSS exports</li>
74-
<li><a href="./validator/">Validator</a> &mdash; check any OTE feed or event by URL, file or paste</li>
84+
<li><a href="https://validator.opentechevents.org/">Validator</a> &mdash; check any OTE feed or event by URL, file or paste</li>
7585
<li><a href="./publish/">Broadcast</a> — publish your events everywhere: structured data, widget, directories, posts</li>
7686
<li><a href="./embed/">Embeddable widget</a> — &lt;ote-events&gt;: drop an OTE feed into any website</li>
7787
</ul>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Deploys workers/validator to Cloudflare: the validator page AND its
2+
# SSRF-guarded fetch endpoint, on one origin
3+
# (validator.opentechevents.org).
4+
#
5+
# Separate from deploy-tools.yml on purpose. That workflow publishes static
6+
# bundles to this repo's GitHub Pages; a Worker is neither static nor served
7+
# from Pages, and this one carries the page with it as its `assets` binding.
8+
# GitHub Pages keeps serving /validator/ as a redirect to the canonical URL.
9+
#
10+
# One-time prerequisite: a CLOUDFLARE_API_TOKEN repository secret with the
11+
# "Edit Cloudflare Workers" template plus read access to the
12+
# opentechevents.org zone (the custom domains are declared in wrangler.jsonc,
13+
# so a deploy re-asserts them).
14+
name: Deploy validator
15+
16+
on:
17+
push:
18+
branches: [main]
19+
paths:
20+
- "apps/validator/**"
21+
- "workers/validator/**"
22+
- "packages/discover-feed/**"
23+
- "packages/validate/**"
24+
- ".github/workflows/deploy-validator.yml"
25+
workflow_dispatch:
26+
27+
concurrency:
28+
group: validator
29+
cancel-in-progress: true
30+
31+
jobs:
32+
deploy:
33+
runs-on: ubuntu-latest
34+
permissions:
35+
contents: read
36+
steps:
37+
- uses: actions/checkout@v7
38+
- uses: pnpm/action-setup@v6
39+
- uses: actions/setup-node@v7
40+
with:
41+
node-version: 22
42+
cache: pnpm
43+
- run: pnpm install --frozen-lockfile
44+
# The Worker's `assets` directory is apps/validator/dist, so the page has
45+
# to exist before wrangler runs. `pnpm build` builds every workspace
46+
# package the bundle imports as well.
47+
- run: pnpm build
48+
49+
- name: Deploy
50+
run: pnpm --filter @opentechevents/validator-service exec wrangler deploy
51+
env:
52+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
53+
54+
- name: Smoke check
55+
run: |
56+
set -euo pipefail
57+
base=https://validator.opentechevents.org
58+
curl -fsS "$base/health" > /dev/null
59+
# The endpoint's whole purpose is refusing these; a deploy that
60+
# stopped refusing them is worse than a deploy that failed.
61+
for probe in "file:///etc/passwd" "http://169.254.169.254/"; do
62+
code=$(curl -s -o /dev/null -w '%{http_code}' "$base/fetch?url=$(printf %s "$probe" | jq -sRr @uri)")
63+
test "$code" = "400" || { echo "SSRF probe $probe answered $code, expected 400"; exit 1; }
64+
done

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ Central monorepo for the OTE organizer kit. Read DESIGN.md before any task.
2323
`pnpm build` has run. That error means the workspace is unbuilt, not broken —
2424
never "fix" it by touching imports. CI never hits it: it builds first.
2525
- Convention: connectors never invent data; absent field = absent + warning.
26+
- **Visual language: every UI here follows opentechevents.org.** The tools are
27+
reached from that site and carry its name, so a visitor must never feel
28+
handed off to a different product. Take the tokens from its stylesheet
29+
(`https://opentechevents.org/styles.css`) rather than inventing a palette:
30+
ink `#10131a` / soft `#4a5265` / faint `#6f7787`, accent `#2b5bd7` with
31+
`#eaf0fe` soft and `#1e46ab` for hover, line `#e3e6ea`, alt background
32+
`#f6f7f9`, dark `#10131a`, ok `#0f8a5f`, warn `#b06d00`; radius 10px, wrap
33+
1120px, 17px/1.65 body in the system sans stack, mono for code. Reuse its
34+
components rather than re-designing them: sticky translucent header with the
35+
`OTE` brand mark, `.btn`/`.btn-primary`/`.btn-ghost`, white cards on
36+
`--line` borders, uppercase pill badges, dark code blocks with a caption
37+
bar, dark footer. `apps/validator/styles.css` is the current reference
38+
implementation. When the main site's palette moves, move it here too.
2639
- Versioned public assets: `apps/embed` is a consumer-facing Web Component.
2740
Read `apps/embed/CLAUDE.md` before changing it. Changes to public widget
2841
behavior should use semantic versioning in `apps/embed/package.json`, update
@@ -31,6 +44,14 @@ Central monorepo for the OTE organizer kit. Read DESIGN.md before any task.
3144
- `apps/editor` has its own `CLAUDE.md` — dev-workflow gotchas (static
3245
files aren't watched), a recurring CSS `:not([hidden])` pitfall, and
3346
browser-testing notes specific to that app. Read it before editor work.
47+
- `apps/validator` is built here but **served by `workers/validator`** at
48+
`validator.opentechevents.org`, page and `/fetch` endpoint on one origin (so
49+
it needs no CORS and its CSP is `connect-src 'self'`). It is the one tool
50+
without a `?repo=` context, hence its own hostname instead of a path under
51+
`tools.opentechevents.org`, which keeps redirecting to it. Read
52+
`apps/validator/README.md` before touching it: two failure modes there are
53+
invisible to the test suite (the detached global `fetch`, and ajv needing
54+
`'unsafe-eval'` in the page CSP).
3455
- `apps/publish` (Broadcast) has its own `CLAUDE.md` too — the pinned-event
3556
rule, the generated/guided/planned ladder that lets its destination
3657
catalogue be wide without becoming a wall of promises, and the

README.md

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,32 @@ improvements can be traced package by package.
4040
| [`preview`](apps/preview/) | Static feed previewer for OTE organizer forks. |
4141
| [`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. |
4242
| [`embed`](apps/embed/) | Embeddable `<ote-events>` web component: drop an OTE feed into any website. |
43-
| [`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. |
43+
| [`validator`](apps/validator/) | Is this document a valid OTE feed or event? Three input modes (URL, file, paste), linkable results, errors pointed at the exact line. Served at `validator.opentechevents.org` by `workers/validator`, not from Pages. |
4444
| [`dashboard-checks`](apps/dashboard-checks/) | Client-side setup checks + template-update banner for OTE organizer dashboards. |
4545

46-
`editor`, `preview`, `publish`, `validator` and `embed` are built and deployed
47-
together by `deploy-tools.yml`; `dashboard-checks.js` is served as a
48-
standalone file. Once the `tools.opentechevents.org` custom domain is
49-
configured (see `.github/workflows/deploy-tools.yml`), they're reachable at
50-
`tools.opentechevents.org/editor`, `/preview`, `/validator` and `/embed`.
46+
`editor`, `preview`, `publish` and `embed` are built and deployed together by
47+
`deploy-tools.yml` to `tools.opentechevents.org/<tool>/`;
48+
`dashboard-checks.js` is served as a standalone file. Those four take a
49+
`?repo=owner/name` context from an organizer's fork, which is what the shared
50+
host expresses.
51+
52+
The `validator` does not: it serves anyone with a JSON document, so it gets
53+
its own hostname, `validator.opentechevents.org`, deployed with the Worker
54+
that its URL mode needs anyway (`deploy-validator.yml`). The Pages path
55+
`/validator/` remains as a redirect.
5156

5257
## Workers
5358

5459
| Worker | What it does |
5560
| --- | --- |
56-
| [`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. |
57-
58-
It exists for one mode of one tool: the validator cannot fetch a third-party
59-
feed from the browser, because community feeds send no CORS headers. This is
60-
not the "CORS proxy for reading platforms" that DESIGN.md rules out — it
61-
fetches a document the user already has the URL of, in order to validate it,
62-
and stores nothing.
61+
| [`validator`](workers/validator/) | Serves `validator.opentechevents.org`: the validator page **and** its fetch endpoint on one origin. The **only** component with network access. |
62+
63+
The endpoint exists for one mode of one tool: the validator cannot fetch a
64+
third-party feed from the browser, because community feeds send no CORS
65+
headers. This is not the "CORS proxy for reading platforms" that DESIGN.md
66+
rules out — it fetches a document the user already has the URL of, in order to
67+
validate it, and stores nothing. Serving the page from the same Worker means
68+
the page never makes a cross-origin request at all.
6369

6470
## Reusable workflows
6571

apps/validator/README.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pnpm --filter @opentechevents/validator test
1818
| --- | --- |
1919
| Upload a file | Entirely in the tab. The file is never uploaded. |
2020
| Paste JSON | Entirely in the tab. Nothing is sent anywhere. |
21-
| From a URL | Through `workers/fetch-url`, the only component with network access. |
21+
| From a URL | Through `/fetch` on the same origin, served by `workers/validator` the only component with network access. |
2222

2323
Upload and paste keep working with the Worker down — there is a test that
2424
deletes `globalThis.fetch` and validates a fixture anyway. That is not a
@@ -65,14 +65,26 @@ so the endpoint cannot be used to serve someone's feed as a page.
6565

6666
The document is displayed and validated. It is never executed.
6767

68-
## The fetcher origin is baked in at build time
68+
## The fetch endpoint is same-origin in production
6969

70-
`build.mjs` substitutes `OTE_FETCH_ENDPOINT` (default
71-
`https://fetch.opentechevents.org`) into **both** the bundle and the CSP's
72-
`connect-src` in `index.html`. Change it in one place only and the page will
73-
call an endpoint its own CSP blocks — which fails at runtime, in the one mode
74-
that needs a network. `deploy-tools.yml` sets it from the
75-
`OTE_FETCH_ENDPOINT` repository variable.
70+
`build.mjs` leaves `OTE_FETCH_ENDPOINT` empty by default, which makes the page
71+
call a relative `/fetch` — no cross-origin request, and a CSP that says
72+
`connect-src 'self'`. Set the variable to an absolute origin only when page
73+
and endpoint genuinely live apart, which in practice means `pnpm dev`:
74+
75+
```sh
76+
OTE_FETCH_ENDPOINT=https://ote-validator.hhkaos.workers.dev PORT=8000 \
77+
pnpm --filter @opentechevents/validator dev
78+
```
79+
80+
`build.mjs` substitutes the value into **both** the bundle and the CSP's
81+
`connect-src`, so the two cannot drift — set only one and the page calls an
82+
endpoint its own CSP blocks, which fails at runtime in the one mode that needs
83+
a network.
84+
85+
Local URL mode also requires that origin to be in the Worker's
86+
`ALLOWED_ORIGINS` (`localhost:8000` already is). Uploading and pasting need
87+
none of this.
7688

7789
## Permalinks
7890

apps/validator/build.mjs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,13 @@ import * as esbuild from "esbuild";
1111

1212
const serve = process.argv.includes("--serve");
1313

14-
// The endpoint that actually exists today. `fetch.opentechevents.org` is the
15-
// intended name, but it needs opentechevents.org's zone moved to Cloudflare
16-
// DNS first (a Workers custom domain cannot be a CNAME from another
17-
// provider), and defaulting to a hostname that does not resolve makes every
18-
// local `pnpm dev` fail in URL mode for no reason. Switch this the day that
19-
// domain is live; CI overrides it through the OTE_FETCH_ENDPOINT variable
20-
// either way.
21-
const FETCH_ENDPOINT =
22-
process.env.OTE_FETCH_ENDPOINT ?? "https://ote-fetch-url.hhkaos.workers.dev";
14+
// Empty means same origin, which is the production shape: workers/validator
15+
// serves this page AND its /fetch endpoint, so the page calls a relative path
16+
// and no cross-origin request happens at all. Set OTE_FETCH_ENDPOINT to an
17+
// absolute origin only when the two are genuinely apart — notably `pnpm dev`,
18+
// where esbuild serves the page on localhost while the fetcher lives on
19+
// Cloudflare.
20+
const FETCH_ENDPOINT = process.env.OTE_FETCH_ENDPOINT ?? "";
2321

2422
const options = {
2523
entryPoints: ["src/main.ts"],
@@ -38,9 +36,14 @@ const options = {
3836

3937
mkdirSync("dist", { recursive: true });
4038
for (const file of ["styles.css", "boot-errors.js"]) copyFileSync(file, `dist/${file}`);
39+
// The placeholder carries its own leading space so that removing it (the
40+
// same-origin case) leaves `connect-src 'self'` rather than a stray token.
4141
writeFileSync(
4242
"dist/index.html",
43-
readFileSync("index.html", "utf8").replaceAll("__FETCH_ENDPOINT__", FETCH_ENDPOINT),
43+
readFileSync("index.html", "utf8").replaceAll(
44+
" __FETCH_ENDPOINT__",
45+
FETCH_ENDPOINT ? ` ${FETCH_ENDPOINT}` : "",
46+
),
4447
);
4548

4649
if (serve) {

apps/validator/src/lib/resolve.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ export type FetchEnvelope =
3636
| { ok: false; code: string; message: string };
3737

3838
export interface ResolveDeps {
39-
/** Base URL of the fetcher Worker, e.g. `https://fetch.opentechevents.org`. */
39+
/**
40+
* Base URL of the fetch endpoint. Empty string — the production case —
41+
* means same origin: `workers/validator` serves both this page and
42+
* `/fetch`, so the request is relative and never crosses an origin.
43+
*/
4044
endpoint: string;
4145
fetchImpl: typeof fetch;
4246
options?: DiscoverOptions;
@@ -78,7 +82,7 @@ export async function fetchViaWorker(url: string, deps: ResolveDeps): Promise<Fe
7882
ok: false,
7983
code: "fetcher-unreachable",
8084
message:
81-
`The fetch service (${deps.endpoint}) could not be reached — ${reason}. ` +
85+
`The fetch service (${deps.endpoint || "this page's own origin"}) could not be reached — ${reason}. ` +
8286
"Uploading a file or pasting JSON still works; those never leave your browser.",
8387
};
8488
}

pnpm-lock.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)