Appearance
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-Secretheader; - 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 SearchAll 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".
| # | Match | Handler | Line |
|---|---|---|---|
| 1 | OPTIONS (any path) | handleCors() | 60 |
| 2 | /twilio/bridge/* with Upgrade: websocket | handleTwilioBridgeUpgrade() (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: websocket | handleVoiceRealtimeUpgrade() (Durable Object; non-WS returns 426) | 87 |
| 6 | /emily-voice/* | handleVoiceRequest() -- batch STT → Emily → TTS fallback | 96 |
| 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 |
| 12 | POST /voice-debug/pipeline | inline 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-orphans | scanOrphans() (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 proxy | 290 |
| 21 | /d1-api/* | handleStockAPI() -- D1 stock database | 294 |
| 22 | /api/* | proxyToLaravel() -- Laravel backend with KV caching | 299 |
| 23 | /health or /health/* | handleHealthCheck() | 303 |
| -- | Everything else | 404 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
- Check cache -- For GET requests to cacheable endpoints, check KV cache first.
- Forward request -- Build a new request to the Laravel backend URL, forwarding all headers except
host,cf-ray, andaccept-encoding.accept-encodingis dropped to avoid compression issues in dev;cf-connecting-ipis deliberately kept. (Verified:filterHeaders()insrc/index.jslines 611-632; exclusion list at line 615.) - Add client IP -- Sets
X-Upvendo-Client-IPfromcf-connecting-ipfor accurate IP detection in Laravel. (Verified:src/index.jslines 623-625.) - Inject
traceparent-- A W3Ctraceparentheader 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()atsrc/index.jsline 35; injection at lines 627-629.) - Handle redirects -- Redirect responses (3xx) are forwarded with CORS headers.
- Handle file downloads -- Binary responses (ZIP, Excel, images) are streamed directly without loading into memory.
- Cache response -- Successful GET responses to cacheable endpoints are stored in KV with TTL.
- Return response -- Add CORS headers and
X-Cache: HIT|MISSindicator.
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 Pattern | Cache 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: gzipheader 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}
| Column | Type | Description |
|---|---|---|
item_id | TEXT | Item identifier |
location_id | TEXT | Location identifier |
stock | INTEGER | Current stock quantity |
expired_at | INTEGER | Unix timestamp when stock expires |
updated_at | INTEGER | Last update timestamp |
stock_reservations_merchant_{slug}
| Column | Type | Description |
|---|---|---|
id | TEXT | Reservation identifier |
item_id | TEXT | Item identifier |
reserved_quantity | INTEGER | Reserved quantity |
session_id | TEXT | Cart/order session ID |
expires_at | INTEGER | Unix timestamp when reservation expires |
created_at | INTEGER | Creation timestamp |
constants_merchant_{slug}
| Column | Type | Description |
|---|---|---|
key | TEXT | Constant key |
value | TEXT | JSON-encoded value |
updated_at | INTEGER | Last update timestamp |
Stock Operations
| Method | Description |
|---|---|
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:
| Layer | Behaviour | Source |
|---|---|---|
| Stocks cache | One 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 cache | An 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 registry | merchant_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.
| Method | Sub-path | Purpose |
|---|---|---|
| POST | /chat | Send a message |
| GET | /threads | List threads |
| POST | /threads/rename | Rename a thread (owner-scoped) |
| GET | /history | Thread history |
| POST | /clear | Clear history |
| GET | /config | Client 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-steps | Fetch setup steps from the Emily KB |
| POST | /invalidate-setup-steps-cache | Called 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 code —
DISABLE_CACHE = true(src/emily/shared/setup-steps.js:9). Every/setup-stepscall 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.
| Method | Description |
|---|---|
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:
- Customer adds item to cart ->
createReservation()with 10-minute default expiration - Available stock = total stock - sum of active (non-expired) reservations
- If reservation exists for the session, it is updated rather than duplicated
- When customer places order -> reservations are released via
releaseReservationsBySession() - 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/production2026-08-09). It is 1 h now because/reservationstakes 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 holdsDEFAULT_MAX_RESERVATIONS_PER_SESSION = 100live 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 viaMAX_RESERVATION_TTL_SECONDS/MAX_RESERVATIONS_PER_SESSION(createStockService(),:83-92); neither is set inwrangler.toml, so every environment runs the defaults.
Why the coercion is load-bearing. Sent as a JSON string,
now + expirationSecondsused to concatenate rather than add —1780135653 + "120"→1780135653120, roughly the year 58 400. D1's INTEGER affinity happily stored it, the row then satisfiedexpires_at > nowforever, andcleanupExpiredReservations(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
| Method | Description |
|---|---|
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
| Endpoint | Response |
|---|---|
/health | Overall 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 itemsEach country database contains items scoped by business type. The scope format is {business_type}-{country_code} (e.g., frituur-be, restaurant-fr).
Endpoints
| Method | Path | Auth Required | Description |
|---|---|---|---|
| GET | /item-database-api/items?scope={scope} | Yes | List items with pagination and search |
| GET | /item-database-api/items/{id}?scope={scope} | Yes | Get single item by ID |
| POST | /item-database-api/items | Yes | Create item (supports multipart/form-data) |
| PUT | /item-database-api/items/{id}?scope={scope} | Yes | Update item |
| DELETE | /item-database-api/items/{id}?scope={scope} | Yes | Delete item |
| GET | /item-database-api/suggestions?scope={scope}&search={term} | Yes | Get single most relevant item for autofill |
| GET | /item-database-api/scopes | Yes | List all scopes with pagination |
| POST | /item-database-api/scopes | Yes | Create new scope |
| PUT | /item-database-api/scopes/{id} | Yes | Update scope name |
| DELETE | /item-database-api/scopes/{id} | Yes | Delete scope |
| GET | /item-database-api/ingredients | Yes | List ingredients with pagination |
| POST | /item-database-api/ingredients | Yes | Create ingredient |
| PUT | /item-database-api/ingredients/{id} | Yes | Update ingredient |
| DELETE | /item-database-api/ingredients/{id} | Yes | Delete ingredient |
| GET | /item-database-api/allergens | Yes | List allergens |
| GET | /item-database-api/dietary-preferences | Yes | List dietary preferences |
| GET | /item-database-api/dietary-supplements | Yes | List dietary supplements |
| GET | /item-database-api/ingredient-categories | Yes | List ingredient categories |
| GET | /item-database-api/item-categories | Yes | List item categories |
| POST | /item-database-api/import/items | Yes | Import items (JSON = synchronous; .xlsx = multipart, returns 202) |
| POST | /item-database-api/import/global-entities | Yes | Import global reference entities |
| GET | /item-database-api/import/status/{jobId} | Yes | Poll one import job's status |
| GET | /item-database-api/import/jobs?scope={scope}&limit={n} | Yes | List recent import jobs |
| POST | /item-database-api/migrations/{scope} | Yes | Create tables for a scope |
| GET | /item-database-api/constants | Yes | Get all business types, countries, languages |
| GET | /item-database-api/reference-data/all | No (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
/migrationsendpoint
Environment Configuration
Configured in wrangler.toml with per-environment settings.
Environments
| Environment | Worker Name | Backend URL |
|---|---|---|
production | upvendo-api-proxy | https://backend.upvendo.com |
staging | upvendo-api-proxy-staging | https://staging.backend.upvendo.com |
testing | upvendo-api-proxy-testing | https://testing.backend.upvendo.com |
dev | upvendo-api-proxy-dev | https://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 dataDOMAIN_MAPPINGS-- KV namespace of registered merchant custom domains, read-only here. Every environment points at the same namespace idf248335e67644a3da4b147159be07fa6, exactly asupvendo-custom-domain-routerdoes; environment separation comes from theenvfield inside each record, not from separate namespaces. (Verified:wrangler.tomllines 30, 95, 232, 372, 512.)STOCK_DB-- D1 database for merchant stock managementITEMS_DB_GLOBAL-- D1 database for global item reference dataITEMS_DB_{CC}-- Country-specific D1 databases (US, BE, FR, NL, DE, PT, ES)
Environment Variables
| Variable | Description |
|---|---|
ENVIRONMENT | Current environment name |
LARAVEL_BACKEND_URL | Backend API base URL |
ALLOWED_ORIGINS | Comma-separated list of allowed CORS origins |
D1_API_SECRET | Secret for backend-to-proxy D1 API calls |
BACKEND_IPS | Allowed backend server IPs |
CLOUDFLARE_ACCOUNT_ID | Cloudflare account identifier |
CLOUDFLARE_ACCOUNT_HASH | Cloudflare account hash for image URLs |
Optional, unset in wrangler.toml on origin/production -- read them as "defaults apply unless someone set a secret":
| Variable | Effect when set |
|---|---|
CORS_DEBUG_SECRET | Enables 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_SECONDS | Overrides the 3 600s reservation ceiling (src/stock-service.js:87-89) |
MAX_RESERVATIONS_PER_SESSION | Overrides 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, orALLOWED_ORIGINS[0]when the origin is deniedAccess-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 onorigin/productionafter5923fe8.
The decision runs in two stages. staticAllowedOrigin() (src/auth-helpers.js:54-95) settles everything that needs no I/O, in order:
- No
Originheader ->ALLOWED_ORIGINS[0](line 58). - Exact match against
ALLOWED_ORIGINS-> reflect it (line 59). X-Debug-CORS-SecretmatchingCORS_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).localhost/127.0.0.1-> reflect, in every environment including production (lines 73-75).- Any
.devhostname -> reflect, whenENVIRONMENTis neitherproductionnorstaging(lines 78-80). - Our own platform domains (
hostname.match(/\.(zestidoo|upvendo)\.(com|be|nl|de|fr|co\.uk|dev)$/)) -> governed byALLOWED_ORIGINSalone; not listed means denied, and the registry has no say (lines 84-86). - Unparseable
Origin-> denied (lines 87-90). - 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 onupvendo-backendorigin/production. - Record shape:
{ merchantId, assignedLocations, defaultSlug, env }(DomainProfileService.php:440-445). Theenvvalue comes fromgetKVEnvironment()(:674-685), which maps Laravel'sapp.env:localandtesting->testing,staging->staging,production->production, anything else ->production. - Key normalisation: lowercase, trimmed, port stripped, trailing dot stripped -- and
www.is deliberately NOT stripped, sowww.example.comis a different key fromexample.com. The proxy'snormalizeHost()(src/custom-domains.js:36-38) must stay identical to the router's and to the backend'snormalizeHostname()(DomainProfileService.php:621-629). - Environment scoping: the proxy compares the record's
envagainst its ownENVIRONMENT, withdevaliased totesting(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 logscustom-domains: mapping belongs to another environment. A record with noenvfield, 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_MAPPINGSbinding is missing or the KV read throws,isRegisteredCustomDomain()returnsnulland the origin is reflected anyway, withconsole.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: verifiedin the back office means the KV write succeeded -- verification only promotes the profile whensyncDomainToKV()returned true, otherwise it stayspendingand the merchant is told the TXT record verified but the sync failed (DomainProfileService.php:210-227). So apendingdomain 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 averifieddomain with a stale or missing record. Re-run verification rather than adding the host toALLOWED_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
envfield, so that combination was never legitimate. - A
*.upvendo.com/*.zestidoo.*origin missing fromALLOWED_ORIGINSis denied, as before.order001.zestidoo.devis denied in production (platform domain) and allowed in testing (the.devcarve-out). - CORS is still not an access control -- the
/d1-api/GETs answer a header-lesscurlwith noOriginat 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:
| Header | Value |
|---|---|
Access-Control-Allow-Origin | result of getAllowedOrigin() |
Access-Control-Allow-Methods | GET, POST, PUT, DELETE, OPTIONS, PATCH |
Access-Control-Allow-Headers | Content-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-Credentials | true |
Access-Control-Max-Age | 86400 (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
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.
Cache staleness: the Laravel
/api/*cache TTLs range from 5 minutes to 1 hour; check theX-Cacheresponse 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.Scope initialization: New merchants or business types need their scope tables created via the
/migrationsendpoint before the item database works.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.Environment mismatch: Ensure the frontend is pointing to the correct proxy environment. Staging frontends should hit
upvendo-api-proxy-staging, not production."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_MAPPINGSand gets no CORS; (b) is the record'senvthe environment being called -- a testing-registered domain hitting the production proxy is rejected, and the Worker logscustom-domains: mapping belongs to another environment; (c) does the key match exactly,www.included, sincewww.example.comandexample.comare separate keys; (d) is the request header on the preflightAccess-Control-Allow-Headerslist; (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 toALLOWED_ORIGINSis a workaround, not the fix -- re-run verification so the registry entry exists. To reproduce from an arbitrary origin, setCORS_DEBUG_SECRETand sendX-Debug-CORS-Secret. See "Which origins are allowed" above.Unexpected Slack noise: every 404 from the Worker -- including typo'd paths and scanner traffic -- fires an
ErrorNotificationServiceSlack notification throughctx.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.jslines 308-320 for the 404 path and lines 325-348 for the top-levelcatch.)Item permanently shows as unavailable / phantom
reserved_stock: immortal reservations from a string-concatenatedexpires_at(see "TTL bounds" above). Identify the poisoned rows withexpires_at > created_at + 86400and repair withscripts/repair-immortal-reservations.js. Fixed onorigin/production2026-07-25; rows created before that date can still be poisoned, so check the data even on a patched Worker.