Release model
- Pull requests must pass two independent gates: GitHub Actions runs
npm ci+npm test, while Cloudflare Workers Builds uploads a non-production preview version - Production auto-deploys from
mainvia Cloudflare Workers Builds; a manualwrangler deployis a break-glass path, not the normal release workflow - No build step; worker and static assets are deployed as-is
- Custom domain:
pub.ihnyc-rc.org - Admin UI at
/admin/*behind Cloudflare Access (Zero Trust)
Prerequisites
- Node.js v18+
- Wrangler CLI (
npm install -g wrangler && wrangler login) - Access to the Cloudflare account that owns the worker
Secrets and variables
Required secrets
Set once per environment via wrangler secret put <NAME>:
| Secret | Purpose |
|---|---|
PUB_ADMIN_TOKEN | Admin API auth token (also gates Short.io QR sync). Renamed from TENDER_QR_SYNC_TOKEN; the worker reads PUB_ADMIN_TOKEN. |
NOW_PLAYING_INGEST_TOKEN | Bearer token for Pi song-ID pipeline |
CF_ACCESS_POLICY_API_TOKEN | Used by the Access page to update the dedicated delegated-operator policy |
Optional secrets
| Secret | Purpose |
|---|---|
SHORT_IO_API_KEY | Short.io API for tender QR code short links |
NOTION_API_KEY | Retained only while a remaining Pub-owned content entity reads from Notion. Not an events credential |
ALERT_NOTIFY_TO | Recipient for TV-offline and warm-fridge alerts. Personally identifying, so it is a secret rather than a var. Alerting is inert until it is set |
| Provider and relay credentials | Optional integrations for Short.io, email, Q-SYS, lighting, and TV control |
Machine-integration secrets
| Secret | Purpose |
|---|---|
CALENDAR_PUB_TV_READ_CREDENTIAL | Reads the Calendar Pub TV event projection. Calendar holds the same value as PUB_TV_CALENDAR_READ_CREDENTIAL |
| Closing-ingest bearer | Authenticates the WhatsApp ingest Worker posting the nightly closing report |
HOUSE_SYSTEMS_SUMMARY_CREDENTIAL | Legacy bounded snapshot only while the final House Systems consumer is audited and retired |
Each is dedicated. Reusing an admin, ingest, email, or Access-policy credential in another lane is refused rather than tolerated.
Pub deploys through Cloudflare's Git integration
Configure these in the Worker dashboard. Forcing a CLI deploy to set a secret works against the release model.
Non-secret vars in wrangler.toml
| Var | Purpose |
|---|---|
TV_BUILD_ID | Bump on every user-facing change; triggers TV self-reload |
PUBLIC_SITE_URL | https://pub.ihnyc-rc.org |
CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD | JWT issuer and audience used for every delegated Pub operator |
| Access policy identifiers | Dedicated delegated-operator policy bridge; public identifiers, never the API token |
CALENDAR_PUB_TV_BASE_URL | Calendar origin serving the Pub TV event projection |
CALENDAR_PUB_TV_ENVIRONMENT | Exactly local, staging, or production; must match Calendar |
TV_STATE_PUSH_ENABLED | Canary switch for the TV-state WebSocket push. false rejects upgrades and returns clients to the legacy cadence |
TAKEOVER_VIDEO_ENABLED | Emergency decoder kill switch for takeover video. Images continue when false |
TV_QSYS_NOTICE_ENABLED | Whether the bar TVs show the “systems reloading” strip while the Q-SYS Core is unreachable |
TV_PAIRING_STRICT, TV_PAIRING_AUTO_MIGRATE | TV pairing behavior. Moved from secrets to vars so they are auditable and survive deploys |
RESEND_FROM | Verified sender for alert email |
NOTION_ANNOUNCEMENTS_DATABASE_ID, NOTION_PUB_TENDERS_DATABASE_ID | Vestigial; these entities read from D1 regardless |
SHORT_IO_DOMAIN | Domain for Short.io QR links |
SETLIST_LEAD_SURFACE_ENABLED | Feature flag for Top Songs surface |
SETLIST_VIBE_COMPASS_ENABLED | Feature flag for Vibe Compass |
SETLIST_PERSONA_ENABLED | Feature flag for Persona AI surface |
SETLIST_STORIES_ENABLED | Feature flag for Stories AI surface |
SPORTS_ENABLED | Live sports schedule (keyless ESPN) → /api/sports/live + cron refresh of the sports KV cache |
NOW_SHOWING_ENABLED | ”Now Showing” fusion (/api/now-showing) — fuses the Pi content verdict with the ESPN schedule to name a live game |
SCREENINGS_ENABLED | Movie-night auto “Now Showing” + /screen archive. Values: off | shadow | on |
FEEDBACK_ENABLED | Patron feedback / suggestion box (/feedback + /api/feedback); admin read at /admin/feedback is always on |
PUB_SCHEDULER_V2 | Pub-scheduler run state-machine engine. Dormant — unset until the v2 cutover; flipping it activates the new transition engine |
Bindings in wrangler.toml
| Binding | Type | Purpose |
|---|---|---|
CONTENT_DB | D1 | Pub scheduler data (tenders, shifts, assignments, submissions, audit) |
SETLIST_DB | D1 | Song-ID data, setlist analytics, theme presets, API health |
IMAGE_MIRROR_BUCKET | R2 | Image mirror for TV/announcement/tender images |
EVENT_MEDIA | R2 | Event and takeover slideshow media |
DJ_VISUALS | R2 | DJ Mode visual media |
TUTORIALS_DATA | KV | Admin-edited tutorial content blobs |
DJ_MODE, DJ_FLASH, LYRICS | KV | DJ Mode state, flash queue, and cached lyrics |
CONTENT_VERSION | KV | Per-entity content version keys read by the version endpoints |
TV_PAIRING | Durable Object | TvPairingCoordinator for slot state and the authoritative TV state push |
SCAN_ANALYTICS | Analytics Engine | Tender QR scan analytics. Not provided by wrangler dev; writes are null-guarded |
MEDIA, IMAGES | Stream, Images | Visual media processing |
AI | Workers AI | Persona/Stories AI inference and the parked stock interpreter |
D1 migrations
Migrations are plain SQL files applied manually. Two databases:
CONTENT_DB (pub scheduler)
wrangler d1 execute CONTENT_DB --remote --file=./migrations/content/<file>.sqlRecent additions cover the closing-report subsystem and TV takeovers: closing reports (0041), stock catalog expansion (0042), stock addendum receipts (0054), stock report dismissals (0055), closing export photo dedupe (0056), stock groups (0057), closing messages and their media (0058 and 0059), Resend webhook events (0060), TV takeovers (0061), and takeover event scheduling (0062). Earlier ones add email-log attachments (0052), the fairness-report setting (0053), tender holds (0040), and unsubscribes (0051).
SETLIST_DB (song-ID, analytics, theme, API health)
wrangler d1 execute SETLIST_DB --remote --file=./migrations/<file>.sqlMigrations 0053 through 0067 add quiet-hours state, TV control, scoped Pub roles, delegated Access sync, Q-SYS control discovery and curation, Spotify Jam scheduling, DMX command results, audio snapshots, the permission split (0063), touchscreen events (0064), House shadow evidence (0065), DJ flash media retention (0066), and DJ video requests (0067). Two different 0058 files exist; use the repository’s recorded migration order rather than assuming the number is unique.
Always apply migrations before deploying code that depends on new tables/columns. Check CLAUDE.md in the repo for the full ordered migration list.
Deploy
Production deploys automatically when a change lands on main via Cloudflare Workers Builds. Before merge, the GitHub Actions test check and Cloudflare preview build must both pass. GitHub Actions is test-only and has no deploy credentials; Cloudflare owns preview uploads and the production deploy.
Because main can go live immediately after merge, required D1 migrations must either be applied before merging or remain backward-compatible with both the old and new Worker. The canonical release, verification, and rollback procedure lives in the Pub repository’s docs/DEPLOYMENT.md.
Bump TV_BUILD_ID in wrangler.toml (in the same change) if TV-facing assets changed; TVs self-reload within 60 seconds when it changes.
A manual wrangler deploy is reserved for break-glass/one-off situations and must be recorded in the PR or incident — merging a green PR to main is the normal path.
Local development
# Create .dev.vars with local secrets
echo "PUB_ADMIN_TOKEN=<token>" > .dev.vars
# Start local dev server
wrangler devLocal dev serves at http://localhost:8787. Workers AI (env.AI) is not available in local mode; use wrangler dev --remote for AI surface testing (bills Neurons).
Monitoring
| Signal | Where to look |
|---|---|
| Worker errors | Cloudflare Dashboard > Workers & Pages > Logs |
| TV health | /admin/tv-health — live heartbeat, build match, memory, JS errors per screen |
| API dependencies | /admin/api-health — Notion, LRCLib, Short.io, Deezer, Formbricks, Workers AI, ESPN (keyless live-sports scoreboard), TMDB (movie posters, keyless Deezer fallback) |
| Scheduler state | /admin/pub-schedule — current week’s wizard step and submission feed |
| Email delivery | /admin/pub-setup > Email Log |
| Song-ID pipeline | ssh pi@<pi-tailnet-hostname> 'systemctl is-active ptp4l-eth0 aes67-daemon pi-capture' |
| Setlist rollups | Check /api/setlist/<surface> for fresh computedAt timestamps |
TV build version
TV screens self-reload when TV_BUILD_ID changes. Bump it on every deploy that touches public/tv/, public/bar-tv-right/, or any TV-facing asset:
TV_BUILD_ID = "YYYY-MM-DD-NN" # e.g. 2026-06-07-01The worker injects this into TV HTML as window.__TV_BUILD_ID__. TVs poll /api/tv-build every 60 seconds and reload when the value changes.
Cloudflare Access setup
The admin UI is gated by a Pub-specific Cloudflare Access application. Its audience remains stable when Authentik is added upstream; Authentik supplies verified identity through Access and does not replace Pub roles.
- Full administrators remain on the standing full-admin policy and hold
*. - Delegated operators are kept in a dedicated email-only policy managed from
/admin/access. - The Worker verifies the Access JWT, then reads scoped permissions from
pub_admin_roles. The current catalog has 88 exact keys at version 4. - Removing an operator revokes Pub permissions and then removes the delegated Access entry.
Add a delegated operator in /admin/access, not in Authentik groups, House Systems, Resident Council tooling, or by hand-editing the main Access application policy. The bridge touches only the configured email-only Pub delegated operators policy. If policy and D1 role synchronization diverge, the Access page shows the pending retry.
Troubleshooting
TVs show “Some TVs need attention”
The hub shows amber only for genuinely missing TVs (no heartbeat for 30+ minutes). If a TV is borderline stale (5-30 min), the full TV Health page shows details. Common causes:
- TV went to sleep or lost network
- Cloudflare Workers had a brief outage
- TV browser crashed (check JS errors on the health card)
If the slot is an intentionally parked QA device, open /admin/slots and turn off Monitor for that slot. Its real state remains visible, but it no longer contributes to warning counts or offline-alert email.
Scheduler email not sending
- Check the This Week wizard — is Step 4 (Send) active?
- Confirm all shifts are Approved (Draft shifts block sending)
- Check
/admin/pub-setup> Email Log for send failures - Verify
PUB_ADMIN_TOKENis set as a Cloudflare secret
Setlist surfaces show stale data
The cron dispatcher picks ONE due surface per tick. If a surface hasn’t updated:
- Check its
surface_config.enabled = 1in SETLIST_DB - Check its cadence (e.g., Persona/Stories run weekly, Top Songs runs hourly)
- Check
/admin/api-health— Deezer degraded blocks Vibe Compass enrichment
Tutorial edits not saving
- Verify
TUTORIALS_DATAKV namespace is bound inwrangler.toml - Check the Access session and the operator’s
tutorials.editpermission - Preview a history version before restoring it
- Check browser console for 401/403/503 errors
Audio controls are missing or locked
- Confirm the Pi and Q-SYS Core are reachable on the System page.
- Refresh discovery on
/admin/audio. - Confirm the control is writable and included in an operator group.
- Check the group and control lock state.
- Capture a snapshot before changing several controls.
A sound-stage mode button is refused
The quiet curfew holds loud modes back. The refusal names the curfew window and says which setting to change: if the pub is open later than the curfew assumes, adjust tonight’s window in Quiet Mode rather than fighting the button. Blocked attempts and overrides are both recorded in the audit log with their reason.
Events are missing from the bar TV
Events come from the Calendar service now, not Notion.
- Confirm the event is published in the Calendar admin and carries the Pub display flag. See Event review and publication.
- Confirm its location contains the word
Pub; that filter is unchanged. - Check whether the projection response was rejected. The consumer refuses a whole response on any validation failure and serves the last known good payload, so stale-but-correct events are the expected symptom rather than an empty board.
- Confirm
CALENDAR_PUB_TV_ENVIRONMENTmatches Calendar’s value and the read credential is present.
A closing report did not appear
- Check whether the message was held rather than parsed. A message the parser cannot read is retained for review and an operator can overrule the classifier.
- Confirm the night. A report filed after midnight is credited to the previous business day, with a 06:00 venue-time cutoff.
- Confirm the night actually had a closing shift. Nights that owed no report are not flagged.
- If a warm-fridge alert did not arrive, confirm
ALERT_NOTIFY_TOis set. Alerting is inert without it.
A takeover will not start or stop
- A start is refused while the media list has no ready and enabled item.
- Activation is a compare-and-set. A stale revision means someone else edited the takeover; reload and retry.
- An active or scheduled takeover cannot be edited or deleted. Stop it first.
- Stop is idempotent and unconfirmed, so clicking it twice is safe.
- If video is not playing but images are, check
TAKEOVER_VIDEO_ENABLED.
Song-ID pipeline (Pi-side)
The pipeline runs on a Raspberry Pi reached through its approved private tailnet hostname. Three systemd services:
| Service | What |
|---|---|
ptp4l-eth0.service | PTP slave syncing to Q-SYS Core |
aes67-daemon.service | RAVENNA kernel module + ALSA capture |
pi-capture.service | Fingerprint + POST to worker |
All enabled on boot with dependency ordering. See pi-capture-ops for the full runbook.