Skip to content

Proxy Layer Architecture Overview

The Upvendo backend proxy (upvendo-backend-proxy) is a Cloudflare Worker that sits between all client applications and the Laravel backend API. It is much more than an API pass-through. Alongside request routing to Laravel, KV-based response caching, D1-based stock management, and the Item Database API, the same Worker hosts:

  • the Emily AI surfaces -- ordering, merchant, reseller, global-admin, QA, and the voice pipeline (both a Durable-Object WebSocket realtime session and a batch fallback);
  • the Twilio telephony bridge (webhooks plus a Media Stream WebSocket upgrade, also a Durable Object);
  • Emily search-index admin jobs (backfill, rebuild, fast-update, orphan scan) behind an X-Admin-Secret header;
  • the missed-prompts review UI under /admin/emily/;
  • the back-office AI Assistant, Slack, and PWA handlers;
  • Slack error notifications on every 404 and unhandled Worker error.

Repository: upvendo-backend-proxyRuntime: Cloudflare Workers (V8 isolates) Entry point: src/index.jsDurable Objects: EmilyVoiceSession, TwilioBridge


High-Level Architecture

Client apps: Backoffice, Kiosk, Online Ordering, POS, KDS, Field
Plus: Twilio (telephony webhooks + Media Stream), Emily admin tooling
         |
         v
  Cloudflare Workers Proxy
    |         |               |                  |          |
    v         v               v                  v          v
 /api/*   /d1-api/*  /item-database-api/*  /emily-*     /health
    |         |               |                  |
    v         v               v                  v
 Laravel   D1 Stock      D1 Item          Azure OpenAI /
 Backend   Database      Databases        Azure AI Search

All traffic from frontends goes through the proxy. The proxy decides how to handle each request based on the URL path prefix.


Request Routing

The main fetch() handler in src/index.js routes requests by path. The whole handler is wrapped in withRequestObservability() and a top-level try/catch that reports unhandled errors to Slack. Matching is first-match, in source order -- the order below is the order in the file, and it matters (see the AI Assistant entry). (Verified: upvendo-backend-proxy/src/index.js lines 33-321 on origin/production.)

Before any routing, the handler awaits resolveAllowedOrigin(request, env) (line 46) -- the one place the async custom-domain registry is consulted. See "CORS Handling".

#MatchHandlerLine
1OPTIONS (any path)handleCors()60
2/twilio/bridge/* with Upgrade: websockethandleTwilioBridgeUpgrade() (Durable Object; non-WS returns 426)65, 67
3/twilio/voice/*handleTwilioWebhook() (any other /twilio/* path 404s)75
4/emily-ordering/*handleEmilyOrdering()82
5/emily-voice-ws/* with Upgrade: websockethandleVoiceRealtimeUpgrade() (Durable Object; non-WS returns 426)87
6/emily-voice/*handleVoiceRequest() -- batch STT → Emily → TTS fallback96
7/emily-merchant/*handleEmilyMerchant() (backoffice)104
8/emily-reseller/*handleEmilyReseller()109
9/emily-global-admin/*handleEmilyGlobalAdmin()114
10/admin/emily/missed-prompts or /admin/emily/missed-prompts/*handleAdminMissedPrompts()120-121
11/emily-qa/*handleEmilyQA() (X-QA-Secret)127
12POST /voice-debug/pipelineinline OrderPipeline diagnostic (X-QA-Secret; marked temporary in source)134
13/emily-admin/backfill/*backfillSearchableContent() (X-Admin-Secret; ?dry_run=true)153
14/emily-admin/rebuild/*rebuildLocationIndex() (X-Admin-Secret)187
15/emily-admin/fast-update/*updateSearchableContentOnly() (X-Admin-Secret)222
16/emily-admin/scan-orphansscanOrphans() (X-Admin-Secret)254
17/item-database-api/*handleItemDatabaseAPI() -- D1 item databases (country-based)275
18/api/back-office/ai-assistant/* or /ai-assistant/*handleAIAssistantAPI()279
19/slack-api/*handleSlackAPI()286
20/pwa/*handlePwaAPI() -- manifest, icon proxy290
21/d1-api/*handleStockAPI() -- D1 stock database294
22/api/*proxyToLaravel() -- Laravel backend with KV caching299
23/health or /health/*handleHealthCheck()303
--Everything else404 plus a Slack error notification fired through ctx.waitUntil()308-320

Ordering trap: the AI Assistant branch (#18) must stay above the /api/* catch-all (#22), otherwise /api/back-office/ai-assistant/* would be proxied to Laravel instead. That branch also rewrites the path -- /api/back-office/ai-assistant/ is replaced with /ai-assistant/ before the handler sees it. (Verified: src/index.js lines 279-284.)


Proxying to Laravel Backend

For /api/* requests, the proxy forwards the request to the Laravel backend and optionally caches the response.

Flow

  1. Check cache -- For GET requests to cacheable endpoints, check KV cache first.
  2. Forward request -- Build a new request to the Laravel backend URL, forwarding all headers except host, cf-ray, and accept-encoding. accept-encoding is dropped to avoid compression issues in dev; cf-connecting-ip is deliberately kept. (Verified: filterHeaders() in src/index.js lines 611-632; exclusion list at line 615.)
  3. Add client IP -- Sets X-Upvendo-Client-IP from cf-connecting-ip for accurate IP detection in Laravel. (Verified: src/index.js lines 623-625.)
  4. Inject traceparent -- A W3C traceparent header is generated (or reused from the inbound request) and set on the backend request, so a Worker request and its Laravel handling share a trace ID. (Verified: getOrCreateTraceparent() at src/index.js line 35; injection at lines 627-629.)
  5. Handle redirects -- Redirect responses (3xx) are forwarded with CORS headers.
  6. Handle file downloads -- Binary responses (ZIP, Excel, images) are streamed directly without loading into memory.
  7. Cache response -- Successful GET responses to cacheable endpoints are stored in KV with TTL.
  8. Return response -- Add CORS headers and X-Cache: HIT|MISS indicator.

Upstream timeout: the Laravel fetch is bounded by LARAVEL_TIMEOUT_MS = 15000 (15 s) via AbortSignal.timeout(), with the surrounding resilience wrapper allowed LARAVEL_TIMEOUT_MS + 500. A stalled backend therefore fails the Worker request at ~15 s rather than hanging the browser. (Verified: src/index.js line 31 and lines 448-451.)

Cache Configuration

Cache key format: api:{userHash}:{apiKeyHash}:{pathname}:{search}

The cache key incorporates the Authorization header and API key to ensure per-user cache isolation.

Cacheable endpoints:

Endpoint PatternCache TTL
/api/settings/*1 hour
/api/constants/*1 hour
/api/menus/*10 minutes
/api/items/*10 minutes
/api/languages/*5 minutes
/api/categories/*5 minutes

Only GET requests are cached. POST, PUT, DELETE requests always go through to the backend.


D1 Stock Management System

The stock management system uses Cloudflare D1 (SQLite at the edge) for low-latency stock tracking. This is critical for kiosk and online ordering to check item availability without hitting the Laravel backend.

Large constants payloads are gzipped, and the Worker sniffs for it

A ~1.15 MB menu push does not reliably fit the 10-second timeout; gzipped it is ~80 KB. When a payload exceeds the threshold, CloudflareD1Helper::sendPost() gzips it and sends the body as opaque bytes with a JSON content type and NO Content-Encoding header.

Never add the Content-Encoding: gzip header here. Cloudflare may decompress a body that declares it before the Worker ever sees it, so the Worker would receive plain JSON while expecting gzip. The Worker detects compression by sniffing the gzip magic bytes instead.

Small payloads and a false services.cloudflare.d1_gzip_upload config both fall back to a plain POST, as does a gzencode() failure — the compression is an optimisation, never a requirement.

Stock Service (src/stock-service.js)

The StockService class provides all stock operations. It uses merchant-specific tables with a naming convention of stocks_merchant_{slug} and stock_reservations_merchant_{slug}.

Table Structure

For each merchant, three tables exist:

stocks_merchant_{slug}

ColumnTypeDescription
item_idTEXTItem identifier
location_idTEXTLocation identifier
stockINTEGERCurrent stock quantity
expired_atINTEGERUnix timestamp when stock expires
updated_atINTEGERLast update timestamp

stock_reservations_merchant_{slug}

ColumnTypeDescription
idTEXTReservation identifier
item_idTEXTItem identifier
reserved_quantityINTEGERReserved quantity
session_idTEXTCart/order session ID
expires_atINTEGERUnix timestamp when reservation expires
created_atINTEGERCreation timestamp

constants_merchant_{slug}

ColumnTypeDescription
keyTEXTConstant key
valueTEXTJSON-encoded value
updated_atINTEGERLast update timestamp

Stock Operations

MethodDescription
getStocks(slug, locationId)Get all stocks for a merchant, with reserved quantities calculated (KV-cached -- see below)
updateStock(slug, itemId, locationId, stock, expiredAt)Create or replace stock for an item
deleteStock(slug, itemId)Remove stock record for an item
bulkUpdateStocks(slug, updates, alsoTruncate)Batch stock update, optionally clearing existing data
getAvailableStock(slug, itemId)Get available stock (total minus active reservations)

/d1-api/ read paths are unauthenticated, and cached to bound the cost

X-D1-API-Secret guards writes only. isAuthenticated() is called on the stocks/constants POST and DELETE branches and on /migrations, but the GET branches (src/stock-api-handler.js:85 stocks, :190 constants) skip it, and POST/DELETE /d1-api/{slug}/reservations (:143, :167) never call it at all. That is not an oversight to "fix" in the Worker: the zestidoo storefront calls these paths with raw fetch and only Content-Type, so requiring any header would break online ordering. (PROXY_ABUSE_HARDENING_SPEC.md §1 and §0.1 on origin/production.)

Since PR #177 (origin/production 2026-08-09) the reads are cached rather than gated, so a flood costs KV rather than D1:

LayerBehaviourSource
Stocks cacheOne KV entry per merchant, all locations unfiltered; fresh 15 s, KV TTL 60 s. Rows stored raw so expired_at is re-evaluated per request. Busted explicitly on every stock/reservation mutation. Skipped for reservation-scoped reads.src/stocks-cache.js:7-8, src/stock-service.js:173-217
Constants negative cacheAn empty result ({} -- absent key or missing table) is written as a negative entry, fresh 60 s / TTL 5 min, instead of a 7-day one. Real values keep 6 h fresh / 7-day TTL.src/constants-cache.js:2-11, src/stock-service.js:528-566
Known-slug registrymerchant_tables is read into a KV set (slugs:v1, TTL 300 s) so an unprovisioned slug is answered without touching D1. Fails open: a load failure, or an empty registry, reads as "undetermined", never as "no such merchant".src/known-slugs.js:1-105, src/stock-service.js:126-128

Consequences when debugging: a newly written constant can be up to 60 s stale behind a negative entry, a stock change up to 15 s behind the stocks cache if an invalidation was lost, and a merchant provisioned out-of-band up to 5 min invisible unless provisioning busted slugs:v1 (src/stock-service.js:853).

There is still no rate limiting in the Worker, by design -- per-IP counters would fight KV's ~1-write/sec-per-key ceiling, and a venue behind one NAT egress shares an IP. It belongs at the zone; draft rules and a rollout procedure live in OPERATOR_RUNBOOK.md §1 and were not applied as of 2026-08-09.

Emily merchant API surface

handleEmilyMerchant() routes eleven sub-paths (src/emily/merchant/handler.js:70-120). Several are depended on by the back office and by this KB's own CI.

MethodSub-pathPurpose
POST/chatSend a message
GET/threadsList threads
POST/threads/renameRename a thread (owner-scoped)
GET/historyThread history
POST/clearClear history
GET/configClient config
GET/onboarding-state/{id}Read onboarding state
POST/onboarding-state/{id}Save onboarding state
DELETE/onboarding-state/{id}Delete onboarding state
GET/setup-status/{id}Auto-detect completed steps from merchant data
GET/setup-stepsFetch setup steps from the Emily KB
POST/invalidate-setup-steps-cacheCalled by the KB GitHub workflow after indexing

Thread titles are AI-generated from the first message via gpt-4.1 (3–6 words, Title Case, temperature 0.3, maxTokens 16, trimmed of quotes and trailing punctuation, capped at 60 chars), falling back to the truncated first message. ThreadStore.upsert preserves an existing title and location on later messages rather than recomputing them. The rename endpoint is owner-scoped and deny-by-default.

Setup-steps endpoint and cache

Caching is currently DISABLED in codeDISABLE_CACHE = true (src/emily/shared/setup-steps.js:9). Every /setup-steps call therefore hits Azure directly, and the CI invalidation POST is a no-op. The comment records why: the GitHub workflow invalidated on PR creation rather than on merge. SETUP_STEPS_CACHE_TTL (24h) is defined but unused while the flag is on.

POS_PROVIDERS (used to enumerate cache keys) is ['hendrickx', 'vanhoutte', 'square', 'mpluskassa', 'shopcaisse'] — it omits lightspeed. That does not bite while caching is off, but it will the moment the flag flips: Lightspeed setup steps would never be invalidated.

Reservation System

The reservation system prevents overselling by temporarily holding stock during the ordering process.

MethodDescription
createReservation(slug, itemId, qty, sessionId, expirationSec, reservationId)Reserve stock for a cart session
releaseReservationsBySession(slug, sessionId)Release all reservations for a session
cleanupExpiredReservations(slug)Delete expired reservations

Reservation flow:

  1. Customer adds item to cart -> createReservation() with 10-minute default expiration
  2. Available stock = total stock - sum of active (non-expired) reservations
  3. If reservation exists for the session, it is updated rather than duplicated
  4. When customer places order -> reservations are released via releaseReservationsBySession()
  5. If customer abandons cart -> reservations expire automatically

TTL bounds. expiration_seconds arrives straight off the request body, so it is coerced with Number() before any arithmetic. Default 600s, ceiling 3 600s; non-finite, <= 0 or null falls back to the default, itself clamped to the ceiling (DEFAULT_RESERVATION_TTL_SECONDS / DEFAULT_MAX_RESERVATION_TTL_SECONDS, src/stock-service.js:40-41; normalizeExpirationSeconds() at :61-69).

The ceiling was 86 400s until PR #177 (origin/production 2026-08-09). It is 1 h now because /reservations takes no credentials, so the ceiling is also how long an anonymous caller can pin a tracked item's inventory; both real clients ask for 600s. Alongside it, createReservation() refuses a new hold once a session already holds DEFAULT_MAX_RESERVATIONS_PER_SESSION = 100 live reservations, returning { success: false, error: 'Too many active reservations for this session (max 100)' } -- updates to an existing hold still pass, so a capped-out cart stays editable (src/stock-service.js:49, :365-368). Both are overridable per environment via MAX_RESERVATION_TTL_SECONDS / MAX_RESERVATIONS_PER_SESSION (createStockService(), :83-92); neither is set in wrangler.toml, so every environment runs the defaults.

Why the coercion is load-bearing. Sent as a JSON string, now + expirationSeconds used to concatenate rather than add — 1780135653 + "120"1780135653120, roughly the year 58 400. D1's INTEGER affinity happily stored it, the row then satisfied expires_at > now forever, and cleanupExpiredReservations (expires_at <= now) could never reap it. The hold ate the stock permanently and the storefront/kiosk showed the item as unavailable. On one production merchant 89 of 96 reservation rows were poisoned, accumulating since 2026-05-30. The ceiling is defence in depth: no caller can mint a hold that outlives it.

Insufficient stock handling: If available_stock < requested_quantity, the reservation returns { success: false, error: 'Insufficient stock' }. The same guard also applies on the update path, where the caller's own reservation is excluded only for the item it actually holds.

No stock record: If an item has no stock record in D1, it returns available_stock: 99999 (unlimited).

Constants Operations

MethodDescription
getConstants(slug, key?)Get all or specific constants
setConstant(slug, key, value)Set a single constant
bulkSetConstants(slug, constants)Batch set constants

Analytics

The stock service records analytics for each D1 API call:

sql
INSERT INTO analytics (merchant_slug, endpoint, response_time, timestamp)

Analytics recording is done asynchronously via ctx.waitUntil() to avoid blocking the response.

Health Checks

EndpointResponse
/healthOverall health + analytics count
/health/{merchantSlug}Merchant-specific stock and reservation counts

Item Database API

The Item Database API provides a centralized product database for item information, nutritional data, allergens, and ingredients. It uses a country-based architecture with separate D1 databases per country.

Handler: src/item-database-handler.js

Database Architecture

D1 Databases:
  ITEMS_DB_GLOBAL    -- Global reference data (allergens, ingredients, dietary info)
  ITEMS_DB_BE        -- Belgian items
  ITEMS_DB_FR        -- French items
  ITEMS_DB_NL        -- Dutch items
  ITEMS_DB_DE        -- German items
  ITEMS_DB_ES        -- Spanish items
  ITEMS_DB_PT        -- Portuguese items
  ITEMS_DB_US        -- American items

Each country database contains items scoped by business type. The scope format is {business_type}-{country_code} (e.g., frituur-be, restaurant-fr).

Endpoints

MethodPathAuth RequiredDescription
GET/item-database-api/items?scope={scope}YesList items with pagination and search
GET/item-database-api/items/{id}?scope={scope}YesGet single item by ID
POST/item-database-api/itemsYesCreate item (supports multipart/form-data)
PUT/item-database-api/items/{id}?scope={scope}YesUpdate item
DELETE/item-database-api/items/{id}?scope={scope}YesDelete item
GET/item-database-api/suggestions?scope={scope}&search={term}YesGet single most relevant item for autofill
GET/item-database-api/scopesYesList all scopes with pagination
POST/item-database-api/scopesYesCreate new scope
PUT/item-database-api/scopes/{id}YesUpdate scope name
DELETE/item-database-api/scopes/{id}YesDelete scope
GET/item-database-api/ingredientsYesList ingredients with pagination
POST/item-database-api/ingredientsYesCreate ingredient
PUT/item-database-api/ingredients/{id}YesUpdate ingredient
DELETE/item-database-api/ingredients/{id}YesDelete ingredient
GET/item-database-api/allergensYesList allergens
GET/item-database-api/dietary-preferencesYesList dietary preferences
GET/item-database-api/dietary-supplementsYesList dietary supplements
GET/item-database-api/ingredient-categoriesYesList ingredient categories
GET/item-database-api/item-categoriesYesList item categories
POST/item-database-api/import/itemsYesImport items (JSON = synchronous; .xlsx = multipart, returns 202)
POST/item-database-api/import/global-entitiesYesImport global reference entities
GET/item-database-api/import/status/{jobId}YesPoll one import job's status
GET/item-database-api/import/jobs?scope={scope}&limit={n}YesList recent import jobs
POST/item-database-api/migrations/{scope}YesCreate tables for a scope
GET/item-database-api/constantsYesGet all business types, countries, languages
GET/item-database-api/reference-data/allNo (public)Get all reference data from KV cache

Import jobs

The two upload formats behave differently:

  • JSON file → imported synchronously, result returned inline.
  • .xlsx file → uploaded as multipart, returns 202 with { async: true, jobId }.

The back office then shows a Recent Imports panel and polls import/status/{jobId} every 3s for every job still in pending or processing, stopping once none are active. On completed it toasts and reloads the item list; on failed it toasts the error. import/jobs (limit 20, scoped) repopulates the panel on mount and on scope change, and restarts polling if any job is still active. Poll failures are swallowed per job per cycle, so one bad response does not kill the whole panel.

Authentication

Most endpoints require a valid bearer token with has_global_access permission. The reference-data endpoint is public.

Scope Validation

Scope values are validated against:

  • Business types: Predefined list of valid types (frituur, restaurant, etc.)
  • Country codes: BE, FR, NL, DE, ES, PT, US

Invalid scopes return a 400 error with the list of valid values.

Uninitialized Scope Handling

If a scope's tables have not been created yet (SQL "no such table" error), the API returns:

  • Empty results for list endpoints (not a 500 error)
  • A helpful error message with a hint to use the /migrations endpoint

Environment Configuration

Configured in wrangler.toml with per-environment settings.

Environments

EnvironmentWorker NameBackend URL
productionupvendo-api-proxyhttps://backend.upvendo.com
stagingupvendo-api-proxy-staginghttps://staging.backend.upvendo.com
testingupvendo-api-proxy-testinghttps://testing.backend.upvendo.com
devupvendo-api-proxy-devhttps://testing.backend.upvendo.com

(Verified: wrangler.toml lines 41/42/61, 178/179/198, 315/316/335, 462/463/479. The dev environment points at the testing backend, not at a local Laravel -- there is no 127.0.0.1:8000 in wrangler.toml on origin/production.)

Bindings Per Environment

Each environment has:

  • KV_CACHE -- KV namespace for response caching and reference data
  • DOMAIN_MAPPINGS -- KV namespace of registered merchant custom domains, read-only here. Every environment points at the same namespace id f248335e67644a3da4b147159be07fa6, exactly as upvendo-custom-domain-router does; environment separation comes from the env field inside each record, not from separate namespaces. (Verified: wrangler.toml lines 30, 95, 232, 372, 512.)
  • STOCK_DB -- D1 database for merchant stock management
  • ITEMS_DB_GLOBAL -- D1 database for global item reference data
  • ITEMS_DB_{CC} -- Country-specific D1 databases (US, BE, FR, NL, DE, PT, ES)

Environment Variables

VariableDescription
ENVIRONMENTCurrent environment name
LARAVEL_BACKEND_URLBackend API base URL
ALLOWED_ORIGINSComma-separated list of allowed CORS origins
D1_API_SECRETSecret for backend-to-proxy D1 API calls
BACKEND_IPSAllowed backend server IPs
CLOUDFLARE_ACCOUNT_IDCloudflare account identifier
CLOUDFLARE_ACCOUNT_HASHCloudflare account hash for image URLs

Optional, unset in wrangler.toml on origin/production -- read them as "defaults apply unless someone set a secret":

VariableEffect when set
CORS_DEBUG_SECRETEnables the X-Debug-CORS-Secret origin-reflection bypass (see CORS). Inert while unset; deployed via wrangler secret put, never wrangler.toml. (.dev.vars.example)
MAX_RESERVATION_TTL_SECONDSOverrides the 3 600s reservation ceiling (src/stock-service.js:87-89)
MAX_RESERVATIONS_PER_SESSIONOverrides the 100-live-holds-per-session cap (:90-92)

Local secrets live in .dev.vars, which is git-ignored since PR #176 (86b3727, 2026-08-09) -- the committed copy carried six live credentials. Do not expect to find any working value in the repo; .dev.vars.example is the template. The leaked values were removed from the working tree but remain in git history and were still unrotated as of 2026-08-09 (OPERATOR_RUNBOOK.md §2).

Allowed Origins (Production)

Twelve entries, in this order:

https://field.upvendo.com
https://kds.upvendo.com
https://pos.upvendo.com
https://kiosk.upvendo.com
https://backoffice.upvendo.com
https://zestidoo.com
https://zestidoo.be
https://zestidoo.co.uk
https://zestidoo.de
https://zestidoo.fr
https://zestidoo.nl
https://localhost

(Verified: wrangler.toml line 62.) The order matters: allowedOrigins[0] is the value every denied origin gets in Access-Control-Allow-Origin, and the value used when a request carries no Origin header at all -- so today that is https://field.upvendo.com.

Staging (wrangler.toml line 199) and testing (line 336) carry the same eleven hostnames with an environment prefix -- https://staging.field.upvendo.com, https://testing.kds.upvendo.com, and so on -- then http://localhost:5174, :5175, :5176 and https://localhost. Testing additionally allows http://kiosk.local. dev (line 480) has only the localhost entries plus http://kiosk.local.


CORS Handling

CORS is assembled in src/cors-helpers.js, but the origin decision is made in src/auth-helpers.js. Every response includes:

  • Access-Control-Allow-Origin -- the resolved origin, or ALLOWED_ORIGINS[0] when the origin is denied
  • Access-Control-Allow-Credentials: true (src/cors-helpers.js:40)

For file downloads, Content-Disposition is added to Access-Control-Expose-Headers (src/index.js lines 537-539); redirects expose Location (line 483); and the observability wrapper always merges in traceparent and Server-Timing (src/observability/request-observability.js lines 100-101).

Which origins are allowed

Reversed on 2026-08-09 by PRs #179 and #180. The proxy used to echo any origin that was not one of our own domains, alongside Access-Control-Allow-Credentials: true -- a credentialed cross-origin read for every third-party site. It no longer does. Documentation or debugging habits that say "a merchant custom domain works without configuration" are wrong for anything on origin/production after 5923fe8.

The decision runs in two stages. staticAllowedOrigin() (src/auth-helpers.js:54-95) settles everything that needs no I/O, in order:

  1. No Origin header -> ALLOWED_ORIGINS[0] (line 58).
  2. Exact match against ALLOWED_ORIGINS -> reflect it (line 59).
  3. X-Debug-CORS-Secret matching CORS_DEBUG_SECRET -> reflect anything, and it wins over every rule below (lines 64-67). Constant-time compare, and inert unless the secret is configured (hasCorsDebugSecret(), lines 34-47).
  4. localhost / 127.0.0.1 -> reflect, in every environment including production (lines 73-75).
  5. Any .dev hostname -> reflect, when ENVIRONMENT is neither production nor staging (lines 78-80).
  6. Our own platform domains (hostname.match(/\.(zestidoo|upvendo)\.(com|be|nl|de|fr|co\.uk|dev)$/)) -> governed by ALLOWED_ORIGINS alone; not listed means denied, and the registry has no say (lines 84-86).
  7. Unparseable Origin -> denied (lines 87-90).
  8. Anything left is a candidate merchant custom domain -> unsettled (line 94).

Stage two is the registry lookup, resolveAllowedOrigin() (:107-127): a candidate is reflected only if it is registered in the DOMAIN_MAPPINGS KV namespace, otherwise it gets ALLOWED_ORIGINS[0] and the browser blocks the read (line 124).

The custom-domain registry

DOMAIN_MAPPINGS is not new and is not CORS-specific: keys are domain:{host}, and presence there is what makes a custom-domain storefront routable by upvendo-custom-domain-router in the first place. The proxy only reads it.

  • Who writes it: the Laravel backend, over the Cloudflare KV REST API. DomainProfileService::syncDomainToKV() writes on verification (app/Services/BackOffice/DomainProfileService.php:196), on default-location change (:257), and on location assign/unassign (:296, :331); removeDomainFromKV() deletes on domain deletion (:131). OnlineOrderingService::syncDomainToKV() writes the same key shape for the per-location mapping (app/Services/BackOffice/OnlineOrderingService.php:661, key at :690). Verified on upvendo-backend origin/production.
  • Record shape: { merchantId, assignedLocations, defaultSlug, env } (DomainProfileService.php:440-445). The env value comes from getKVEnvironment() (:674-685), which maps Laravel's app.env: local and testing -> testing, staging -> staging, production -> production, anything else -> production.
  • Key normalisation: lowercase, trimmed, port stripped, trailing dot stripped -- and www. is deliberately NOT stripped, so www.example.com is a different key from example.com. The proxy's normalizeHost() (src/custom-domains.js:36-38) must stay identical to the router's and to the backend's normalizeHostname() (DomainProfileService.php:621-629).
  • Environment scoping: the proxy compares the record's env against its own ENVIRONMENT, with dev aliased to testing (src/custom-domains.js:17-23, mappingBelongsToThisEnv() at :93-115). An explicit mismatch is rejected -- a domain registered for testing gets no CORS from the production proxy, and logs custom-domains: mapping belongs to another environment. A record with no env field, or an unparseable body, is accepted on presence alone (older records; the router already refuses to route what it cannot parse).
  • Caching: per-isolate memo, 60 s (src/custom-domains.js:28). A freshly connected domain becomes usable within that window.
  • Fails open, deliberately: if the DOMAIN_MAPPINGS binding is missing or the KV read throws, isRegisteredCustomDomain() returns null and the origin is reflected anyway, with console.error('CORS: custom-domain registry unavailable, reflecting origin') (src/custom-domains.js:52-83; src/auth-helpers.js:120-122). A KV outage must not take every custom-domain storefront offline. So a deploy that loses the binding silently reverts to the old permissive behaviour -- the error log is the only signal.

Why the resolve-then-memo split

getAllowedOrigin() is synchronous and called from response builders scattered across the Worker, so it cannot be made async (the source comments at src/auth-helpers.js:10 and src/index.js:41 put the figure at ~169 call sites; a literal grep of src/ finds 14, so treat the comment's number as historical, not current). src/index.js:46 calls resolveAllowedOrigin() once per request, before anything can build a response, and stores the answer in a WeakMap keyed by the Request. Every later getAllowedOrigin() call reads that memo (src/auth-helpers.js:15, :132-146).

If a custom-domain candidate ever reaches a response builder unresolved, getAllowedOrigin() refuses to reflect and logs CORS: origin needed the registry but was never resolved; denying reflection (:142-145). Seeing that line means a new code path is building responses outside the main fetch() handler -- fix the path, do not re-add reflection.

What this means in practice

  • status: verified in the back office means the KV write succeeded -- verification only promotes the profile when syncDomainToKV() returned true, otherwise it stays pending and the merchant is told the TXT record verified but the sync failed (DomainProfileService.php:210-227). So a pending domain gets no CORS by construction. What can still drift is a later re-sync -- location assign/unassign or default-location change (:296, :331, :257) -- which is not status-checked and can leave a verified domain with a stale or missing record. Re-run verification rather than adding the host to ALLOWED_ORIGINS.
  • A testing-registered domain pointed at the production proxy now fails CORS rather than silently working. This is the intended behaviour: the router picks the storefront's proxy from the same env field, so that combination was never legitimate.
  • A *.upvendo.com / *.zestidoo.* origin missing from ALLOWED_ORIGINS is denied, as before. order001.zestidoo.dev is denied in production (platform domain) and allowed in testing (the .dev carve-out).
  • CORS is still not an access control -- the /d1-api/ GETs answer a header-less curl with no Origin at all. What #179 closed is the credentialed browser read, not the endpoint.

Preflight response

OPTIONS on any path short-circuits to handleCors() (src/index.js line 60), which returns 204 No Content with:

HeaderValue
Access-Control-Allow-Originresult of getAllowedOrigin()
Access-Control-Allow-MethodsGET, POST, PUT, DELETE, OPTIONS, PATCH
Access-Control-Allow-HeadersContent-Type, Authorization, X-API-Key, X-Capacitor-API-Key, X-Pos-Staff-Token, X-Requested-With, Ngrok-Skip-Browser-Warning, X-Order-Number, X-Debug-CORS-Secret
Access-Control-Allow-Credentialstrue
Access-Control-Max-Age86400 (24 h)

A request header not on that Allow-Headers list will fail preflight -- this is the usual cause of a new custom header silently breaking a browser call. (Verified: createCorsPreflightResponse() in src/utils/response-helpers.js lines 184-201.)

The preflight cannot carry the debug secret, because a browser only announces the header it intends to send in Access-Control-Request-Headers. So handleCors() reflects the origin on the strength of that announcement alone when CORS_DEBUG_SECRET is configured (src/cors-helpers.js:22-32). That is not a bypass: the preflight response carries no data, and the real request that follows still has to present the secret or it gets a non-matching Allow-Origin.


Key Patterns for Debugging

  1. Stock discrepancies: Check if the D1 stock data is out of sync with the Laravel backend inventory. The D1 data is populated by the backend and may lag behind.

  2. Cache staleness: the Laravel /api/* cache TTLs range from 5 minutes to 1 hour; check the X-Cache response header and the relevant endpoint TTL. The /d1-api/ paths have their own, much shorter layers -- stocks 15 s, constants misses 60 s, known slugs 5 min -- see "/d1-api/ read paths" above.

  3. Scope initialization: New merchants or business types need their scope tables created via the /migrations endpoint before the item database works.

  4. Reservation leaks: If reservations are not released (e.g., payment flow crashes), expired reservations are cleaned up by cleanupExpiredReservations(). The default reservation expiration is 10 minutes.

  5. Environment mismatch: Ensure the frontend is pointing to the correct proxy environment. Staging frontends should hit upvendo-api-proxy-staging, not production.

  6. "It's a CORS problem" on a merchant's own domain: since 2026-08-09 the origin list is a first thing to check. Work in this order: (a) is the domain verified in Settings → Domains -- an unverified domain, or one whose KV sync failed at verification, is not in DOMAIN_MAPPINGS and gets no CORS; (b) is the record's env the environment being called -- a testing-registered domain hitting the production proxy is rejected, and the Worker logs custom-domains: mapping belongs to another environment; (c) does the key match exactly, www. included, since www.example.com and example.com are separate keys; (d) is the request header on the preflight Access-Control-Allow-Headers list; (e) is the path actually routed, since an unrouted path 404s before any handler runs; (f) is the failure really a 401/403 the browser is surfacing as a CORS message. Adding the domain to ALLOWED_ORIGINS is a workaround, not the fix -- re-run verification so the registry entry exists. To reproduce from an arbitrary origin, set CORS_DEBUG_SECRET and send X-Debug-CORS-Secret. See "Which origins are allowed" above.

  7. Unexpected Slack noise: every 404 from the Worker -- including typo'd paths and scanner traffic -- fires an ErrorNotificationService Slack notification through ctx.waitUntil(), as does every unhandled Worker error. A burst of Slack alerts often means a client is calling a path the router does not know, not that a handler broke. (Verified: src/index.js lines 308-320 for the 404 path and lines 325-348 for the top-level catch.)

  8. Item permanently shows as unavailable / phantom reserved_stock: immortal reservations from a string-concatenated expires_at (see "TTL bounds" above). Identify the poisoned rows with expires_at > created_at + 86400 and repair with scripts/repair-immortal-reservations.js. Fixed on origin/production 2026-07-25; rows created before that date can still be poisoned, so check the data even on a patched Worker.