Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/deploy-tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
#
# Not to be confused with build-pages.yml, the reusable workflow that the
# ote-template forks call to publish THEIR feed sites.
#
# workers/* are NOT deployed here: a Cloudflare Worker is neither static nor
# served from Pages. `workers/validator` ships through deploy-validator.yml,
# and it carries the validator's own page with it.
name: Deploy tools site

on:
Expand Down Expand Up @@ -38,10 +42,26 @@ jobs:

- name: Assemble site
run: |
mkdir -p _site/editor _site/preview _site/publish _site/embed _site/embed/latest
mkdir -p _site/editor _site/preview _site/publish _site/validator _site/embed _site/embed/latest
cp -R apps/editor/dist/. _site/editor/
cp -R apps/preview/dist/. _site/preview/
cp -R apps/publish/dist/. _site/publish/
# The validator itself is NOT served from Pages: workers/validator
# serves it at validator.opentechevents.org, page and fetch endpoint
# on one origin (which is what lets it need no CORS). This path stays
# as a redirect so links already shared keep working and there is one
# canonical URL for permalinks.
cat > _site/validator/index.html <<'EOF'
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>OTE validator has moved</title>
<link rel="canonical" href="https://validator.opentechevents.org/">
<meta http-equiv="refresh" content="0; url=https://validator.opentechevents.org/">
<p>The OTE validator now lives at
<a href="https://validator.opentechevents.org/">validator.opentechevents.org</a>.</p>
</html>
EOF
if [ -d apps/embed/versions ]; then
cp -R apps/embed/versions/. _site/embed/
fi
Expand All @@ -61,6 +81,7 @@ jobs:
<ul>
<li><a href="./editor/">Event editor</a> — create and edit OTE events without writing JSON</li>
<li><a href="./preview/">Feed previewer</a> — inspect generated JSON, ICS and RSS exports</li>
<li><a href="https://validator.opentechevents.org/">Validator</a> &mdash; check any OTE feed or event by URL, file or paste</li>
<li><a href="./publish/">Broadcast</a> — publish your events everywhere: structured data, widget, directories, posts</li>
<li><a href="./embed/">Embeddable widget</a> — &lt;ote-events&gt;: drop an OTE feed into any website</li>
</ul>
Expand Down
64 changes: 64 additions & 0 deletions .github/workflows/deploy-validator.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Deploys workers/validator to Cloudflare: the validator page AND its
# SSRF-guarded fetch endpoint, on one origin
# (validator.opentechevents.org).
#
# Separate from deploy-tools.yml on purpose. That workflow publishes static
# bundles to this repo's GitHub Pages; a Worker is neither static nor served
# from Pages, and this one carries the page with it as its `assets` binding.
# GitHub Pages keeps serving /validator/ as a redirect to the canonical URL.
#
# One-time prerequisite: a CLOUDFLARE_API_TOKEN repository secret with the
# "Edit Cloudflare Workers" template plus read access to the
# opentechevents.org zone (the custom domains are declared in wrangler.jsonc,
# so a deploy re-asserts them).
name: Deploy validator

on:
push:
branches: [main]
paths:
- "apps/validator/**"
- "workers/validator/**"
- "packages/discover-feed/**"
- "packages/validate/**"
- ".github/workflows/deploy-validator.yml"
workflow_dispatch:

concurrency:
group: validator
cancel-in-progress: true

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# The Worker's `assets` directory is apps/validator/dist, so the page has
# to exist before wrangler runs. `pnpm build` builds every workspace
# package the bundle imports as well.
- run: pnpm build

- name: Deploy
run: pnpm --filter @opentechevents/validator-service exec wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

- name: Smoke check
run: |
set -euo pipefail
base=https://validator.opentechevents.org
curl -fsS "$base/health" > /dev/null
# The endpoint's whole purpose is refusing these; a deploy that
# stopped refusing them is worse than a deploy that failed.
for probe in "file:///etc/passwd" "http://169.254.169.254/"; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$base/fetch?url=$(printf %s "$probe" | jq -sRr @uri)")
test "$code" = "400" || { echo "SSRF probe $probe answered $code, expected 400"; exit 1; }
done
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ Central monorepo for the OTE organizer kit. Read DESIGN.md before any task.
`pnpm build` has run. That error means the workspace is unbuilt, not broken —
never "fix" it by touching imports. CI never hits it: it builds first.
- Convention: connectors never invent data; absent field = absent + warning.
- **Visual language: every UI here follows opentechevents.org.** The tools are
reached from that site and carry its name, so a visitor must never feel
handed off to a different product. Take the tokens from its stylesheet
(`https://opentechevents.org/styles.css`) rather than inventing a palette:
ink `#10131a` / soft `#4a5265` / faint `#6f7787`, accent `#2b5bd7` with
`#eaf0fe` soft and `#1e46ab` for hover, line `#e3e6ea`, alt background
`#f6f7f9`, dark `#10131a`, ok `#0f8a5f`, warn `#b06d00`; radius 10px, wrap
1120px, 17px/1.65 body in the system sans stack, mono for code. Reuse its
components rather than re-designing them: sticky translucent header with the
`OTE` brand mark, `.btn`/`.btn-primary`/`.btn-ghost`, white cards on
`--line` borders, uppercase pill badges, dark code blocks with a caption
bar, dark footer. `apps/validator/styles.css` is the current reference
implementation. When the main site's palette moves, move it here too.
- Versioned public assets: `apps/embed` is a consumer-facing Web Component.
Read `apps/embed/CLAUDE.md` before changing it. Changes to public widget
behavior should use semantic versioning in `apps/embed/package.json`, update
Expand All @@ -31,6 +44,14 @@ Central monorepo for the OTE organizer kit. Read DESIGN.md before any task.
- `apps/editor` has its own `CLAUDE.md` — dev-workflow gotchas (static
files aren't watched), a recurring CSS `:not([hidden])` pitfall, and
browser-testing notes specific to that app. Read it before editor work.
- `apps/validator` is built here but **served by `workers/validator`** at
`validator.opentechevents.org`, page and `/fetch` endpoint on one origin (so
it needs no CORS and its CSP is `connect-src 'self'`). It is the one tool
without a `?repo=` context, hence its own hostname instead of a path under
`tools.opentechevents.org`, which keeps redirecting to it. Read
`apps/validator/README.md` before touching it: two failure modes there are
invisible to the test suite (the detached global `fetch`, and ajv needing
`'unsafe-eval'` in the page CSP).
- `apps/publish` (Broadcast) has its own `CLAUDE.md` too — the pinned-event
rule, the generated/guided/planned ladder that lets its destination
catalogue be wide without becoming a wall of promises, and the
Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -39,13 +40,32 @@ improvements can be traced package by package.
| [`preview`](apps/preview/) | Static feed previewer for OTE organizer forks. |
| [`publish`](apps/publish/) | "Broadcast" console: one event → every channel it can be published to. schema.org snippet, widget and subscribe links work today; directories, newsletters and social posts are declared and unbuilt. |
| [`embed`](apps/embed/) | Embeddable `<ote-events>` web component: drop an OTE feed into any website. |
| [`validator`](apps/validator/) | Is this document a valid OTE feed or event? Three input modes (URL, file, paste), linkable results, errors pointed at the exact line. Served at `validator.opentechevents.org` by `workers/validator`, not from Pages. |
| [`dashboard-checks`](apps/dashboard-checks/) | Client-side setup checks + template-update banner for OTE organizer dashboards. |

`editor`, `preview`, `publish` and `embed` are built and deployed together by
`deploy-tools.yml`; `dashboard-checks.js` is served as a standalone file.
Once the `tools.opentechevents.org` custom domain is configured (see
`.github/workflows/deploy-tools.yml`), they're reachable at
`tools.opentechevents.org/editor`, `/preview` and `/embed`.
`deploy-tools.yml` to `tools.opentechevents.org/<tool>/`;
`dashboard-checks.js` is served as a standalone file. Those four take a
`?repo=owner/name` context from an organizer's fork, which is what the shared
host expresses.

The `validator` does not: it serves anyone with a JSON document, so it gets
its own hostname, `validator.opentechevents.org`, deployed with the Worker
that its URL mode needs anyway (`deploy-validator.yml`). The Pages path
`/validator/` remains as a redirect.

## Workers

| Worker | What it does |
| --- | --- |
| [`validator`](workers/validator/) | Serves `validator.opentechevents.org`: the validator page **and** its fetch endpoint on one origin. The **only** component with network access. |

The endpoint exists for one mode of one tool: the validator cannot fetch a
third-party feed from the browser, because community feeds send no CORS
headers. This is not the "CORS proxy for reading platforms" that DESIGN.md
rules out — it fetches a document the user already has the URL of, in order to
validate it, and stores nothing. Serving the page from the same Worker means
the page never makes a cross-origin request at all.

## Reusable workflows

Expand Down
1 change: 1 addition & 0 deletions apps/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
30 changes: 24 additions & 6 deletions apps/editor/src/lib/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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),
};
}
Expand All @@ -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
Expand Down
18 changes: 16 additions & 2 deletions apps/editor/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ async function fetchJson(url: string): Promise<unknown | null> {
}
}

/** The first URL that answers with JSON, or null when none of them does. */
async function fetchFirstJson(urls: string[]): Promise<unknown | null> {
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) {
Expand Down Expand Up @@ -245,6 +254,9 @@ async function startEditor(repo: string | null): Promise<void> {
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();
Expand Down Expand Up @@ -1783,7 +1795,7 @@ async function startEditor(repo: string | null): Promise<void> {
);
} 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(
Expand Down Expand Up @@ -2914,7 +2926,9 @@ async function startEditor(repo: string | null): Promise<void> {
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",
Expand Down
16 changes: 15 additions & 1 deletion apps/editor/test/repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions apps/preview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
29 changes: 15 additions & 14 deletions apps/preview/src/main.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}

Expand Down
Loading