Skip to content

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 ​

PortalURLPurpose
Customerncmuse.coPublic-facing site. Event listings, bookings, Sit and Paint sign-ups, drink menu, gift cards.
Admin / Staffstaff.ncmuse.coInternal portal. Event management, check-in, quotes, orders, inventory, label printing.
Wikiwiki.ncmuse.coThis 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 ​

LayerTechnologyNotes
FrontendReact + TypeScript + ViteCustomer and Admin portals
StylingTailwind CSSUtility-first CSS framework
Static SiteVitePressWiki only
HostingCloudflare PagesAll three portals
API RoutesCloudflare Pages FunctionsServerless functions co-deployed with each portal
DatabaseCloudflare D1SQLite at the edge, shared across portals
File StorageCloudflare R2Images, uploaded assets
PaymentsStripeEvent tickets, bookings, gift cards
EmailAWS SESOrder confirmations, booking receipts, notifications
Label PrintingBrother QL-820NWBDrink 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's SHIFT_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.phone are normalized to +1XXXXXXXXXX before matching. If two or more active people share a number the answer is 409 { "error": "ambiguous" }; the endpoint never guesses.
  • has_pin is true when staff_users.pin_hash is 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_hash with the same verifyPassword (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, so last_login is 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 own staff.phone (rules in @muse/security/pin-policy). Hashes with the same hashPassword, and clears pin_attempts, pin_locked_until and lockout_count. Validation errors name the field.
    • DELETE /api/staff/me/pin { current_password } removes it.
    • Audited as STAFF_PIN_SET / STAFF_PIN_REMOVE without 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 as STAFF_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)

  • from defaults to today in New York; days is 1–14 (default 7). Bad values return 400 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 local HH:MM, 24-hour.
  • Data model. Shifts live in employee_shifts (staff_id = staff_users.id, date, start_time, end_time, status = confirmed or pending). There is no schedule or week table and no publish button: a confirmed shift is the published one (staff see it in the portal, the calendar feed and reminders); pending is a draft. Weeks run Monday to Sunday. position is the person's staff.position, or null.
  • How the status is derived:
    • none: employee_shifts has no rows at all, so the business does not use scheduling.
    • published: every Monday–Sunday week the range touches has at least one confirmed shift for anyone. An empty shifts list then means "no shifts for you".
    • not_published: scheduling is in use, but at least one week in the range has no confirmed shift (only drafts, or nothing yet).
  • shifts is always this person's confirmed shifts inside the range. next_shift is their first confirmed shift 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.phone normalizes to a valid US number; anyone else is left off.
  • on_shift uses exactly the on-shift rule: an open time entry younger than 16 hours. The SQL (OPEN_SHIFT_CTE) lives once in phone-api.js and both endpoints use it.
  • shared_phone is 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: list is a static path segment, so Pages never treats it as an {id}. functions/api/phone/staff-list.test.js builds the real functions/ tree with wrangler and checks that /api/phone/staff/list resolves to list.js and 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 files

Each project has its own package.json, wrangler.toml, and build configuration. They are deployed independently.

Data Flow Examples ​

Customer Buys a Paint & Boba Ticket ​

  1. Customer visits ncmuse.co/events and selects a Paint & Boba session
  2. Customer portal calls Stripe Checkout to process payment
  3. Stripe webhook fires on successful payment
  4. Pages Function receives webhook, creates order record in D1
  5. AWS SES sends confirmation email to customer
  6. Order and ticket appear in the admin portal for check-in

Staff Prints a Drink Label ​

  1. Staff opens staff.ncmuse.co and enters a drink order
  2. Admin portal sends a print request through the Pages Function API
  3. Request routes through Cloudflare Tunnel to the local network
  4. Brother QL-820NWB prints the label

Last updated: March 2026

Internal documentation for Muse & Co staff only