The most original developer conference in Prague is back.
DevFest.cz 2026 is a community-built conference and festival for developers, geeks, and tech enthusiasts — AI agents, generative UI, cloud & backends, mobile & web accessibility, and the humans behind it — happening October 30, 2026 at Uhelný Mlýn in Libčice nad Vltavou, Czech Republic.
- Framework: Astro 7
- Language: TypeScript (strict mode)
- Styling: Sass — the design system is documented in docs/design.md
- UI: React 19 (interactive islands)
- Backend: Firebase
- Node: 22 (see
.nvmrc;engine-strictis on)
docs/design.md is the binding visual system — check it before styling anything.
| File | What it is |
|---|---|
| README.md | Setup, every function, secrets and the console-only steps (this file) |
| CLAUDE.md | Working rules for contributors and coding agents: commands, definition of done, the repo map |
| docs/adr/ | Architecture decision records: why the site is built the way it is |
| docs/design.md | The design system |
| docs/product.md | The product record: users, purpose, brand commitments |
.claude/rules/ |
Per-domain notes (tickets, lineup, invoices, workshops, analytics, design) |
# Install dependencies (site + functions)
npm ci && npm --prefix functions ci
# Start development server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Accessibility audit (mock-data build + axe)
npm run a11y
# Everything CI checks: astro check, cognitive complexity, function tests, axe
npm run verify2026 is the production branch. A merge into it deploys the site
(firebase-hosting-merge.yml) and, when functions/** or firebase.json
changed, the functions (firebase-functions-merge.yml). Every PR gets a
preview channel, devfest-public--pr-<n>-*.web.app, which calls the
production functions.
npm run dev has no Hosting rewrite table, so it serves /api/* from the fixtures in scripts/a11y-mocks/api.mjs (otherwise the
lineup, agenda and ticket sections render "unavailable"). npm run a11y uses
the same fixtures.
DEVFEST_LIVE_API=1 npm run dev skips the fixtures and hits the deployed
functions — use it when changing the functions.
The ticket section renders client-side from an RTDB cache, read through /api/tickets — never the Firebase SDK (see CLAUDE.md). The static build never calls ti.to.
Cloud Scheduler (every 5 min, Europe/Prague)
└─> Cloud Function `refreshTicketsScheduled` (europe-west1)
├─ fetch https://api.tito.io/v3/<acc>/<evt>/releases
└─ write RTDB /tickets = { releases, accountSlug, eventSlug, fetchedAt }
Browser
└─> Tickets.tsx (client:load)
└─ fetch /api/tickets ─→ Hosting rewrite ─→ Cloud Function `ticketsApi` (europe-west1)
└─ read RTDB /tickets via Admin SDK (cached, 5-min edge TTL)
The Blaze plan is required for scheduled functions and Secret Manager.
Shared Firebase project.
devfest-cz-appalso hosts the mobile app's functions and site. This repo declares"codebase": "website"and only ever deploysfunctions:website; the app repo uses codebaseappand different function names. Rules are split by collection and merged by hand. See ADR 0002.
# Install function deps
npm --prefix functions install
# Set secrets (one-time each)
firebase functions:secrets:set TITO_API_TOKEN # ti.to admin API
firebase functions:secrets:set TITO_WEBHOOK_SECRET # ti.to webhook security token
firebase functions:secrets:set SLACK_WEBHOOK_URL # Slack incoming-webhook URL
# Set ti.to slugs as non-secret params (functions/.env)
echo 'TITO_ACCOUNT_SLUG=your-account' >> functions/.env
echo 'TITO_EVENT_SLUG=your-event' >> functions/.env
# Deploy: happens on merge to 2026 (firebase-functions-merge.yml).
# A manual deploy is the maintainer's, and always scoped:
firebase deploy --only functions:websiteThe workflow deploys without --force: a function removed from the code makes the deploy fail instead of deleting it. Delete it by hand (firebase functions:delete <name> --region europe-west1), then re-run the workflow. A rotated secret reaches the functions only on the next deploy: re-run the workflow from the Actions tab.
The default Cloud Functions service account has the IAM needed to write RTDB; no extra service-account JSON is required at runtime.
| Name | Trigger | Purpose |
|---|---|---|
refreshTicketsScheduled |
Cloud Scheduler, every 5 min | Sync ti.to releases → RTDB /tickets |
ticketsApi |
HTTPS, public (/api/tickets) |
Serve the cached /tickets roadmap as JSON for the browser to fetch() (5-min edge TTL) |
ticketsWebhook |
HTTPS, public | Verifies Tito-Signature, drops redeliveries (RTDB webhookDedup) and posts purchase notifications to Slack |
dailyTicketStatusScheduled |
Cloud Scheduler, daily 09:00 Europe/Prague |
Fetches live releases from ti.to and posts a sales summary to Slack |
Wire up the webhook in ti.to → Customize → Webhook Endpoints:
- Paste the deployed
ticketsWebhookURL. - Copy ti.to's security token into
TITO_WEBHOOK_SECRET(Secret Manager). - Subscribe to
registration.finished— that event fires once per completed order and already lists every ticket in the registration, so subscribing toticket.completedas well would double-post.
The RTDB ruleset is project-global, so it lives in the app repository (ryzizub/devfest_mobile_app, database.rules.json) and deploys from its CI. Change it with a pull request there, never in the console.
/tickets is read only by ticketsApi (Admin SDK), so tickets.".read": true is not required. Root default and all writes stay false. /workshops, /webhookDedup and /ops (function health) are server-only and covered by the root deny. The cache omits raw counts (quantity / quantity_sold / tickets_count) and ships a coarse has_sales boolean — see tito-api.ts::projectRelease.
reCAPTCHA Enterprise in src/lib/firebase.ts, committed public key
APPCHECK_SITE_KEY. Inits on page load. Only gated surface:
submitInvoiceCallable (enforceAppCheck: true). Do not enforce on
ticketsWebhook (HMAC, external caller) or the schedulers.
Setup:
- Register the key in Firebase App Check. GCP console (project
devfest-cz-app) → Security → reCAPTCHA holds the score-based website key (6Ld…WChra); adddevfest.czand any preview domains to its allowed domains. Then Firebase console → App Check → Apps: register the web app and point it at that reCAPTCHA Enterprise key. (Per-environment override: setPUBLIC_FIREBASE_APPCHECK_SITE_KEYin.envto use a different key ID.) - Local dev. Set
PUBLIC_FIREBASE_APPCHECK_DEBUG_TOKEN=truein.env, load the site, copy the debug token from the console, and register it under App Check → Apps → Manage debug tokens. Leave this empty in production. - Watch metrics. App Check → APIs shows the verified-vs-unverified split for Cloud Functions; confirm real invoice submissions are verified.
Only secret releases are dropped (isWebsiteVisible, again client-side). Every other state renders a badge in Tickets.tsx; sale_status is synthesised in tito-api.ts::deriveSaleStatus.
/speakers and /agenda render client-side from Firestore, mirrored every 15 minutes from Sessionize by a Cloud Function. Browsers never touch Sessionize; they read the cached /api/lineup endpoint, not the Firebase SDK.
Cloud Scheduler (every 15 minutes)
└─> Cloud Function `refreshSessionizeScheduled` (europe-west1)
├─ fetch Sessionize "All data" JSON view (SESSIONIZE_ENDPOINT_ID)
├─ fetch its "Speakers" view too: hosts + keynote, missing from All, join the roster
├─ mirror speaker photos → Firebase Storage `speakers/{id}` (idempotent)
└─ write Firestore `speakers` + `sessions` (cross-referenced) + `rooms`, atomic batches
Browser
└─> Speakers.tsx / Sessions.tsx (client:load)
└─ fetch /api/lineup ─→ Hosting rewrite ─→ Cloud Function `lineupApi` (europe-west1)
└─ read Firestore speakers+sessions+rooms via Admin SDK (cached, 15-min edge TTL)
Share pages. /talks/<id> and /speakers/<id> are static pages built from the production /api/lineup at astro build (src/lib/lineup-static.ts; set DEVFEST_LINEUP_URL to build against another copy). They carry the talk or speaker as text and their own preview, are indexed, and forward a browser to the sheet. A failed read builds none; 404.astro forwards links to anything newer. See ADR 0006.
Readers without script. The same build also reads /api/tickets and /api/workshops (src/lib/static-api.ts; DEVFEST_TICKETS_URL / DEVFEST_WORKSHOPS_URL override) and renders the lineup, the programme, the open workshops and the prices as plain HTML under the islands (.no-js, hidden once html.js is set), the Event JSON-LD offers, and /llms.txt. All of it is as fresh as the last Hosting deploy. See ADR 0008.
| Name | Trigger | Purpose |
|---|---|---|
refreshSessionizeScheduled |
Cloud Scheduler, every 15 minutes | Sync Sessionize → Storage photos + Firestore speakers/sessions/rooms |
lineupApi |
HTTPS, public (/api/lineup) |
Serve { speakers, sessions, rooms } as JSON for the browser to fetch() (15-min edge TTL) |
speakerPhotoApi |
HTTPS, public (/api/speaker-photo?id=) |
Serve one speaker's photo same-origin so /speaker-card can export its canvas (1-day edge TTL) |
firebase functions:secrets:set SESSIONIZE_ENDPOINT_ID # Sessionize JSON API endpoint id (or full URL)Must be a JSON API endpoint ("All data" / "Speakers" view; an embed id returns HTML). Photos are mirrored to Firebase Storage. Requires the Firestore database.
/workshops lets ticket holders save a seat at a workshop and join a waitlist when it is full. See ADR 0005.
RTDB /workshops/{sessionId}/limit ← set by hand in the console; opens the workshop
RTDB /workshops/{sessionId}/registrations ← written by the callable; first `limit` by time hold a seat
Browser /workshops (Workshops.tsx, client:load)
├─ fetch /api/workshops ─→ `workshopsApi` (seat counts only, 30-s edge TTL)
├─ sign in with the ticket code: the mobile app's
│ verifyRegistrationCodeCallable → emailed code → completeEmailVerificationCallable → custom token
└─ workshopRegistrationCallable (status / join / leave, App Check enforced)
| Name | Trigger | Purpose |
|---|---|---|
workshopsApi |
HTTPS, public (/api/workshops) |
Seats taken and waiting per open workshop, no names |
workshopRegistrationCallable |
Callable (App Check enforced) | Join or leave a workshop; trusts only a verified ticket on the app's Firestore users/{uid} |
Firebase console → Realtime Database → workshops/<Sessionize session id>/limit = number of seats. The page lists the workshop from then on. Raising the limit seats the waitlist in order; deleting it closes sign-up. The sign-in callables and users collection belong to the mobile app repo: keep their names in step.
ti.to only takes cards; some companies must pay by bank transfer against an invoice. /invoice lets them request one; once paid, we mint a 100%-off ti.to code so they can claim the tickets.
Browser /invoice (InvoiceForm, client:load)
└─> submitInvoiceCallable (callable, validates) → Firestore invoices/{id} (status: pending)
Firestore onCreate
└─> processInvoiceTrigger (europe-west1)
├─ claim pending → processing (transaction; a redelivered event is a no-op)
├─ ti.to: read company-funded release → price (CZK, no FX)
├─ iDoklad: find/create contact → create issued invoice → email it (PDF attached)
└─ invoices/{id} = { status: invoiced, idokladInvoiceId, variableSymbol, … }
Cloud Scheduler (hourly) — iDoklad has NO webhooks
└─> pollPaidInvoicesScheduled (europe-west1)
├─ for each `invoiced` doc → GET iDoklad PaymentStatus
└─ if paid:
├─ ti.to: create 100%-off discount_code scoped to company-funded releases
├─ email the code (Resend, optional) + post to Slack
└─ invoices/{id} = { status: completed, discountCode, discountLink }
The browser never touches Firestore — it calls submitInvoiceCallable, so invoices stays server-only and input is validated before reaching iDoklad.
| Name | Trigger | Purpose |
|---|---|---|
submitInvoiceCallable |
Callable (App Check enforced) | Validate the form (honeypot), apply the global + per-company throttles, write invoices/{id} |
processInvoiceTrigger |
Firestore onCreate invoices/{id} |
Create the iDoklad contact + issued invoice and email it |
pollPaidInvoicesScheduled |
Cloud Scheduler, hourly | Check unpaid invoices' iDoklad PaymentStatus; on paid, mint + deliver the 100%-off ti.to code |
iDoklad has no webhooks — payment is polled hourly, so a paid invoice is claimed up to ~1 h later.
invoiceRateLimits(the per-company throttle) writes anexpiresAttimestamp. Set a Firestore TTL policy on that collection with fieldexpiresAt(console → Firestore → TTL) so spent windows are deleted; the code does not depend on it, the collection just grows without it.
Abuse limits. App Check is enforced but tokens are not single-use:
consumeAppCheckTokenneeds the Firebase App Check Token Verifier role on the functions' runtime service account and answers 401 to every submit without it. On top: a global ceiling of 10 requests per hour across all companies (GLOBAL_LIMIT_MAXinsubmit.ts— every request mints an iDoklad invoice and an email) and 3 per hour per IČO, whatever the email. Hitting either returnsresource-exhausted; the form tells the visitor to retry in an hour.
Existing iDoklad contacts are read, never written. An IČO is public data, so the form must not be able to rewrite a customer's stored email or address. When an IČO already exists in iDoklad the contact is reused as-is, the invoice carries its stored details, and the mail goes to the submitted address only (
SendToPartner: false). The Slack line lists which submitted fields differ from the stored record (contactDifferson the doc) so a human decides whether the company moved or a stranger typed someone else's IČO.
All credentials are secrets (Secret Manager) — set each once:
firebase functions:secrets:set IDOKLAD_CLIENT_ID # iDoklad → Settings → API
firebase functions:secrets:set IDOKLAD_CLIENT_SECRET
firebase functions:secrets:set RESEND_API_KEY # discount-code email (Slack fallback if empty)Plus the tickets-domain secrets TITO_API_TOKEN and SLACK_WEBHOOK_URL, and the string params TITO_ACCOUNT_SLUG / TITO_EVENT_SLUG (functions/.env).
Everything else is a code constant in functions/src/invoice/params.ts (no env, nothing to set): INVOICE_RELEASE_MATCH (company funded), INVOICE_VAT_RATE (21), INVOICE_DUE_DAYS (14), INVOICE_FROM_EMAIL (devfest@gug.cz), INVOICE_FROM_NAME. Change them there and redeploy.
The invoice price is taken automatically from the active ti.to release whose title contains INVOICE_RELEASE_MATCH — there is no manual price anywhere.
- iDoklad OAuth: iDoklad → Settings → API → client credentials into the secrets above. Token endpoint and lifetime:
.claude/rules/invoices.md. - Invoice email is sent by iDoklad (PDF attached, pay by bank transfer). If it fails, the invoice number goes to Slack for manual relay. Copy:
buildInvoiceEmailinfunctions/src/invoice/email.ts. - Discount-code email: branded HTML from
email-template.ts. Preview by buildingfunctions/and renderingbuildDiscountEmail(...).html. - Invoice fields are seeded from
GET /IssuedInvoices/Default; contactCountryIdfromGET /Contacts/Default(free-text country stored, not mapped; foreign companies handled manually). - ti.to needs release(s) whose title contains
INVOICE_RELEASE_MATCH(company funded); the public (non-secret) one on sale prices the invoice, and the paid code is scoped to the release the invoice was priced from. When a wave closes, keep its company-funded release on sale but secret until its unpaid invoices are redeemed. - Frontend:
submitInvoiceCallableviagetFunctions(app, 'europe-west1')→httpsCallable. App Check enforced; for local dev setPUBLIC_FIREBASE_APPCHECK_DEBUG_TOKENand register the token.
One-time setup: create a Firestore database (Native mode). Backs
invoicesand the Sessionize sync.
The Firestore ruleset is project-global, so it lives in the app repository (ryzizub/devfest_mobile_app, firestore.rules) and deploys from its CI. It denies all client access to invoices. Change it with a pull request there, never in the console.
/tv is the unlisted page for the TVs at the venue (noindex, out of the sitemap, linked from nowhere). /tv?room=<room id or name slug> pins a screen to one room (the talk on now, up next with a countdown, what the other rooms are showing); plain /tv shows every room as a now/next board. During a break (a service session titled as a break, coffee, lunch or registration) both lead with the next talk and show the break as a small tag by the heading; a kick-off, lightning talks, closing remarks or the afterparty lead like a talk, and the other rooms' screens say which room it is in. A credits band under both names GUG.cz as the organiser and loops through the partners (the homepage set, from src/content/partners.json, three logos a page). A TV hung portrait gets the same type sizes with the zones stacked: the hero over the rail, and on the board each room as one block; &at=10:15 fakes the clock on the event day for checking a screen early. The agenda comes from /api/lineup, polled every 2 minutes.
Firebase Analytics, measurement ID G-L5NK2S2EZ0, in Google Consent Mode. Architecture and gotchas: .claude/rules/analytics.md. This section is the console-side setup, not in the repo.
- Mark conversions as key events. GA4 → Admin → Events:
ticket_purchase_confirmed,sign_up(newsletter),generate_lead(company invoice).begin_checkoutis a recommended ecommerce event and needs no marking; pair it withticket_purchase_confirmedfor checkout drop-off. - Register invitation parameters as custom dimensions. GA4 → Admin → Custom definitions → two event-scoped dimensions:
invite_member(theteam.jsonid) andinvite_member_name. GA4 does not report on unregistered custom parameters, and registration is not retroactive — do it before the links go out. - No revenue in reports. ti.to's thank-you redirect carries no order id or amount, so
ticket_purchase_confirmedisn't GA4'spurchaseand has no value. Revenue lives in ti.to.begin_checkoutandgenerate_leadcarryvalue(gross CZK), so intent is measurable. - Consent Mode is the source of truth — no second GA4 tag, no GTM container. Basic mode: nothing loads before the visitor accepts, so GA4 counts consenting visitors only. Behavioral modeling (which would fill in the rest) needs ≥1000 consenting and ≥1000 non-consenting visitors a day — out of reach, so Reporting identity stays "Blended" with modeling inactive.
- Development traffic is excluded in code, not by a GA4 filter: measurement is limited to
devfest.cz(and subdomains) plusdevfest-public.web.app/devfest-public.firebaseapp.com.npm run devand preview channels send nothing. To measure a preview, build withPUBLIC_ANALYTICS_ALLOWED_HOSTS=<host>. - Verifying a change needs a real host: GA4 DebugView, or devtools Network filtered to
/g/collect. - Performance Monitoring rides the same consent and host gates (Firebase console → Performance; first data shows up after ~12 hours). It needs the Firebase Installations API enabled for
devfest-cz-app; if the browser API key is ever restricted, allow Firebase Installations, Firebase Remote Config and Firebase Performance (firebaselogging) on it. Verify in devtools Network:firebaseinstallations.googleapis.comthenfirebaselogging.googleapis.comafter an accept.
| Route | Description |
|---|---|
/ |
Landing page with countdown and newsletter signup |
/speakers |
Speaker lineup (reads /api/lineup); ?speaker=<id> opens one speaker |
/speakers/<id>, /talks/<id> |
Share pages: one speaker or talk as text with its own link preview, then forward to the sheet; indexed |
/llms.txt |
The site as Markdown for language models: facts, tickets, pages, every talk and speaker (built) |
/agenda |
Conference-day timetable — room grid on wide screens, time-ordered list on a phone or a single-room day — and under it (#talks) every talk with search and filters (reads /api/lineup). /sessions redirects here, ?talk= included |
/workshops |
Workshop seat sign-up and waitlist for ticket holders (reads /api/workshops) |
/invoice |
Request a company invoice to buy tickets by bank transfer |
/partners |
Sponsors & partners |
/press/downloads |
Press kit and downloadable assets (/press redirects to the press band on /contact) |
/team |
Organizing team |
/contact |
Contact desks, plus press coverage and the press-kit link (#press) |
/faq |
Frequently asked questions |
/tv |
Unlisted venue-screen page: a room's now/next, or the whole venue (reads /api/lineup) |
/venue |
Getting to Uhelný Mlýn — train from Prague, walk from the station |
/attending |
"I'm attending" share-card generator (client-side canvas → PNG) |
/code-of-conduct |
Code of Conduct one-pager, Listener's contact, link to the full text |
/privacy-policy |
GDPR privacy policy, website and mobile app |
/delete-account |
How to delete a mobile app account (the Google Play "Delete account URL"; keep the route) |
/newsletter-subscription-thank-you |
Post-signup confirmation |
/thank-you |
Post-purchase confirmation (ti.to "thank you URL") |
/404 |
Not-found page; also forwards share links to talks or speakers added since the last build |
Unlisted pages (noindex, out of the sitemap, linked from nowhere): /invite/<member> (one per src/content/team.json entry) and /speaker-card.
public/.well-known/apple-app-site-association decides which devfest.cz links open the iOS app: only /sessions, /agenda, /game/* and /login/*. Add a path only when the app has a route for it.
- Website: devfest.cz
- Last year's edition: 2025.devfest.cz
