This document gets you from a clean laptop to a working local copy of Proscenium, the Nottingham New Theatre's public website and box office.
Everything below is written from what is actually in the repository at the time of writing. Where the code contradicts itself or a documented command does not exist, that is called out explicitly rather than smoothed over: you will hit these, and it is faster to know in advance.
| Thing | What it is here |
|---|---|
| Framework | Nuxt 4 (nuxt ^4.3.1), Vue 3, Nuxt UI 4 |
| Server runtime | Nitro with the cloudflare_module preset: the server runs as a Cloudflare Worker, not Node |
| Database | SQLite. Locally a file; in production Cloudflare D1 (Cloudflare's managed SQLite) |
| ORM / migrations | Drizzle ORM + Drizzle Kit, wrapped by NuxtHub's hub:db layer |
| File storage | Cloudflare R2 (S3-like object storage) for show posters and venue images, via NuxtHub's blob layer |
| Auth | nuxt-auth-utils (sealed cookie sessions) + nuxt-authorization (ability checks) |
| Resend | |
| Marketing pages | @nuxt/content v3, Markdown in content/, stored in the same database |
| Deployment | NuxtHub module config + Wrangler to Cloudflare Workers (see docs/08-operations.md) |
Repository: https://github.com/newtheatre/proscenium
app/ Nuxt app (pages, components, layouts, middleware, composables)
content/ Markdown for the static marketing pages
server/api/ REST endpoints (shows, venues, reservations, bookings, users, admin)
server/db/schema/ Drizzle table definitions: the source of truth for the schema
server/db/migrations/sqlite/ Generated SQL migrations (do not hand-edit)
server/routes/images/[...pathname].get.ts Serves R2 blobs at /images/**
server/tasks/ Nitro tasks: currently just seed
server/utils/ Email, auth tokens, image upload, query helpers, validation
shared/utils/abilities/ Authorisation rules shared between client and server
public/ Static assets served as-is
| Tool | Version | Why |
|---|---|---|
| Bun | Latest stable | The only lockfile in the repo is bun.lock. Use Bun so you resolve the same dependency tree everyone else has. |
| Node.js | 20 LTS or newer | Some tooling (Wrangler, Drizzle Kit, esbuild) still shells out to Node. Bun alone is usually fine, but having Node installed avoids surprises. |
| Git | Any recent | Cloning. |
| A Cloudflare account | - | Only needed for production work (deploys, migrations against production, logs). You do not need one to run locally. |
Caveat:
package.jsonhas nopackageManagerfield, so nothing enforces Bun or pins its version. The presence ofbun.lockis the only signal. If you usenpm installorpnpm installyou will generate a second lockfile and a different dependency tree: don't. Adding"packageManager": "bun@<version>"topackage.jsonwould be a worthwhile five-minute fix.
Install Bun:
curl -fsSL https://bun.sh/install | bashgit clone https://github.com/newtheatre/proscenium.git
cd proscenium
bun installbun install runs the postinstall script, which is nuxt prepare. That generates the .nuxt/ directory: TypeScript types, auto-import declarations, the Drizzle config that the database CLI reads, and .nuxt/eslint.config.mjs, which the root eslint.config.mjs imports. If .nuxt/ is missing, both linting and the database CLI will fail. Re-run bunx nuxt prepare if you ever delete it.
There is no .env.example in the repository, so nothing tells you what to set. Create a .env in the project root (it is gitignored). This is the full list of variables the code actually reads.
| Variable | Read from | Required? | What breaks without it |
|---|---|---|---|
NUXT_SESSION_PASSWORD |
Dev: .env → runtimeConfig.session.password. Production: the Secrets Store SESSION_PASSWORD binding, written into the same place by server/plugins/0.secrets-store.ts |
Yes in production. Auto-generated in dev | The nnt-session cookie cannot be unsealed, so nobody is logged in anywhere. This is the estate-wide seal shared with every *.newtheatre.org.uk app, in production it is never set on this worker, only bound from the store (ADR-0016), so never generate your own. Must be at least 32 characters. In dev, nuxt-auth-utils generates a random one and appends it to your .env on first run. |
NUXT_AUTH_SERVICE_TOKEN |
runtimeConfig.authServiceToken |
Yes in production | Every path that needs a shadow account fails closed with a 502 "Booking is temporarily unavailable": guest checkout, staff walk-ins, issuing a pass, creating a mirror user. It is also the token whose SHA-256 authenticates the inbound GDPR hooks, so erasure and subject-access requests from the auth service stop working. |
NUXT_TRAINING_API_TOKEN |
runtimeConfig.trainingApiToken, read in server/utils/eligibility.ts |
No, but set it in production | Nothing breaks and nothing errors, which is the danger: with no token the eligibility seam takes the same path as an outage, so every shift claim is allowed and flagged for review (ADR-0026). Fine in development, quietly useless in production. Pair it with NUXT_TRAINING_API_BASE_URL if training ever moves. Also gates training mode, in the opposite direction: with no token nobody can open a practice sandbox (ADR-0033). A token that goes away mid-lesson cannot end a run already open: a missing token reads as UNREACHABLE, and only a definitive CLOSED ends a run and deletes its events (ADR-0034). |
NUXT_BOOKING_TOKEN_SECRET |
runtimeConfig.bookingTokenSecret |
No, but set it in production | Falls back to NUXT_SESSION_PASSWORD. That works, but it signs guest booking links with the estate-wide seal, so rotating the seal (the emergency estate logout lever) also invalidates every booking link already sitting in customers' inboxes, and any other estate app holding the seal could mint booking tokens. |
NUXT_RESEND_API_KEY |
runtimeConfig.resendApiKey, read in server/utils/resend.ts |
No, but email is dead without it | No email at all: confirmations, cancellations, reminders. The client is constructed lazily and logs [Email] No Resend API key configured; email sending is disabled. rather than throwing, so the site stays up. The bare RESEND_API_KEY is still read as a fallback for older deployments; prefer the NUXT_ form. |
NUXT_RESEND_FROM_EMAIL |
runtimeConfig.resendFromEmail, read in server/utils/email.ts |
No | Falls back to the hard-coded no-reply@tickets.newtheatre.org.uk. If that address is not verified in Resend, every send fails. |
NUXT_PUBLIC_BASE_URL |
runtimeConfig.public.baseURL, defaulted to https://newtheatre.org.uk |
No | The canonical site URL used by @nuxtjs/seo and by every link in every email. Wrong value here means confirmation and booking links point at the wrong host. |
NUXT_PUBLIC_AUTH_BASE_URL |
runtimeConfig.public.authBaseURL, defaulted to https://auth.newtheatre.org.uk |
No | Where login, account and session-refresh links point. Only override it if you are running a local auth service. |
| Variable | Read from | Required? | What breaks without it |
|---|---|---|---|
NUXT_HUB_CLOUDFLARE_ACCOUNT_ID |
@nuxthub/core database CLI (nuxt db …) |
Only when running database commands against production D1 | bunx nuxt db migrate against production throws: "D1 CLI commands require Cloudflare API credentials." Local development is unaffected. |
NUXT_HUB_CLOUDFLARE_DATABASE_ID |
Same | Same. In this repo the production database id is also hard-coded in nuxt.config.ts, so the env var is usually redundant |
As above. |
NUXT_HUB_CLOUDFLARE_API_TOKEN |
Same | Same, and there is no hard-coded fallback for this one | As above. Needs a Cloudflare API token with D1 edit permission. |
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID |
Wrangler | Only for deploys, wrangler tail, wrangler d1 … |
Wrangler falls back to an interactive browser login, which is fine for a human but not for automation. |
TURSO_DATABASE_URL + TURSO_AUTH_TOKEN |
@nuxthub/core database resolution |
No: do not set these | If both are present, NuxtHub silently switches your local database to a remote Turso instance instead of the local SQLite file. Mentioned only so you recognise the symptom. |
# Optional; without it email is disabled and logs a warning: the site still runs
NUXT_RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
# Optional; sender address must be verified in Resend
NUXT_RESEND_FROM_EMAIL=no-reply@tickets.newtheatre.org.uk
# Only needed if you want guest checkout, walk-ins or passes to work locally,
# since all of them call the auth service for a shadow account. Ask the ITM.
NUXT_AUTH_SERVICE_TOKEN=nnt_svc_xxxxxxxxxxxx
# nuxt-auth-utils will append NUXT_SESSION_PASSWORD here on first `bun run dev`You can start with an empty .env. Nothing is required for the site to boot: email degrades to a
logged warning, and /dev-login?staff=admin seals a local session without any auth service running
(see §9).
server/utils/resend.ts constructs the Resend client lazily, inside getResend():
const key = useRuntimeConfig().resendApiKey || process.env.RESEND_API_KEY
if (!key) {
console.warn('[Email] No Resend API key configured; email sending is disabled.')
return null
}So the canonical name is NUXT_RESEND_API_KEY (which populates runtimeConfig.resendApiKey),
with the bare RESEND_API_KEY kept as a fallback for deployments configured before the rename.
Set the NUXT_ form on anything new.
A missing key disables email and logs that warning; it does not take the site down. This used to
be a module-load throw, which on a Worker meant a missing key returned an error for every request
on the site, including pages with nothing to do with email.
When email is misbehaving:
- Nothing arriving at all? Check
wrangler tailfor[Email] No Resend API key configured: that is a missing secret, not a Resend problem. - Sends failing? Check the Resend dashboard: key still valid, sending domain still verified,
messages not bouncing or rate-limited. A failed send logs
[Email] Failed to send email:. - Wrong sender? With
NUXT_RESEND_FROM_EMAILunset the code falls back tono-reply@tickets.newtheatre.org.uk, which must be verified in Resend. - Links wrong? They are built from
runtimeConfig.public.baseURL(NUXT_PUBLIC_BASE_URL).
Identity lives in the central auth service (stage-door), and this app only ever reads the sealed
nnt-session cookie. You do not need the auth service running to develop.
GET /dev-login seals a local session for you: the single sanctioned exception to "apps never
write the session", guarded by import.meta.dev so it does not exist in a production build:
| URL | Session you get |
|---|---|
/dev-login |
An ordinary logged-in customer |
/dev-login?staff=box-office |
proscenium:BOX_OFFICE |
/dev-login?staff=manager |
proscenium:MANAGER |
/dev-login?staff=admin |
proscenium:ADMIN |
/dev-login?staff=foh-manager |
proscenium:FOH_MANAGER |
/dev-login?staff=front-of-house |
proscenium:FRONT_OF_HOUSE |
front-of-house is the one worth using deliberately: /foh is scoped by the rota, so that persona
sees only what it is confirmed on, and seed rosters it on the door of a performance tonight so
there is something to see (ADR-0019).
The client middleware sends logged-out visitors here in dev and to the hosted login in production.
Two things that will confuse you otherwise:
- Roles are namespaced. The session carries
proscenium:ADMIN, notADMIN, because one estate session holds every app's roles. Compare withhasRole/isStafffromshared/utils/abilities/types.ts, never against a bare string. - Staff sessions go stale after 15 minutes and lose their roles until refreshed. In dev the
middleware skips the refresh bounce, but the server-side rule still applies: if staff-only API
calls start 403-ing after a while, hit
/dev-login?staff=…again.
bun run devThen open http://localhost:3000. Nuxt DevTools is enabled (devtools: { enabled: true }), the floating toolbar at the bottom of the page is how you reach the seed task later.
What happens on the first run, so nothing surprises you:
- NuxtHub creates a local data directory
.data/(gitignored):.data/db/sqlite.db: your local database, driven by@libsql/client. Not D1; D1 only exists in production..data/blob/: local stand-in for the R2 bucket. Uploaded posters land here.
- A NuxtHub dev-only Nitro plugin applies any pending migrations to that SQLite file automatically. You do not run a migrate command in development.
nuxt-auth-utilsgeneratesNUXT_SESSION_PASSWORDand appends it to.env.- The database is empty. Nothing is on sale, and there are no users. Seed it: §9.
| Command | What it does |
|---|---|
bun run dev |
Development server with HMR on port 3000 |
bun run build |
Production build into .output/ (Cloudflare Worker bundle) |
bun run preview |
Runs the built output through Wrangler locally |
bun run generate |
Do not use. NuxtHub aborts the process: this app needs a server |
bun run postinstall |
nuxt prepare; runs automatically after install |
| Path | Purpose |
|---|---|
server/db/schema/*.ts |
Drizzle table definitions: user.ts, venue.ts, show.ts, ticket.ts, reservation.ts. This is the source of truth. |
server/db/migrations/sqlite/*.sql |
Generated SQL, applied in numeric order. Currently 0000 through 0016. Never hand-edit an already-applied file. |
server/db/migrations/sqlite/meta/ |
Drizzle's snapshots and _journal.json. Committed; generated. Do not edit. |
_hub_migrations (table, in the database) |
Ledger of which migrations have been applied. Both NuxtHub and Wrangler use this same table name. |
Server code never imports these files directly: it imports db and schema from the virtual modules @nuxthub/db / hub:db:schema, which NuxtHub generates from the schema directory during nuxt prepare.
@nuxthub/core ships a nuxt-db binary that the Nuxt CLI exposes as the db subcommand. Both spellings work:
| Command | What it does |
|---|---|
bunx nuxt db generate |
Diffs server/db/schema/*.ts against the last snapshot and writes a new numbered .sql migration. Runs nuxt prepare first. |
bunx nuxt db migrate |
Applies pending migrations to the currently-configured database and records them in _hub_migrations. |
bunx nuxt db mark-as-migrated |
Records migrations as applied without running them. For adopting an existing database. Dangerous; know why you are using it. |
bunx nuxt db drop <table> |
Drops one table. |
bunx nuxt db drop-all |
Drops every table. Prompts you to type confirm. |
bunx nuxt db squash |
Collapses the migration history into one file. |
bunx nuxt db sql "<query>" |
Runs an ad-hoc SQL query. |
That is the complete list in @nuxthub/core@0.10.6. Anything else you read about does not exist here.
- Edit the relevant file in
server/db/schema/. bunx nuxt db generate: inspect the generated.sqlbefore committing it. SQLite cannot alter most constraints in place, so Drizzle rebuilds tables: you will seePRAGMA foreign_keys=OFF, a__new_*table, a copyingINSERT … SELECT, aDROP TABLE, and aRENAME. Migration0008_left_ikaris.sqlis a good example. Read the copyingINSERTcarefully, any column missing from it silently loses its data.- Restart
bun run dev; the dev plugin applies the migration locally. - Commit the schema change, the
.sqlfile, and themeta/snapshot together. Splitting them across commits corrupts the migration history for everyone else. - Applying it to production is a deliberate, separate step: see
docs/08-operations.md.
Different from development, and worth understanding:
- In development, a NuxtHub Nitro plugin applies migrations on server start.
- With the D1 driver, NuxtHub explicitly disables build-time migrations.
bun run buildwill not migrate anything. - At build time, the migration
.sqlfiles are copied into.output/server/db/migrations/, and the generated Wrangler config is annotated withmigrations_dirandmigrations_table: _hub_migrations. - Applying them in production is therefore a manual step, either
nuxt db migrateover the D1 HTTP API orwrangler d1 migrations apply. Both routes use the same ledger table, so they interoperate. Procedure and safety notes:docs/08-operations.md§5.
The seed lives in server/tasks/seed.ts (task name seed, which Nitro derives from the file path, not from meta.name) with per-entity modules in server/tasks/seed/. It relies on Nitro's experimental tasks feature, enabled in nuxt.config.ts via nitro.experimental.tasks.
With bun run dev running, choose either:
- Nuxt DevTools → Tasks tab →
seed(whatserver/tasks/seed/README.mdrecommends), or curl -X POST http://localhost:3000/_nitro/tasks/seed
The task is guarded: if the users table has any rows it logs "Database already has users. Skipping seed." and does nothing. To re-seed you must empty the database first.
Seed order (dependency order: keep it if you add to it): users → venue features → venues → ticket types → shows and performances → reservations.
All use the password DevPassword123!:
| Roles | |
|---|---|
admin@newtheatre.org.uk |
ADMIN, MANAGER, BOX_OFFICE |
manager@newtheatre.org.uk |
MANAGER |
boxoffice@newtheatre.org.uk |
BOX_OFFICE |
user@newtheatre.org.uk |
none (ordinary customer) |
unverified@newtheatre.org.uk |
none, email unverified: for testing the verification flow |
These are development fixtures. They must never exist in production; docs/08-operations.md covers creating real staff accounts.
server/tasks/seed/README.md gives two different reset commands:
- Under "Running Seeds":
bunx nuxt db drop-all - Under "Troubleshooting": "Reset with
bunx nuxt db push --forcefirst", andserver/tasks/seed.tsprints the same advice at runtime.
nuxt db push does not exist. The db command in @nuxthub/core@0.10.6 has exactly seven subcommands (§8), and push is not one of them; it is Drizzle Kit vocabulary that leaked into the docs. Running it gives you an unknown-command error. drop-all is the correct command of the two.
Use whichever suits:
| Method | Command | Notes |
|---|---|---|
| Recommended for local | Stop the dev server, then rm -rf .data/db, then bun run dev |
Deletes the local SQLite file outright. The dev plugin recreates it and re-applies every migration from scratch. Fastest, and cannot half-succeed. |
| Drop tables in place | bunx nuxt db drop-all (type confirm at the prompt), then restart the dev server |
Works against whichever database is configured: including production if your environment points there. Check twice before running. |
| Nuclear | rm -rf .data |
Also clears local blob storage, so uploaded posters go too. |
Someone should correct the two stale references in server/tasks/seed/README.md and the console.log in server/tasks/seed.ts.
| Command | What it does |
|---|---|
bun run lint |
Lint everything |
bun run lint:fix |
Lint and auto-fix (most stylistic rules are fixable) |
bun run typecheck |
vue-tsc over the whole project |
bunx eslint app/pages/admin/users.vue |
Lint one file |
eslint.config.mjs imports ./.nuxt/eslint.config.mjs, so nuxt prepare must have run first
or ESLint fails on a missing import. bun install runs it via postinstall.
There are none yet. CI (.github/workflows/ci.yml) runs build,
typecheck and lint on every pull request, and those are hard gates, but nothing exercises
behaviour. Any change you make is verified by running it.
Be correspondingly careful with the money paths (POST /api/bookings,
PUT /api/reservations/:id/tickets, POST /api/reservations/:id/refund) and test them manually
against seeded data before deploying. Adding a test runner is tracked in
09-known-issues.
These are imported by first-party code but do not appear in package.json. They currently resolve only because Bun hoists them as transitive dependencies of other packages. A dependency bump elsewhere can remove them and break the build without any change to our code.
| Package | Imported by |
|---|---|
nanoid |
All five files in server/db/schema/: every primary key uses it |
zod |
Every server endpoint (import { z } from 'zod/v4') and twelve modal components in app/components/. Resolves today via the transitive zod@3.25.76, which exposes the zod/v4 subpath |
scule |
app/pages/admin/users.vue, app/pages/account/index.vue (upperFirst) |
@tanstack/table-core |
Five admin table pages: pagination and row types for Nuxt UI's table |
Fix: bun add nanoid zod scule @tanstack/table-core, pinning the versions currently resolved in bun.lock.
drizzle-kit, better-sqlite3, @libsql/client, eslint and typescript are all in dependencies rather than devDependencies. Nothing breaks (the Nitro build tree-shakes the Worker bundle) but it makes the production dependency set misleading, slows installs, and makes it harder to reason about what actually ships. wrangler is (correctly) the only entry in devDependencies.
Two nuances before you move them:
@libsql/clientis genuinely required for local development (NuxtHub uses it for the local SQLite file) so it belongs indevDependencies, not deleted.better-sqlite3has no import anywhere in the codebase. It looks like a leftover; check before removing, but it is a strong candidate.drizzle-ormmust stay independencies: it is imported by runtime server code.
bun install- Create
.env. It can be empty; addNUXT_RESEND_API_KEYonly if you want email to send bun run dev- Confirm
NUXT_SESSION_PASSWORDhas appeared in.env - Open DevTools → Tasks → run
seed - Visit http://localhost:3000 and log in as
admin@newtheatre.org.uk/DevPassword123! - Check
/adminand/admin/box-officeboth load bunx eslint .: should be clean before you start changing things
Next: docs/08-operations.md for deployment, migrations against production, backups and incident handling.