System Architecture Overview
Muse & Co runs on three web portals backed by Cloudflare infrastructure. This page describes how the pieces fit together.
The Three Portals
| Portal | URL | Purpose |
|---|---|---|
| Customer | ncmuse.co | Public-facing site. Event listings, bookings, Sit and Paint sign-ups, drink menu, gift cards. |
| Admin / Staff | staff.ncmuse.co | Internal portal. Event management, check-in, quotes, orders, inventory, label printing. |
| Wiki | wiki.ncmuse.co | This site. Employee documentation and procedures. |
Architecture Diagram
Customers / Staff / Employees
|
v
+-----------------------+
| Cloudflare CDN/DNS |
+-----------+-----------+
|
+----------------+----------------+
| | |
v v v
+-----------+ +-----------+ +-----------+
| ncmuse.co | | staff. | | wiki. |
| (Customer)| | ncmuse.co | | ncmuse.co |
+-----------+ | (Admin) | | (Wiki) |
| CF Pages | +-----------+ +-----------+
| + Functions| | CF Pages | | CF Pages |
+-----+-----+ | + Functions| | VitePress |
| +-----+-----+ +-----------+
| |
+--------+--------+
|
+------------+------------+
| | |
v v v
+---------+ +---------+ +---------+
| D1 | | R2 | | Stripe |
| (SQLite)| | (Files) | |(Payments|
+---------+ +---------+ +---------+
|
| +---------+ +------------------+
| | AWS SES | | Brother QL-820NWB|
| | (Email) | | (Label Printer) |
| +---------+ +------------------+
| |
| CF Tunnel via
| local network
+---------------------------+Technology Stack
| Layer | Technology | Notes |
|---|---|---|
| Frontend | React + TypeScript + Vite | Customer and Admin portals |
| Styling | Tailwind CSS | Utility-first CSS framework |
| Static Site | VitePress | Wiki only |
| Hosting | Cloudflare Pages | All three portals |
| API Routes | Cloudflare Pages Functions | Serverless functions co-deployed with each portal |
| Database | Cloudflare D1 | SQLite at the edge, shared across portals |
| File Storage | Cloudflare R2 | Images, uploaded assets |
| Payments | Stripe | Event tickets, bookings, gift cards |
| AWS SES | Order confirmations, booking receipts, notifications | |
| Label Printing | Brother QL-820NWB | Drink order labels, printed from Admin portal |
How the Pieces Connect
Cloudflare Pages + Functions
Each portal is a Cloudflare Pages project. The customer and admin portals include a functions/ directory that defines serverless API routes. When a request comes in to /api/..., Cloudflare routes it to the corresponding Pages Function instead of serving a static file.
INFO
Pages Functions have access to Cloudflare bindings (D1, R2, KV, etc.) through the function's context.env object. This is how the API routes talk to the database and file storage.
Cloudflare D1
D1 is Cloudflare's edge SQLite database. All application data lives here:
- Event listings and schedules
- Customer accounts and bookings
- Orders and payment records
- Inventory tracking
- Staff and admin data
Both the customer and admin portals share the same D1 database. This means data entered through the admin portal (like new events) is immediately available on the customer site.
Cloudflare R2
R2 stores uploaded files -- event images, artwork photos, and other assets. The admin portal uploads files to R2, and the customer portal serves them.
Stripe
Stripe handles all payment processing:
- Event ticket purchases (Paint & Boba, workshops, classes)
- Private event booking deposits
- Gift card purchases
Stripe webhooks notify the application when payments succeed, fail, or are refunded. The webhook endpoint runs as a Pages Function.
AWS SES
Transactional email (order confirmations, booking receipts, password resets) is sent through AWS Simple Email Service. SES is called from Pages Functions when an email needs to go out.
Business Phone (phone.ncmuse.co)
The business phone engine is a separate Cloudflare Worker. When a call comes in it asks the customer portal who to ring, then rings everyone on the list at once; the first person to press 1 gets the caller.
GET https://ncmuse.co/api/phone/on-shift (muse-customer/functions/api/phone/on-shift.js)
- Auth:
Authorization: Bearer <PHONE_SHIFT_API_KEY>. The same value is the engine'sSHIFT_API_KEY. No secret configured returns 500; a wrong or missing token returns 401. Server-to-server only: no CORS,Cache-Control: no-store. - Who is on the list: everyone clocked in right now, plus every active owner (
ADMIN) and manager (MANAGER) whether or not they are clocked in. A shift left open for more than 16 hours counts as a forgotten clock-out and stops ringing. Deactivated or separated accounts and kiosk accounts are never included. - Order: clocked-in staff, then managers, then owners. Duplicate phone numbers are removed and the list is capped at 8.
- Phone numbers come from the staff record (
staff.phone, matched by email) and are sent as+1XXXXXXXXXX. Anyone without a valid US number is left off, so an owner with no phone on file will not ring. - Response:
{ "people": [{ "name": "Elena", "phone": "+19105550123", "role": "staff" }], "generated_at": "..." }. Only first name, phone and role are returned, and only counts are logged.
Staff menu endpoints
When a staff member calls the business line from their own phone, the engine offers a staff menu. Three more endpoints back it, plus a staff directory. They use the same PHONE_SHIFT_API_KEY bearer, the same 500/401 behaviour, Cache-Control: no-store, no CORS of their own, and log only counts and staff ids, never phone numbers or PINs. Shared code: muse-customer/functions/lib/phone-api.js.
GET /api/phone/staff?phone=%2B19105551234 (functions/api/phone/staff/index.js): who is calling?
200 { "staff": { "id": "<staff_users.id>", "first_name": "Elena", "role": "owner|manager|staff", "has_pin": true } }404 { "error": "not_staff" }for an unknown number or an inactive, separated or kiosk account;400 { "error": "invalid_phone" }.- Both the caller ID and every
staff.phoneare normalized to+1XXXXXXXXXXbefore matching. If two or more active people share a number the answer is409 { "error": "ambiguous" }; the endpoint never guesses. has_pinis true whenstaff_users.pin_hashis set.
POST /api/phone/staff/{id}/verify-pin, body { "pin": "1234" } (functions/api/phone/staff/[id]/verify-pin.js)
200 { "ok": true },401 { "ok": false, "attempts_left": n },423 { "ok": false, "locked_until": "<ISO>" },409 { "error": "no_pin" },404 { "error": "not_staff" },400 { "error": "invalid_pin" }(not 4–8 digits).- One PIN, one lockout. It checks
staff_users.pin_hashwith the sameverifyPassword(PBKDF2-SHA256, constant-time) the kiosk PIN login uses, and counts failures with the same shared helpers (@muse/security/pin-lockout): 5 wrong PINs lock it, for 15 min, then 1 h, 4 h, 24 h and 7 days on repeat lockouts. A lockout from the phone also locks the kiosk login, and the reverse. The attempt that reaches the limit returns 423. A correct PIN clears the counter; it is not a login, solast_loginis not changed. - Setting the PIN. Every active, non-kiosk staff member sets their own PIN in the staff portal under My Account → Phone & kiosk PIN (
muse-admin/functions/api/staff/me/pin.ts):GET /api/staff/me/pin→{ has_pin, created_at, locked_until }PUT /api/staff/me/pin{ current_password, pin, confirm_pin }: requires the account password again; 4–6 digits (the kiosk login's limit, so a PIN that works on the phone also works on a kiosk; the phone endpoint itself still accepts 4–8); refuses all-same-digit, ascending/descending runs, repeated blocks (1212, 123123), well-known PINs (2580, 1004…) and the trailing digits of their ownstaff.phone(rules in@muse/security/pin-policy). Hashes with the samehashPassword, and clearspin_attempts,pin_locked_untilandlockout_count. Validation errors name thefield.DELETE /api/staff/me/pin{ current_password }removes it.- Audited as
STAFF_PIN_SET/STAFF_PIN_REMOVEwithout the PIN or hash.
- Forgotten or locked PIN. An owner (
MANAGE_EMPLOYEES, ADMIN only) uses Employees → Reset PIN (DELETE /api/admin/staff/{id}/pin), which clears the PIN and the lockout and is audited asSTAFF_PIN_ADMIN_RESET. Nobody can set another person's PIN. KIOSK device accounts keep the kiosk PIN screen (POST /api/kiosk/pin).
GET /api/phone/staff/{id}/schedule?from=YYYY-MM-DD&days=7 (functions/api/phone/staff/[id]/schedule.js)
fromdefaults to today in New York;daysis 1–14 (default 7). Bad values return400 invalid_from/invalid_days.200 { "status": "published|not_published|none", "timezone": "America/New_York", "from", "days", "shifts": [{ "date", "start", "end", "position" }], "next_shift": { "date", "start", "end" } | null }. Times are localHH:MM, 24-hour.- Data model. Shifts live in
employee_shifts(staff_id=staff_users.id,date,start_time,end_time,status=confirmedorpending). There is no schedule or week table and no publish button: aconfirmedshift is the published one (staff see it in the portal, the calendar feed and reminders);pendingis a draft. Weeks run Monday to Sunday.positionis the person'sstaff.position, or null. - How the status is derived:
none:employee_shiftshas no rows at all, so the business does not use scheduling.published: every Monday–Sunday week the range touches has at least oneconfirmedshift for anyone. An emptyshiftslist then means "no shifts for you".not_published: scheduling is in use, but at least one week in the range has noconfirmedshift (only drafts, or nothing yet).
shiftsis always this person'sconfirmedshifts inside the range.next_shiftis their firstconfirmedshift after the range ends, at most 30 days later, else null. Draft shifts are never returned.
GET /api/phone/staff/list (functions/api/phone/staff/list.js): the engine's staff directory.
200 { "staff": [{ "id": "<staff_users.id>", "first_name": "Elena", "role": "owner|manager|staff", "phone": "+19105550123", "on_shift": true, "shared_phone": false }], "generated_at": "..." }- Who: every active, non-kiosk staff member (same rules as above) whose
staff.phonenormalizes to a valid US number; anyone else is left off. on_shiftuses exactly the on-shift rule: an open time entry younger than 16 hours. The SQL (OPEN_SHIFT_CTE) lives once inphone-api.jsand both endpoints use it.shared_phoneis true for every person whose number is also on file for someone else. Both people are listed; the engine skips shared numbers rather than guess.- Order: on-shift people first, then off-shift staff, managers, owners; by first name within each group. Capped at 30.
- Routing:
listis a static path segment, so Pages never treats it as an{id}.functions/api/phone/staff-list.test.jsbuilds the realfunctions/tree with wrangler and checks that/api/phone/staff/listresolves tolist.jsand not to a[id]route. - Logs counts only (candidates, returned, on shift, shared).
Label Printer
The Brother QL-820NWB label printer sits on the shop network. Print jobs go from the Pages Functions to the print server on the muse-display Raspberry Pi, exposed through a Cloudflare Tunnel as print.ncmuse.co, which sends them to the printer. Health: curl https://print.ncmuse.co/api/health or staff.ncmuse.co/printer-status. See muse-customer/PRINTER_INTEGRATION.md and PRINT_SERVER_TROUBLESHOOTING.md.
TIP
The label printer is used for drink order labels during events and daily operations. If labels stop printing, open staff.ncmuse.co/printer-status first: printer_reachable: false means the server is fine and the printer is not.
Monorepo Structure
The codebase is organized as a monorepo with separate directories for each portal:
Muse_and_Co/
muse-customer/ # Customer portal (ncmuse.co)
src/ # React app source
functions/ # API routes (Pages Functions)
dist/ # Build output
muse-admin/ # Admin portal (staff.ncmuse.co)
src/ # React app source
functions/ # API routes (Pages Functions)
dist/ # Build output
muse-wiki/ # Employee wiki (wiki.ncmuse.co)
docs/ # VitePress markdown content
migrations/ # D1 database migration SQL filesEach project has its own package.json, wrangler.toml, and build configuration. They are deployed independently.
Data Flow Examples
Customer Buys a Paint & Boba Ticket
- Customer visits ncmuse.co/events and selects a Paint & Boba session
- Customer portal calls Stripe Checkout to process payment
- Stripe webhook fires on successful payment
- Pages Function receives webhook, creates order record in D1
- AWS SES sends confirmation email to customer
- Order and ticket appear in the admin portal for check-in
Staff Prints a Drink Label
- Staff opens staff.ncmuse.co and enters a drink order
- Admin portal sends a print request through the Pages Function API
- Request routes through Cloudflare Tunnel to the local network
- Brother QL-820NWB prints the label
Last updated: March 2026
