ByteBite Docs
Architecture

Tenancy & storefront

Hostname-based tenant resolution and the public storefront restaurant contract.

Tenancy & storefront

The first product slice is Tenant → Restaurant → Domain → Storefront. Menu catalog, guest cart, checkout reads, and weekly opening hours use the same hostname tenant gate.

The same Storefront application renders different restaurant branding based on the request hostname. Tenant authority is never a client-supplied restaurantId.

Local restaurants

HostRestaurant
http://luigi.bytebite.testPizzeria Luigi
http://mario.bytebite.testMario's Restaurant
http://unknown.bytebite.testRestaurant not found
http://storefront.bytebite.testNeutral ByteBite development host (not a restaurant tenant)

unknown.bytebite.test must be listed in /etc/hosts (no wildcard DNS). Add it alongside Luigi and Mario. See Getting started.

Landing host

The marketing landing page is not a restaurant tenant (ADR-022).

EnvironmentLanding hostRestaurant storefront
Stagingstaging.getbytebite.co<slug>.staging.getbytebite.co
Productiongetbytebite.co, www.getbytebite.co<slug>.getbytebite.co, custom domains

apps/landing serves the apex. Locally that is landing.bytebite.test (heading Coming Soon, Production when BYTEBITE_DEPLOY_ENV is unset). Do not seed a restaurant_domains row for the landing host. storefront.bytebite.test stays the local non-tenant storefront placeholder.

Seeds are idempotent (pnpm db:seed).

Database

restaurants

ColumnNotes
idUUID (opaque identifier)
namePublic name
slugUnique
statusACTIVE | INACTIVE
logo_urlOptional
primary_color / secondary_colorStorefront branding
created_at / updated_atTimestamps

restaurant_domains

ColumnNotes
idUUID
restaurant_idFK → restaurants.id
hostnameUnique, stored normalized (lowercase, no port)
typeBYTEBITE_SUBDOMAIN | CUSTOM_DOMAIN
is_primaryAt most one primary domain per restaurant
verified_atPresent on local seeds; custom-domain verification is a later slice
created_at / updated_atTimestamps

Lookup is an exact match on hostname after normalization. The first DNS label is not the tenant key.

Hostname normalization

normalizeHostname (in packages/domain) is the only normalization function:

  • trim surrounding whitespace
  • lowercase
  • strip :port for hostnames and IPv4
  • strip a trailing FQDN dot
  • understand bracketed IPv6 ([::1]:3000 → ::1)

It does not strip subdomains or infer a slug.

Examples:

  • LUIGI.BYTEBITE.TEST:80 → luigi.bytebite.test
  • luigi.bytebite.test:3000 → luigi.bytebite.test

Tenant resolution flow

Host
  → normalize hostname
  → restaurant_domains exact lookup
  → restaurant
  → validate status is ACTIVE
  → TenantContext
  → request handling

Internal outcomes:

  • resolved
  • domain_not_found
  • restaurant_inactive

Anonymous clients see the same public 404 (Restaurant not found) for unknown hosts and inactive restaurants.

Database or infrastructure failures during hostname lookup return 503 Service Unavailable with a generic message. They are never reported as “restaurant not found”.

GET /health and GET /health/db do not require a restaurant. They stay available on api.bytebite.test.

Tenant security

External / browser (including same-origin /api): tenant authority is the Host header as Traefik delivered it. Fastify does not enable trustProxy. X-Forwarded-Host from clients is not used.

Internal Storefront SSR: Next.js calls API_INTERNAL_URL (http://api:3001). That request reaches Fastify on the private Docker service path; the api hostname is not publicly exposed. The Storefront sends the original normalized host in X-ByteBite-Tenant-Host.

Fastify honors that header only when the request Host is the API's internal identity:

  • api
  • localhost
  • 127.0.0.1
  • ::1

It is ignored on every public hostname, including:

  • luigi.bytebite.test (cannot switch to Mario)
  • api.bytebite.test (cannot spoof a tenant through the public API host)

restaurantId in the query string, body, or a custom browser header cannot select a tenant.

Public API

GET /storefront/restaurant

Example (Luigi, Host luigi.bytebite.test or trusted internal header):

{
  "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
  "name": "Pizzeria Luigi",
  "slug": "luigi",
  "branding": {
    "logoUrl": null,
    "primaryColor": "#1F7A4D",
    "secondaryColor": "#E8C547"
  },
  "hours": {
    "windows": [
      { "weekday": 0, "opensAt": "11:00", "closesAt": "14:00" },
      { "weekday": 0, "opensAt": "17:00", "closesAt": "22:00" },
      { "weekday": 4, "opensAt": "11:00", "closesAt": "14:00" },
      { "weekday": 5, "opensAt": "12:00", "closesAt": "22:00" }
    ]
  }
}

Same-origin: http://luigi.bytebite.test/api/storefront/restaurant (Traefik strips /api, Host is preserved).

Direct API tools should send a Host override (Host: luigi.bytebite.test), not ?restaurantId=.

GET http://api.bytebite.test/storefront/restaurant has no restaurant tenant and returns 404.

GET /storefront/menu uses the same tenant gate. The payload is the visible catalog for that restaurant (active categories that still have active items). An empty catalog is 200 with categories: [], not 404. See Menu catalog.

GET /storefront/cart (and cart mutations) use the same tenant gate. An empty cart is 200 with items: [], not 404. The bb_cart cookie is host-only (no Domain attribute), so Luigi's cart UUID never reaches Mario. A Luigi cookie sent to Mario's host is ignored and replaced with a new empty Mario cart. See Guest cart.

POST /storefront/checkout and GET /storefront/orders/:orderId use the same tenant gate. Body restaurantId does not change the tenant. A Luigi checkout never includes Mario items. A Luigi orderId on Mario's host returns 404 (no leak). See Checkout & orders.

GET /storefront/hours uses the same tenant gate. Weekly windows belong to the hostname restaurant. An empty week is 200 with windows: []. Pickup checkout is 409 when closed; cart and menu GETs stay 200. See Opening hours.

Dashboard operator routes under /dashboard skip hostname lookup. Restaurant context is the path (ADR-021). Those routes require a staff session (Staff authentication) and do not replace the path with session.restaurantId. Staging HTTPS (including storefront and dashboard) is also gated by Traefik BasicAuth; that is not a substitute for the staff session.

Storefront

The Next.js Storefront loads restaurant, menu, cart, hours, and order endpoints on the server (cache: "no-store"), parses them with the shared Zod contracts, and renders branding, weekly hours when present, the visible catalog, the guest cart, and checkout. SSR cart/order fetches forward the incoming bb_cart cookie. Browser mutations use same-origin /api with credentials.

Unknown or inactive hosts render:

  • Restaurant not found
  • This ordering page is not available.

On this page