- TypeScript 98%
- Gherkin 1.2%
- SCSS 0.4%
- HTML 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
* Explain restriction refusals, and give a status in use a way out Refuse a package save whose member pick count passes the member's own per-order cap (#2275). The pure rule lives in package-membership.ts, and two transaction fences wire it in: the group write judges the submitted quantities, the listing save and both catalog importers judge the stored side. Each refusal names the member and leaves every row unchanged. Offer a status that attendees hold a way out (#2277). The delete page now counts the attendees it holds and requires a target status; one transaction moves them and deletes the status, and the activity log records both. The save fails closed until the owner picks the target. Say why before the save on four remaining surfaces (#2276). The group picker greys out a candidate whose type or customisable-days setting clashes, through the same homogeneity rules the saves consult. Paired controls disable their counterpart with a one-line why, declared once as data. The duration refusal names the parent's offered lengths and the child's priced length. The HTML 403 body says who can open the page. Also: pin the answer-modifier hint that already ships (PR #1310), delete two stale cpd:renamed entries and one equivalent-mutant entry, and ship the approved behavior contract as docs/plan-restriction-refusals.md. Closes #2275. Closes #2276. Closes #2277. * Mirror the mutation gate's direct suites for forms/field and settings-checkbox The gate demands a direct test at each changed source's mirror path. forms/field.ts gained dataAttrs, and settings-checkbox.tsx joined the branch changed-set, so both get their first direct suites: the option guards that keep a form renderable, and the data attributes that declare an either/or pair. * Kill the mutation survivors on the changed template and rule files The branch mutation gate ran the whole changed src set and reported the mutations nothing caught. The gate's mirror rule also required direct suites at each changed source's mirror path, so several new tests land there: - The group overview panel now feeds buildStatDetailRows directly, so the dead attendee-row inputs are gone instead of mutating unseen. - ownerOnlyAudience moves to its natural home next to the other role predicates, and GateAudience names a gate's two audience spellings. - Direct rows pin the cap fences (group write and listing side), the deleted-group refusal, the picker's type clash, the status delete command's three outcomes, the reassign page's picker and error, the pair affordance on rendered forms, and the money sign conventions. - The wrapper-scan survivors that no reachable input can separate are recorded with their proofs in features.txt. * Pin the hidden row, external-cost sign, and check-in label against the render Three survivors came from assertions that a neighbouring row could satisfy: the external-cost test's minus amount also appeared in the net row, the hidden-row check compared against copy that no surface prints, and no row pinned the check-in label the suffix appends to. Each now asserts its own row's exact markup. The reassign command's moved count is recorded: deleteStatus reads it only through a null comparison, so no input can separate zero from one. * Point the contract at the shared home of ownerOnlyAudience * Judge the imported pick count, and keep a re-rendered conflict editable Two review findings on the new affordances: - import-listing.ts validated the fresh listing's group memberships before the membership insert, so the fence saw the fresh-join default of one and a catalog file could persist a pick count above the listing's per-order cap, recreating an unbookable package. The validation now runs after the insert inside the same transaction, matching the group import path, and a regression test pins the refusal. - When the server re-renders a rejected status edit with both sides of a pair ticked, each direction disabled the other and the operator could not correct the conflict without a reload. The start-up hint now skips the disable for a both-ticked pair, and any change resumes the either/or affordance. A client test pins the recovery. * Pluralize the held-count copy, and fold the two homogeneity finds into shared helpers Two maintainability findings from the review: - With one holder, "1 attendees hold this status." read wrong. The message is now plural-aware, and the page and template tests assert the full singular wording, so the substrings "1 attendee" can no longer hide the defect. - firstMismatchOf is now the one home of the comparison both the saver and the picker ask, so the affordance and the refusal cannot drift on the arithmetic either. A test row also hardens the option guard against a later bad option. * Synchronize the pair after every change, so a resolved conflict stays resolved Following the review's trace: once the operator had cleared one side of a both-ticked pair, the cleared flag could be re-ticked and rebuild the invalid pair. updatePair now derives both controls' state from the pair on every change: both ticked keeps both editable, exactly one grants the boundary to that side, and unticking the granted side frees both. The client test asserts the forward exclusivity and the recovery. * Pin the freed side's why holder to stay hidden The branch mutation gate left two survivors on the pair sync: nothing asserted that the counterpart's refusal message stays hidden while one side holds, so flipping the hide arithmetic passed every test. The row now pins it, and the module's mutation score is back to 100%. * Revalidate the duplicated package member's copied pick count Duplicating a package member validated the fresh join at the default pick count of one, then copied the source's larger quantity onto the new membership with no re-check, so the duplicate could commit as an unbookable package. The save now revalidates the membership as persisted, through requireMembershipValidation, the guard the catalog import paths already ran after their membership inserts (now moved next to the validation it judges). The unbookable copy rolls back with the pick-count refusal, pinned by a test that failed before the change and passes after it; the duplicate fixture posts the pre-filled cap the real form carries. * Record the unreachable member-missing message as an equivalent mutant The listingMissing arm of requireMembershipValidation can only fire if a listing vanished from the same transaction that created it, so no input reaches the message and blanking it survives every test. * Remove the built contract for the restriction refusals All three jobs the contract governed now exist in src/ and test/, and the code and tests are the authority. A plan that keeps describing an implementation that already exists is a second source of truth waiting to drift, and this one has nothing left to govern. * Declare an either/or pair once, as the shared ExclusivePair The pair behind the paired-controls script was declared in two vocabularies: the settings checkbox carried a typed ExclusivePair, while the listing form spelled the DOM attributes by hand beside the same facts. The shared field now carries the typed pair, the generic renderer folds it into the data attributes it already emits, and both render paths read the one declaration, so a misspelled attribute stops compiling. The equivalent entry for the old dataAttributeString body went stale in the rewrite; its mutant no longer exists, so the record is deleted rather than re-registered. * Close the six review findings on the restriction refusals Four unifications and two operator-facing gaps from the second review wave: - The either/or pair's generic dataAttrs escape hatch had no production writer left, so it is gone; a declared pair is the one way to spell the attributes, and a misspelled key stops compiling. - The held-attendee count lived twice, once per side of the transaction, so the delete page's displayed count and the delete command's moved count could disagree after a future query change. One query now reads both ways, next to the status writes. - The new status queries now alias their table, like the rest. - The retire choice is one discriminated state built from the same blocker predicate the delete command refuses by: the page names the prerequisite the save will refuse on for a default or last status, and never renders a picker when no target exists to pick. - Form POST refusals now carry the same owner-only detail the GET gates carry, so a non-owner submitting on an owner-only form sees the same explanation in both channels. * Pin the booking-QR panel's markup, and the reassign prompt's preselection The SelectField refactor carried the QR panel's hand-rolled date dropdown onto the shared select, which brought the whole panel into the branch mutation set — where its 26 attributes had no direct coverage at all. A template suite now pins each attribute, the pay-more range, the description's both states, and the copyable result. The reassign picker's empty prompt keeps the selected attribute, so the browser's required check holds it until a real target is picked. * Keep the listing state type inside the membership module Exporting it widened the surface without a reader, and the field policy gate held its unused cap field up instead. The package writes sibling infers the states from the readers' returns, so the type stays module-private. * Mirror the package writes beside their module The branch mutation gate moves each module's direct tests to the matching path, so the guards and cap suites follow the sibling module into its own directory. |
||
| .claude | ||
| .do | ||
| .github | ||
| .opencode/plugins | ||
| cli | ||
| deploy | ||
| docs | ||
| e2e-payments | ||
| scripts | ||
| specs | ||
| src | ||
| test | ||
| .dockerignore | ||
| .envrc | ||
| .gitignore | ||
| .jscpd.css.json | ||
| .jscpd.helpers.json | ||
| .jscpd.json | ||
| .jscpd.specs.json | ||
| .jscpd.support.json | ||
| .jscpd.test.json | ||
| .mise.toml | ||
| .tool-versions | ||
| AGENTS.md | ||
| app.json | ||
| biome.json | ||
| branch.html | ||
| CLAUDE.md | ||
| deno.json | ||
| deno.lock | ||
| Dockerfile | ||
| E2E_TESTS.md | ||
| flake.lock | ||
| flake.nix | ||
| fly.toml | ||
| heroku.yml | ||
| LICENSE | ||
| PLAN.md | ||
| PR_WORKFLOW.md | ||
| README.md | ||
| render.yaml | ||
| REPO_STRUCTURE.md | ||
| setup.sh | ||
| tsconfig.json | ||
Chobble Tickets
Chobble Tickets is a reservation system that runs on Bunny Edge Scripting (or any Deno environment) with libsql, which encrypts all PII at rest and handles free and paid listings with Stripe, Square, or SumUp.
It is developed by Chobble CIC - a community interest company, which means the assets are locked to the community and cannot be sold off.
Website: tickets.chobble.com
This is not "open core" - every feature is available under AGPL-3.0-only with no proprietary add-ons. If you prefer not to host it yourself, I offer hosted instances at tix.chobble.com for £5/month or £50/year.
Deploy on Bunny Edge Scripting
This is the recommended way to deploy it. Fork the repo, connect it to Bunny, and deploy via GitHub Actions - you can pull in upstream changes and push your own customisations on your own schedule.
-
Fork or clone this repository
-
Create a Bunny Database in the Bunny dashboard - note the database URL and token
-
Create a Bunny Edge Script using your repository as the linked source
-
Add secrets to the script in the Bunny dashboard:
Secret Description DB_URLYour Bunny database URL DB_TOKENYour Bunny database auth token DB_ENCRYPTION_KEY32-byte base64-encoded AES-256 key SCHEDULED_TASK_KEYUnique 32-byte base64url maintenance key -
Add GitHub Actions secrets to your repository:
BUNNY_SCRIPT_IDandBUNNY_ACCESS_KEY
Pushes to main trigger the deploy workflow automatically. The database schema
auto-migrates on first request. Visit /setup/ to set your admin password and
currency.
For image uploads, also add STORAGE_ZONE_NAME and STORAGE_ZONE_KEY as Bunny
secrets. Configure an external monitor using the
scheduled maintenance guide. See the
CONFIG_KEYS reference
for all optional variables.
Features
Listings
- Standard listings (fixed capacity) and daily listings (per-date capacity with calendar picker)
- Listing groups for organising related listings together
- Optional listing date and location fields, displayed on the ticket page
- Configurable contact fields: email, phone, postal address (any combination)
- Special instructions field for attendee notes
- Terms and conditions - set globally in settings, attendees must agree before booking
- Capacity limits, max tickets per purchase, registration deadlines
- Multi-listing booking - combine listings in one URL
(
/ticket/listing1+listing2), one form, one checkout - Multi-booking link builder on the dashboard for generating combined-listing URLs
- Listing QR code SVG (
/ticket/:slug/qr) for posters and printed materials - Embeddable via iframe with configurable CSP frame-ancestors
- Custom thank-you URL or default confirmation page
- Non-transferable tickets - per-listing toggle requiring ID verification at check-in
- Listing image and file attachment uploads (encrypted, stored on Bunny CDN)
- Manual attendee creation from the admin listing page (walk-ins, comps)
Payments
- Stripe, Square, and SumUp, with a provider interface so adding others is straightforward
- Enter your API key in admin settings and the webhook endpoint configures itself
- Checkout sessions with metadata, webhook-driven attendee creation
- Configurable booking fee added to each transaction
- "Pay what you want" pricing with optional minimum and maximum
- Deposits / partial payments: take a flat, percentage, or per-item amount up front and track the rest as owed
- Self-service balance payment: attendees get a signed, PII-free
/pay/:tokenlink to pay off what they owe, no login - With no provider configured, paid bookings are still accepted and recorded as a balance owed
- Automatic refund if capacity exceeded after payment or listing price changes during checkout
- Admin-issued full refunds for individual attendees or all attendees in bulk
Check-in
- Each ticket gets a unique URL (
/t/:token) with a QR code - Staff scan QR to reach check-in page, toggle check-in/out
- Built-in QR scanner - open from a listing page, uses device camera, check-in-only (no accidental check-outs)
- ID verification prompt for non-transferable listings before completing check-in
- Cross-listing detection: scanner warns if a ticket belongs to a different listing
- Multi-ticket view for multi-listing bookings (
/t/token1+token2)
Wallet passes
- Apple Wallet: generates
.pkpassfiles with listing details and barcode - Web service API for automatic Apple pass updates (follows the Apple Wallet spec)
- Google Wallet: inline RS256-signed "Add to Google Wallet" save links
- Configurable via admin settings or environment variables
Localisation
- ICU MessageFormat copy with locale picked from the
Accept-Languageheader - Rebrand layer (
I18N_REPLACEMENTS): substring replacements (for exampleticket|booking) that rewrite copy without touching HTML, links, or interpolated values, applied once at load
Admin
- Listing CRUD, duplicate, deactivate/reactivate, delete (requires typing listing name)
- Calendar view for daily listings with per-date attendee counts
- Attendee list with date filtering (daily listings), check-in status filtering
- Attendee editing - update name, contact details, quantity, or reassign to a different listing
- CSV export (respects active filters)
- Per-listing and global activity log (creation, updates, check-ins, exports, refunds, deletions)
- Holiday/blackout date management for daily listings
- Multi-user with graded roles: owners invite managers (full back office) via
time-limited links (7-day expiry), plus a content-only
editor(edits listings/site but holds no data key, so attendee PII stays undecryptable) and a deliveryagent(locked to its own run sheet) - Session management: view active sessions, kill all others
- Attendee merge: deduplicate records with a review-then-apply diff that repoints ledger entries and posts write-off adjustments so balances stay correct
- Contact history per email/phone: last-contacted, visit/booking counts, and an owner-encrypted private note, keyed by a blind HMAC index
- Bulk email: compose/preview/send to filtered attendee groups with reusable subject/body templates (encrypted under the owner key); recipients get one-click unsubscribe / resubscribe / erase links
- Catalog import/export: export any listing or group as portable JSON and re-import it to move a catalog between sites
- Settings: payment provider config, email templates, custom domain, embed host restrictions, terms and conditions, password change
- Privacy tools (
/admin/privacy): plain-language data-minimisation guidance, automatic/manual purging of orphaned attendee records, and GDPR erasure of a contact's recognition record by email or phone - Demo mode (
DEMO_MODE=true): replaces all user-entered text with generated sample data so demos hold no real PII - Branding: custom header image, website title, theme colours
- Built-in admin guide (
/admin/guide) with FAQ for all features - Ntfy error notifications for production monitoring (optional; sends domain + error code only, never PII)
Logistics
- Optional postcode/address lookup on booking and admin forms, via a same-origin proxy that keeps the provider key server-side, caches results encrypted, and rate-limits anonymous use; provider is pluggable
- Drop-off and collection dates per booking (collection on the last booked day)
- Van/agent allocations: assign a drop-off and collection agent per booking, filter the calendar by agent
- Delivery driver login (
agentrole): an auto-generated run sheet of today's and tomorrow's drop-offs and collections with per-leg done toggles, walled off from the rest of admin
Multi-site hosting
- One-click site builder: provision a new tenant on Bunny Edge or Deno Deploy, auto-creating its database and its own encryption key
- Sell sites as products: buying a "site" listing auto-provisions and assigns a
live site to the buyer, with its own renewal tracking and tokenised
/renewflow - Self-updating fleet: each site records its build commit; the host sees which sites are behind and redeploys them from the latest GitHub release
- Secret backfill: detects host secrets a site is missing and fills them in without overwriting deliberately-changed values
Embedding
- Generated
<script>and<iframe>snippets to drop the booking form onto an existing site /order.jswidget turns anydata-add-listinglink on the operator's own site into a live multi-item cart, with per-origin CORS- Iframe-aware checkout (popup vs redirect, hidden header, auto-resize) and a
dynamic, CDN-cached
/custom.cssfor restyling public pages
SMS
- Two-way SMS to attendees via the SMS Gateway for Android relay, with message text and phone numbers encrypted before they leave the server
- Signed inbound webhook ingests delivery receipts and replies, matching inbound messages to the right attendee by phone
Feeds
- ICS calendar feed (
/feeds/listings.ics) for calendar apps - RSS feed (
/feeds/listings.rss) for feed readers - CalDAV operations feed (
/caldav/events.ics) of bookings for staff calendars, filtered per agent so a driver sees only their jobs
Email Notifications
- Automatic confirmation email to attendees and notification email to admins on each registration
- Five HTTP API providers: Resend, Postmark, SendGrid, Mailgun (US/EU)
- Customisable email templates using Liquid syntax (subject, HTML body, text body)
- Built-in template filters:
currency(formats amounts) andpluralize - Configured in admin settings - optional, the system works without it
Contact form
- Optional contact form on the public contact page
- Enabled per-site from Site → Contact — only needs a configured business email
- Submissions are CSRF-protected and emailed to the business address (reply-to set to the sender)
- Botpoison proof-of-work spam protection is a
progressive enhancement: set
BOTPOISON_PUBLIC_KEY/BOTPOISON_SECRET_KEYand submissions must also pass server-side verification
Public JSON API
- RESTful API for listing and booking (
/api/listings,/api/listings/:slug,/api/listings/:slug/availability,/api/listings/:slug/book) - No API key required - it serves the same data as the public booking pages
- CORS-enabled for cross-origin requests
Admin API
- Manage listings, groups, and holidays as JSON, with the same operations as the admin area
- Send an API key with every request:
Authorization: Bearer YOUR_API_KEY. Owners create keys on the API Keys page under Users - Open
/admin/api-keys/docsfor the complete generated reference, with request and response examples for every endpoint - Detailed questions — such as how a group's members are found through
group_ids, or which groups include hidden ones — are answered in the built-in admin guide's Admin API section (/admin/guide)
Webhooks
- Outbound POST on every registration (free or paid) to per-listing and/or global webhook URLs
- Payload: name, email, phone, address, amount, currency, payment ID, ticket URL, per-ticket details
- Multi-listing bookings send one consolidated webhook
Example webhook payload
{
"address": "42 Oak Lane, Bristol, BS1 1AA",
"amount_owed": 0,
"business_email": "hello@example.com",
"currency": "GBP",
"email": "alice@example.com",
"name": "Alice Smith",
"notification_type": "registration.completed",
"payment_id": "pi_3abc123def456",
"phone": "+44 7700 900000",
"price_paid": 3000,
"special_instructions": "Wheelchair access needed",
"ticket_url": "https://tickets.example.com/t/A1B2C3D4E5",
"tickets": [
{
"date": "2025-08-20",
"listing_name": "Summer Workshop",
"listing_slug": "summer-workshop",
"quantity": 2,
"ticket_token": "A1B2C3D4E5",
"unit_price": 1500
}
],
"timestamp": "2025-08-20T14:30:00.000Z"
}
Prices are in the smallest currency unit (for example pence, cents). For
multi-listing bookings the tickets array contains one entry per listing and
the ticket_url combines tokens with + - one webhook, not several.
Encryption
- Hybrid RSA-OAEP + AES-256-GCM for attendee PII (name, email, phone, postal
address) and payment references
- Public key encrypts on submission (no auth needed)
- Private key only available to authenticated admin sessions
- A database dump alone is not sufficient to recover PII - an attacker would also need the encryption key from the environment
- AES-256-GCM for API credentials and selected configuration fields
- PBKDF2 (600k iterations, SHA-256) for password hashing
- Three-layer key hierarchy: env var root key → RSA key pair → per-user wrapped data keys
- The data key is wrapped with each owner, manager, and agent password and each API key. These credentials are never stored, so a database dump plus the environment key still cannot decrypt PII without one of them
- Activity log entries are encrypted with the owner's public key: unauthenticated code (webhooks, error handlers) can write them, but only a logged-in admin can read them
- If every owner, manager, and agent password and every API key is lost, protected data cannot be recovered
Concurrency
- Capacity check + insert in a single SQL statement to reduce the window for overbooking
- Payment webhook idempotency via two-phase locking on
processed_paymentstable - Stale reservation auto-cleanup after 5 minutes
Security
- CSRF: double-submit cookie with 256-bit random tokens, path-scoped, plus an HMAC-signed cookieless fallback so checkout works inside in-app browsers that block cookies
- Rate limiting: 5 failed logins → 15-minute IP lockout (IPs HMAC-hashed before storage), plus separate lockouts on unknown ticket tokens and failed API keys
- SSRF guard: any URL the server fetches on the operator's behalf is forced to
public HTTPS (localhost,
.internal, and raw IPs rejected) - Boot-time config checks: refuses to start with a missing/short encryption key rather than run insecure
- Constant-time password comparison with random delay
- Session tokens hashed before database storage, 24-hour expiry, HttpOnly cookies
- Content-Type validation on all POST endpoints
These measures aim to raise the cost of common attacks. They do not guarantee security against all scenarios - proper operational practices (key management, access control, monitoring) are equally important.
Alternative Deployment
Docker
docker build -t chobble-tickets .
docker run -p 3000:3000 \
-v tickets-data:/data \
-e DB_URL="file:/data/tickets.db" \
-e DB_ENCRYPTION_KEY="your-base64-key" \
chobble-tickets
The Dockerfile uses a local SQLite file by default - set DB_URL and DB_TOKEN
to point at a remote Turso database instead if you prefer.
One-click platforms
Deploy to: DigitalOcean | Heroku | Koyeb | Render
You can also deploy with Fly.io (fly launch) or any Docker
host.
Repository layout
For the current repository layout and path conventions, see
REPO_STRUCTURE.md.
Development
Screenshots
The screenshot runner starts a throwaway local site, completes setup, and fills
it with sample listings and attendees. It captures a 390 by 844px mobile
viewport at 2x device scale, producing retina PNG files under
screenshots/<theme>/.
deno task screenshot:attendees-list
deno task screenshot:listing
deno task screenshot:all
deno task screenshot attendees-list,listing --theme forest
deno task screenshot all --theme all --output ../tickets-site/images/screenshots
deno task screenshot listing-attendees --element 'form[action$="/attendee"]'
deno task screenshot --scenario ../tickets-site/scripts/screenshots/charity-events.js \
--output ../tickets-site/images/screenshots
Themes are applied through the site's custom CSS form. The choices are
default, forest, sunset, and ink; use --theme all to capture every
selected page in every theme. Set CHROMIUM_EXECUTABLE when Chromium is not in
Playwright's normal browser cache. On NixOS, nix develop provides Chromium and
sets this variable for you.
Form scenes such as listing-form and add-attendee-form hide the rest of the
page, trim the image to the visible content, and add 32px of the page background
around it. Use --element with any CSS selector to make an element-only version
of another scene.
External scenario modules own one specific screenshot's data, custom CSS, and
browser steps while reusing this runner's temporary site and high-resolution
capture. Pass their path with --scenario; the scenario's name sets the PNG
filename directly inside --output. A scenario can set its own viewport when a
wider layout is part of the screenshot. Set adminSetup: false when the
scenario needs to capture the site before setup. The runner skips admin setup,
Stripe, and the saved theme, then applies the scenario's CSS after its browser
steps.
Pass --layers with an external scenario to also write transparent background,
controls, and text layers. They are named <scenario-name>__layer-<layer>.png
next to the normal screenshot.
deno task screenshot --scenario ../tickets-site/scripts/screenshots/charity-events.js \
--layers --output ../tickets-site/images/screenshots
Pass --social (one of facebook, instagram-landscape, instagram-portrait,
instagram-square, a comma-separated list, or all) to also write a
social-media-sized copy of each screenshot next to the original. The runner
takes the page background colour and extends one side of the canvas until the
image reaches the target's aspect ratio, then downscales to the target's pixel
size if the result is larger. Variants are named <theme>/<name>__<target>.png
(or <scenario-name>__<target>.png for scenarios). For example,
dashboard__facebook.png is the Facebook share size (1200 by 630px) of the
dashboard screenshot.
deno task screenshot all --social facebook,instagram-portrait
deno task screenshot dashboard --social all
Specification evidence
deno task specs:evidence runs only Cucumber cases that declare evidence. It
runs them one at a time and captures each declared final page before that
scenario's test database is removed. Normal specs and test runs do not start
Chromium. Commit local changes before running it: the task refuses a dirty Git
worktree so the manifest cannot name a commit that differs from the captured
code.
The task writes a versioned reports/evidence/manifest.json and PNG files under
reports/evidence/assets/. The manifest uses the authored story, rule, case,
and capture IDs. It includes the app commit, image hash and dimensions, browser
profile, viewport, and presentation type. Raw Cucumber messages and reports are
not part of this evidence folder. Main pushes and a monthly refresh check the
captures and upload this folder as the stable tickets-evidence artifact with
GitHub's 35-day retention. Nothing checks the captures before a merge, so a
broken capture shows up on main rather than on the branch that caused it. The
Tickets website imports that artifact into a reviewed pull request and keeps its
ordinary site build offline.
# Install Deno, cache dependencies, run all checks
./setup.sh
# Run locally
DB_URL=libsql://your-db.turso.io DB_TOKEN=your-token \
DB_ENCRYPTION_KEY=your-base64-key deno task start
# Or run against a throwaway in-memory database, with no Turso account
DB_URL=:memory: DB_ENCRYPTION_KEY="$(openssl rand -base64 32)" deno task start
# Same, restarting whenever a source file changes. An in-memory database is
# emptied by each restart, so use a file to keep your setup across edits:
# DB_URL=file:./local.db
DB_URL=:memory: DB_ENCRYPTION_KEY="$(openssl rand -base64 32)" deno task dev
# Run tests (stripe-mock downloaded automatically)
deno task test
On first launch, visit /setup/ to set admin credentials and currency. Payment
providers are configured at /admin/settings.
Available tasks
deno task start # Run server
deno task dev # Run server, restarting on source changes (static assets build once, so editing style.scss or bundle inputs needs the task restarted)
deno task test # Run tests
deno task test:coverage # Tests with coverage report
deno task lint # Format Markdown with Deno and code with Biome
deno task lint:ci # Strict read-only lint (what precommit runs everywhere)
deno task typecheck # Type check
deno task build:edge # Build for Bunny Edge
deno task deploy:edge <script-id> # Build, upload, and publish to Bunny Edge using BUNNY_ACCESS_KEY from .env
deno task backup # Dump the database out-of-band (uploads to storage; --out <path> for a local .zip)
deno task restore <backup.zip> # Restore the database at DB_URL from a local backup using DB_TOKEN from .env
deno task snapshot --out <path.sqlite> # Copy the complete remote database to a local SQLite file
deno task migrate:turso # Copy a remote database into a new Turso database
deno task precommit # All checks (typecheck, lint, cpd, build:edge, test:coverage)
Environment variables
| Variable | Required | Description |
|---|---|---|
DB_URL |
Yes | libsql database URL |
DB_TOKEN |
Yes* | Database auth token (*remote databases) |
DB_ENCRYPTION_KEY |
Yes | 32-byte base64-encoded AES-256 key |
Optional:
| Variable | Description |
|---|---|
ADMIN_EMAIL_ADDRESS |
Enables a superuser recovery account and the owner-only Support page (/admin/support). The email local-part (before @) must be a valid username: 2–32 characters, letters, numbers, hyphens, and underscores only. Email delivery must be configured before the superuser can be enabled. |
SUPPORT_PAGE_TEXT |
Optional markdown for the Support page (requires ADMIN_EMAIL_ADDRESS). Use literal \n for line breaks. The form beneath it delivers to ADMIN_EMAIL_ADDRESS and needs a business email, like the contact form. |
SUPPORT_FORM_NAG_DAYS |
Optional positive integer (default 7): how long the Support page shows a "you last submitted this form …" notice after a submission, to discourage duplicates. |
BOTPOISON_PUBLIC_KEY |
Optional Botpoison public key. When set with BOTPOISON_SECRET_KEY, adds proof-of-work spam protection to the contact form (which otherwise works without it). |
BOTPOISON_SECRET_KEY |
Optional Botpoison secret key. Used server-side to verify contact form submissions when Botpoison is enabled. |
UPTIME_KUMA_URL |
Uptime Kuma 2.4 or newer URL for built-site maintenance monitors. Requires CAN_BUILD_SITES=true, UPTIME_KUMA_USERNAME, and UPTIME_KUMA_PASSWORD. A public host must use https. Cleartext http is allowed only for a local network address (loopback, private, CGNAT, link-local, or IPv6 unique-local). |
UPTIME_KUMA_USERNAME |
Uptime Kuma username. Requires UPTIME_KUMA_URL and UPTIME_KUMA_PASSWORD. |
UPTIME_KUMA_PASSWORD |
Uptime Kuma password. Requires UPTIME_KUMA_URL and UPTIME_KUMA_USERNAME. |
UPTIME_KUMA_INTERVAL_MINUTES |
How often built-site monitors run. Any positive whole-minute interval is allowed. Defaults to 15. |
TURSO_API_TOKEN |
Turso platform API key used to create databases. deno task migrate:turso asks for it when it is not set. |
TURSO_ORGANIZATION |
Turso organization used for new databases. The migration task discovers or asks for it when it is not set. |
TURSO_GROUP |
Turso group used for new databases. The migration task discovers or asks for it when it is not set. |
DEBUG_KEY |
Optional diagnostic key. GET /health returns a plain Up :) by default; a request carrying a matching X-Debug-Key header gets a small JSON payload (build commit, build timestamp, server time). Unset ⇒ the verbose response is disabled. |
Database maintenance: pruning of expired sessions, rate-limit rows, payment
records, and optional orphan attendees runs automatically while serving
requests. For quiet sites, configure an external monitor to send an
authenticated POST /scheduled on the schedule you need. Each site needs its
own key. New managed Uptime Kuma monitors default to every 15 minutes. A builder
with Uptime Kuma configured can inspect and add these monitors from each built
site's Scheduled maintenance tab. See the
scheduled maintenance guide for setup and CDN
rules.
Backups: every table is dumped to a single .zip, with table reads
keyset-paginated so no single response trips libsqld's "Response is too large"
payload cap (the server limit behind Bunny's databases). Backups run
out-of-band, not inside the migration: a full dump of a ~31-table schema
cannot fit alongside a migration within one edge request's
50-subrequest budget, so migrations
just migrate, and a backup is taken by GitHub Actions (or deno task backup)
beforehand. To enforce that, /admin/update and the per-site update button
refuse to deploy unless a backup of that database was taken in the last hour.
Restores run out of band because realistic imports exceed Bunny's per-request
subrequest limit. Put the target database's DB_URL, DB_TOKEN, and
DB_ENCRYPTION_KEY in .env, then run deno task restore <backup.zip>. The
task checks the backup, shows its available table, row, statement, and schema
details, and asks for a typed confirmation before deleting any data. It reports
each restore step and shows the backup's recorded commit when one is available.
Local SQLite snapshots: developers can copy a remote database into one
complete SQLite file using the same DB_URL and DB_TOKEN as the app:
mkdir -p .local-data
deno task snapshot --out .local-data/site.sqlite
The task reads DB_URL and DB_TOKEN from .env. Values in that file take
priority over existing shell variables. It then uses libSQL page-level sync,
moves all WAL data into the main file, and checks the finished database before
publishing it. It refuses to replace an existing file. The temporary replica is
created beside the output and removed on success or failure. Local .sqlite
files and .local-data/ are ignored by Git. Run this only on a development
machine; it needs a local filesystem and the native libSQL client. The file
contains the database exactly as stored, including encrypted personal data, but
it does not include environment secrets or files held in Bunny Storage.
Migrate to Turso: run deno task migrate:turso to copy a remote libSQL
database into a new Turso database. The task asks for the source database URL,
its password or token, and the new database name. It uses TURSO_API_TOKEN from
.env or the shell when available. Otherwise, it asks for the API key without
showing it. It uses TURSO_ORGANIZATION and TURSO_GROUP when set. Otherwise,
it discovers the available choices and asks only when there is more than one.
The task checks that the destination name is free before downloading the source.
It creates a verified standalone SQLite snapshot, then streams that file through
Turso's native database upload API. It does not rebuild the database one SQL
statement at a time. If token creation or upload fails, it deletes the
incomplete Turso database. On success it prints the new DB_URL and DB_TOKEN.
Keep using the source DB_ENCRYPTION_KEY; that key is not stored in the
database file.
The deploy workflows back a site up (via POST /instance/site-credentials)
before deploying to it (the staging push-to-main trigger is the one exception
— see below):
.github/workflows/backup.yml(manual) — backs up the main instance's own database withDB_URL/DB_TOKEN/STORAGE_ZONE_NAME/STORAGE_ZONE_KEYrepository secrets..github/workflows/deploy-clients.yml(manual) — upgrades the built client sites that accept the published tier. It asks the main instance for the DB credentials of the sites on that tier (each site's own full-access token — the workflow only reads, to take backups), backs each up to the builder's storage, then deploys. Thetierrun input (alphadefault, thenbetaorrelease) is the release channel being published: each site carries its ownupdateschannel and only the sites at the deploy's tier or more eager are returned — areleasedeploy reaches every site,betareaches beta + alpha sites,alphaonly alpha sites. The default isalpha(the smallest blast radius), so you ship a risky build to alpha sites first and reaching the whole fleet is the deliberatereleasechoice. For a canary (alpha/beta) the workflow fails closed unless the main instance echoes the tier back in its response, so running it against a master that predates tier filtering (which would ignore the filter and return the whole fleet) cannot silently deploy to everyone — upgrade the master first. It needs theMAIN_INSTANCEURL secret plusSTORAGE_ZONE_*andBUNNY_ACCESS_KEY; CDN-enabled builds additionally need the five optional CDN secrets below. TheMAIN_INSTANCE_KEYthat authorizes the credentials endpoint is pasted in as a run input each time, never stored (set the same value as the main instance'sMAIN_INSTANCE_KEYenv).BUNNY_SCRIPT_DATAis no longer needed — the script ids come from the endpoint..github/workflows/bunny-deploy.yml(staging) and.github/workflows/production-deploy.yml— same flow narrowed to the single site whose script id matchesBUNNY_STAGING_SCRIPT_ID/BUNNY_SCRIPT_ID(the deploy fails unless exactly one fleet site matches), via the sharedbackup-siteaction. They takeMAIN_INSTANCE_KEYas a run input but fall back to the stored secret. Only manual runs back up: the staging push-to-maintrigger (a merge) deploys without a backup, so it needs noMAIN_INSTANCE_KEY/MAIN_INSTANCE/STORAGE_ZONE_*secrets — justBUNNY_STAGING_SCRIPT_IDandBUNNY_ACCESS_KEY, plus the optional CDN secrets when CDN publishing is enabled. A manual run backs up by default (needing those backup secrets, or the key pasted in) and can untick Back up the database before deploying to skip it — the escape hatch for when the backup itself is broken.
Optional static CDN builds: set all five repository secrets CDN_URL,
CDN_BUNNY_STORAGE_ZONE_NAME, CDN_BUNNY_STORAGE_ZONE_KEY,
CDN_BUNNY_STORAGE_HOST, and CDN_BUNNY_PULL_ZONE_ID to publish
site-independent browser bundles and image-codec WASM to a dedicated Bunny
Storage/Pull Zone during the build. CDN_BUNNY_STORAGE_HOST is the Storage API
hostname shown on the zone's Bunny Storage Access page, such as
storage.bunnycdn.com or uk.storage.bunnycdn.com; it has no default because
it must match the zone's primary region. The existing BUNNY_ACCESS_KEY secret
purges that pull zone after every successful upload. Each upload carries its
SHA-256 checksum, and the build then fetches every public URL and verifies its
bytes and media type before emitting a script that depends on it. The generated
script contains immutable, content-addressed CDN URLs and the matching CSP
origin; these values are not runtime Bunny script secrets. Configure the pull
zone's browser cache override for a long lifetime because these paths change
whenever their content or media type changes. Site-bound assets such as the
embed bootstrap and dynamic order widget remain in each script. If all five CDN
secrets are absent, the build remains fully self-contained. A partial
configuration fails the build. Historical restore builds intentionally receive
none of these secrets and therefore retain their embedded assets.
Tune BACKUP_PAGE_SIZE (default 500 rows per read) via env if a single page
ever approaches the payload cap.
Restoring to a point in time: the running build records the git commit it
was built from into its own settings table (current_script_commit, written
on boot; cleared if a build ships without one, so it never goes stale), so every
dump carries the commit the site was running when the backup was taken.
Restoring a backup rolls back the data and surfaces that commit (the full
SHA); to roll the code back to match, run the
.github/workflows/restore-deploy.yml workflow with that commit and the
target script id. It builds the edge bundle fresh from our repository at that
commit (git is the per-commit source of truth) and deploys it — so a restore
only ever redeploys a commit from our own history, never code carried inside an
(untrusted) uploaded backup file. For safety the workflow validates the commit
is a full SHA and deploys via the external Bunny action pinned to an immutable
SHA, so the production API key is never handed to a deploy wrapper loaded from
the checked-out (possibly old) commit. (The rebuilt old code reports the rebuild
time as its build version, so the in-app updater may not surface the latest
release afterwards — redeploy latest via the normal deploy workflow to return to
it.)
See the CONFIG_KEYS reference for all optional variables (email providers, Apple Wallet, image uploads, and more).
Tech Stack
- Runtime: Deno - runs standalone, via Docker, or on Bunny Edge Scripting
- Database: libsql (local SQLite or remote Turso)
- Payments: Stripe, Square, SumUp
- Build: esbuild, single-file output
- Templates: Server-rendered JSX
- Crypto: Web Crypto API (AES-256-GCM, RSA-OAEP, PBKDF2)
License
AGPL-3.0-only - developed by Chobble CIC, a community interest company.