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
| Host | Restaurant |
|---|---|
http://luigi.bytebite.test | Pizzeria Luigi |
http://mario.bytebite.test | Mario's Restaurant |
http://unknown.bytebite.test | Restaurant not found |
http://storefront.bytebite.test | Neutral 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).
| Environment | Landing host | Restaurant storefront |
|---|---|---|
| Staging | staging.getbytebite.co | <slug>.staging.getbytebite.co |
| Production | getbytebite.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
| Column | Notes |
|---|---|
id | UUID (opaque identifier) |
name | Public name |
slug | Unique |
status | ACTIVE | INACTIVE |
logo_url | Optional |
primary_color / secondary_color | Storefront branding |
created_at / updated_at | Timestamps |
restaurant_domains
| Column | Notes |
|---|---|
id | UUID |
restaurant_id | FK → restaurants.id |
hostname | Unique, stored normalized (lowercase, no port) |
type | BYTEBITE_SUBDOMAIN | CUSTOM_DOMAIN |
is_primary | At most one primary domain per restaurant |
verified_at | Present on local seeds; custom-domain verification is a later slice |
created_at / updated_at | Timestamps |
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
:portfor 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.testluigi.bytebite.test:3000→luigi.bytebite.test
Tenant resolution flow
Host
→ normalize hostname
→ restaurant_domains exact lookup
→ restaurant
→ validate status is ACTIVE
→ TenantContext
→ request handlingInternal outcomes:
resolveddomain_not_foundrestaurant_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:
apilocalhost127.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.