Repository navigation
Web Application
The web component (web/) is contribkit.app plus the public SVG/JSON API. It's an Astro + TypeScript app deployed to Cloudflare Workers via @astrojs/cloudflare.
pnpm install
cd webThe command table lives in web/README.md
and is not repeated here: the copy that used to sit in this page had already lost pnpm format:check. CI runs no
single command: verify-web runs the checks of pnpm verify as three parallel steps, pnpm format:check,
pnpm typecheck && pnpm check and pnpm test:ut:coverage (see CI/CD).
| File | Route |
|---|---|
pages/index.astro |
Landing page: SSR initial render + client interactivity |
pages/api/contributions.ts |
GET /api/contributions?user=&year= |
pages/api/contact.ts |
POST /api/contact |
pages/api/health.ts |
GET /api/health |
pages/user/[username].svg.ts |
GET /user/:username.svg |
pages/404.astro, 500.astro
|
Error pages (shared ErrorView) |
pages/contact.astro |
/contact: the contact form, indexable and in the sitemap |
pages/legal-notice.astro, privacy.astro, terms.astro
|
Static legal pages |
All dynamic routes set prerender = false. Pages are the composition root: they instantiate infrastructure and use cases once at module scope, validate input with Zod + domain value objects, call the use case, and map any Failure to an HTTP response via statusFor/messageFor. No business logic lives in pages. See API Reference.
| Route | Validation |
|---|---|
/api/contributions |
Zod schema requires user (min 1), optional year; then parseUsername + parseYear
|
/user/:username.svg |
parseUsername(params.username); palette/shape fall back to defaults, background is regex-checked (transparent, hex, or CSS color name) then defaulted |
/api/contact |
Zod over the JSON body requires email and message and allows name and the website honeypot; then parseContactMessage, whose email rule is also the header-injection guard |
Unknown palette/shape/background values silently fall back to defaults via Zod .catch(), so the SVG never errors on bad options; only an invalid username produces a 4xx.
src/middleware.ts runs on every request and does three things:
-
Blocking the agent guide:
/AGENTSgets a bare404before anything else runs. Astro compilessrc/pages/AGENTS.mdinto a public page, and this is what keeps it off the web (ADR 0018). -
Rate limiting: for
/api/*paths, it picks a binding and callslimit({ key })keyed onCF-Connecting-IP. The/api/contactpath, whatever the method, goes throughCONTACT_RATE_LIMITER(5/min, protecting a mailbox) and every other/api/path throughAPI_RATE_LIMITER(100/min, protecting an upstream). Over the limit, it returns429withRetry-After: 60either way (still wrapped in the security headers). A path whose own binding is absent is simply not limited; the contact path never falls back to the other bucket. - Security headers: every response is re-wrapped with a strict header set:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
https://www.googletagmanager.com https://betterstack.net
https://static.cloudflareinsights.com; style-src 'self'
'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;
img-src 'self' data:; connect-src 'self' https://*.google-analytics.com
https://analytics.google.com https://*.analytics.google.com https://www.googletagmanager.com
https://betterstack.net https://*.betterstackdata.com https://cloudflareinsights.com;
worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Cross-Origin-Embedder-Policy: unsafe-none
/user/:username.svg overrides the resource policy to cross-origin so the calendar embeds on any site; every other route keeps same-origin. See API Reference.
Both deploys run from ci.yml, only after Verify (web) (the checks of pnpm verify, run as parallel steps: format check, typecheck, astro check and coverage) passes:
-
Production: every push to
mainthat thechangesjob counts as a web change builds withCLOUDFLARE_ENV=production, thenwrangler deploy --env production→ workercontribkitoncontribkit.app. That set coversshared/,docs/,scripts/, root*.md, the root config files and the whole of.github/as well asweb/, so a documentation-only push redeploys too; see CI/CD for why. -
Development: every PR counted the same way builds with
CLOUDFLARE_ENV=developmentand deploys an ephemeral workerpr-<n>-contribkit-developmenton*.workers.dev; the PR gets a comment with the URL, and the worker is deleted when the PR closes.
@astrojs/cloudflaregotcha: two switches choose the environment and the deploy sets both.CLOUDFLARE_ENV=<env> astro builddecides whichwrangler.toml[env.NAME]block the adapter flattens intodist/server/wrangler.json;wrangler deploy --env <env>decides which block the deploy itself selects, and--nameoverrides the Worker name for a preview. Setting only the first is what once shipped the bare top level, leaving the custom domain and the rate-limit binding in the file and out of the Worker. A mismatched pair fails loudly rather than quietly: wrangler compares the flag with the generated config'stargetEnvironmentand reportsThis does not match the target environment "production".
See CI/CD for the full pipeline.
All BetterStack/GA vars are build-time (import.meta.env, Vite-inlined).
| Variable | Type | Used by |
|---|---|---|
PUBLIC_GOOGLE_ANALYTICS_ID |
build-time | GA (browser) |
PUBLIC_BETTER_STACK_TRACKING_TOKEN |
build-time | Better Stack browser tag (RUM), from the app's Frontend tab |
API_RATE_LIMITER |
runtime binding | rate limiter for every /api/ path but /api/contact
|
CONTACT_RATE_LIMITER |
runtime binding | rate limiter for /api/contact
|
MAINTAINER_EMAIL |
build-time | the verified mailbox a Contact Message is delivered to, a GitHub repository variable |
CONTACT_EMAIL |
runtime binding |
send_email, sending from contact@contribkit.app; see ADR 0030
|
Hit /api/health to verify which vars/bindings the deployed worker has (presence only, never values).
-
Server logs:
loggerwrites one JSON line per 5xx failure and unhandled 500 throughconsole; Cloudflare exports it. -
Worker telemetry: Cloudflare observability (logs + traces, full head sampling), per env in
wrangler.toml. -
Export: Cloudflare ships both logs and traces to Better Stack over OTLP, named as
destinationsinwrangler.toml. - Browser RUM: Better Stack telemetry and GA4, loaded only after cookie consent.
The browser records a Usage Event when a person reaches one point in the product. Each one is a name from a closed set plus properties drawn from closed sets of their own: a Palette key, a Cell Shape, an Export Format, a Year, an outcome. No event carries the Username, a typed value or any free text. The full list, and the one function that sends them, is web/src/ui/components/core/telemetry/usage-event.ts.
| Event | Properties | Fires when |
|---|---|---|
calendar_rendered |
source: form | suggestion | year | history · year: number |
A Contribution Calendar was fetched and drawn |
calendar_render_failed |
reason: invalid_username | invalid_year | not_found | rate_limited | upstream | unreachable | unknown · year: number |
The fetch answered a non-ok status or a body of the wrong shape, or never answered |
palette_chosen |
palette: a Palette key |
A Palette row was picked |
cell_shape_chosen |
cellShape: a Cell Shape |
A Cell Shape button was picked |
export_format_chosen |
format: png | svg | md
|
An Export Format tab was picked |
export_copied |
format: svg | md · outcome: copied | failed
|
The copy button settled |
store_link_opened |
store: play · placement: hero | header | footer
|
A Google Play link was clicked |
section_navigated |
section: how | custom | export | widget
|
A header section link was clicked |
theme_changed |
theme: light | dark | system
|
The colour-scheme toggle was clicked |
contact_message_sent |
outcome: sent | rejected | failed
|
A Contact Message left the form: accepted, refused by the server, or never delivered |
Both GA4 and Better Stack receive every event, and each only once its own consent service (ga4, betterstack, under the analytics category) has been accepted; with neither accepted, nothing is sent. The links carry their event declaratively, as data-usage-event attributes read by one delegated click listener, so a value outside the closed set is ignored rather than forwarded.