Tenancy & storefront
Hostname-based tenant resolution and the public storefront restaurant contract.
Tenancy & storefront
The first product slice is Tenant → Restaurant → Domain → Storefront.
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.
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"
}
}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.
Storefront
The Next.js Storefront loads this endpoint on the server (cache: "no-store"), parses the payload with StorefrontRestaurantSchema, and renders name + branding colors.
Unknown or inactive hosts render:
- Restaurant not found
- This ordering page is not available.