Design — pinbox-mongo-proxy on bms-4 (authenticated read-only rs0 proxy for Vercel)
Issue: #5408 · Status: Design → implementation (plan-first) · Related: #5400
1. Problem
/zarzad/pinbox on et-operational-platform (Vercel, team devp24coms-projects, Pro plan) returns
500 on GET /api/pinbox/records from the deployed environment while local dev succeeds (#5400). Root
cause is confirmed: the route reads w4_db.regRecords from the Pinbox24 rs0 replica set using the
et_oper credential, but ufw on bms-2 (145.239.133.104) / bms-3 (51.68.155.224) allowlists
inbound 27017 only to static peers (rs0 members, vps-i1, admin). Vercel serverless functions (region
fra1) egress from rotating public IPs with no allowlist entry, so every deployed request is
dropped at the firewall. The et_oper credential itself is valid (already verified from bms-4 against
both PRIMARY and SECONDARY).
Constraints that rule out the obvious fixes:
- Opening 27017 to the internet is forbidden (
CLAUDE.md§Do NOT;docs/w3-w4-stack-operations.md). Vercel publishes no stable egress CIDR to allowlist. - Vercel static-IP / Secure Compute (a stable egress IP we could allowlist) requires an Enterprise plan; the team is on Pro. Out of scope without a separate paid-upgrade decision.
- Precedent:
docs/designs/2737-et-lager-mongodb-atlas-inventory.mddocuments a sibling Vercel app deliberately keeping serverless egress off direct rs0 exposure for the same reason; andinfra-src/redis-bridgealready solves the exact mirror-image problem (a serverless component that cannot reach a bms-4-internal datastore directly) with a narrow, key-gated HTTP shim on bms-4 behind Traefik.
2. Chosen solution
A minimal authenticated, read-only HTTPS proxy on bms-4, modeled 1:1 on redis-bridge.
bms-4 (54.36.123.110) is already on the rs0 27017 allowlist, and already runs Traefik on
80/443 open to the internet with automatic Let’s Encrypt certs. So this adds no new ufw rule and
no new open port — it reuses the existing Traefik ingress, exactly as redis-bridge does. The proxy
holds the et_oper MongoDB URI in its own container env; no MongoDB URI ever reaches Vercel. Vercel
holds only the shared proxy secret and calls one HTTPS endpoint.
Vercel /api/pinbox/records ──HTTPS + X-Proxy-Key──▶ Traefik (bms-4 :443)
│
pinbox-mongo-proxy (Hono, :3000)
│ et_oper cred (PINBOX24_MONGODB_URI)
▼
rs0 w4_db.regRecords (read-only find)
2.1 Service
- Path:
infra-src/pinbox-mongo-proxy/— TypeScript, Hono +@hono/node-server,mongodbdriver. Same layout, tsconfig, Dockerfile (multi-stagenode:20-alpine, non-rootUSER node) and vitest setup asredis-bridge. - Domain:
pinbox-mongo-proxy.bms-4.infra.zintegrowana.online(Traefik host rule + TLS challenge, same label block asredis-bridge). - Runs in:
bms-4/docker-compose.ymlas a new servicepinbox-mongo-proxy.
2.2 Endpoints (deliberately narrow — find-only, single collection)
| Method | Path | Auth | Behaviour |
|---|---|---|---|
GET | /health | none | 200 ok — liveness only, never touches Mongo |
POST | /records | X-Proxy-Key | Runs w4_db.regRecords.find(filter, {projection, sort}).skip(skip).limit(limit) and returns the same JSON shape the et-op route returns today (see §2.4). Body is an optional { filter?, projection?, sort?, skip?, limit? }. |
POST (not GET) for /records so the caller can forward its existing query object as a JSON body
without URL-length / encoding limits — the et-op route builds the query server-side, so forwarding it is
the “narrow swap, not a rewrite” the issue asks for.
2.3 Auth + hardening (honour-system boundary → hard guards)
X-Proxy-Keyheader must equal envPINBOX_PROXY_SHARED_SECRET(constant-time compare). Missing/wrong →401. This is a new SOPS-managed key — creation is delegated tosecret-manager(§5). Never hardcoded.- Collection is hardcoded to
w4_db.regRecords(DB/collection come from env with those defaults); the caller cannot select a different DB or collection. - Read-only: only
findis issued. No insert/update/delete/aggregate path exists in the code. - Operator-injection guard: the request
filter/projection/sortmust be plain JSON objects, and thefilteris rejected (400) if it contains any of$where,$function,$accumulator,$expr— the operators that can execute server-side JS or arbitrary expressions. This blocks the one way afindfilter can turn into code execution. - Result cap:
limitdefaults toMAX_LIMIT(default1000) and is clamped to it;skip/limitmust be non-negative integers. Prevents an unbounded scan of the whole collection. - Body size: requests over 64 KB are rejected (
413) — a query object is tiny. - No secret in logs: errors log the Mongo error message only, never the URI or the proxy key
(same rule as
redis-bridge).
Connection lifecycle (from plan review #5408):
- A single pooled
MongoClientis created once at boot and reused across requests — never a connect-per-request (which would exhaust rs0 connections under load and add latency). The client connects lazily on first use with the driver’s built-in retry; a failed connect surfaces as502. - The find pins
readPreference=secondaryPreferred(unless the URI already sets one) so this extra client keeps read load off the rs0 PRIMARY.
2.4 Response contract
The et-op route today returns the regRecords documents. The proxy returns them in a stable envelope:
// 200
{ "records": [ /* regRecords documents, as returned by find().toArray() */ ], "count": <number> }
// 401 { "error": "unauthorized" }
// 400 { "error": "invalid_json" | "invalid_filter" | "forbidden_operator" | "invalid_pagination" }
// 413 { "error": "body_too_large" }
// 502 { "error": "mongo_query_failed" } // rs0 unreachable / auth failure at MongoContract finalisation is cross-repo and out of scope here (issue point 6). The exact filter/projection the et-op route currently builds lives in that repo (
src/pages/api/pinbox/records.ts). This proxy forwards whateverfilter/projectionthe authenticated caller sends, so the et-op change is a narrow swap of the data-fetching call (direct Mongo →fetch()to this proxy) with the same query object. The precise envelope (bare array vs{records,count}) will be pinned in the follow-up so both sides agree;{records,count}is the proposed default. This issue does not modify et-op — the follow-up is filed as a comment on #5400 / an et-op issue.
2.5 Env vars
| Name | Required | Purpose |
|---|---|---|
PINBOX24_MONGODB_URI | yes | et_oper rs0 connection string. Key name only — value lives in secrets/et-operational-platform.env.sops; must be distributed to bms-4/.env by secret-manager / secrets-sync.yml (§5). Never printed. |
PINBOX_PROXY_SHARED_SECRET | yes | Pre-shared auth secret. New key — secret-manager creates it in SOPS and distributes to bms-4 and to Vercel (et-op) so both ends share it (§5). |
MONGO_DB_NAME | no (w4_db) | Fixed database. |
MONGO_COLLECTION | no (regRecords) | Fixed collection. |
MAX_LIMIT | no (1000) | Hard cap on returned documents. |
PORT | no (3000) | Listen port inside the container. |
3. Files
New
| File | Purpose |
|---|---|
infra-src/pinbox-mongo-proxy/src/index.ts | Hono app — health + /records, auth, guards, Mongo client. |
infra-src/pinbox-mongo-proxy/tests/proxy.test.ts | vitest — auth, injection guard, pagination bounds, happy path (mongodb mocked). |
infra-src/pinbox-mongo-proxy/package.json | deps: hono, @hono/node-server, mongodb. |
infra-src/pinbox-mongo-proxy/tsconfig.json | copy of redis-bridge tsconfig. |
infra-src/pinbox-mongo-proxy/Dockerfile | copy of redis-bridge multi-stage Dockerfile. |
infra-src/pinbox-mongo-proxy/.dockerignore | copy of redis-bridge. |
infra-src/pinbox-mongo-proxy/README.md | endpoints, env, deploy, shared-secret note. |
docs/pinbox-mongo-proxy-operations.md | ops runbook + compliance registration. |
docs/designs/5408-pinbox-mongo-proxy.md | this document. |
Modified
| File | Change |
|---|---|
bms-4/docker-compose.yml | add pinbox-mongo-proxy service (build + env ${PINBOX24_MONGODB_URI} / ${PINBOX_PROXY_SHARED_SECRET} + Traefik labels + HSTS), mirroring the redis-bridge block. |
CHANGELOG.md | [Unreleased] entry. |
4. Regression risks
- None to existing services. New container, new subdomain, no shared state, no ufw change, no change
to rs0 or to
redis-bridge/Traefik config beyond adding one router. Traefikexposedbydefault=falsemeans the new labels only affect the new container. - Deploy ordering: the compose service references
${PINBOX24_MONGODB_URI}and${PINBOX_PROXY_SHARED_SECRET}. Both must be present inbms-4/.envbeforedocker compose up, orrequireEnvexits the container on boot (fail-closed, by design). Ordering is enforced in §5. - rs0 read load: one more read client on rs0.
findis bounded byMAX_LIMIT; reads land on a SECONDARY via the driver’s default read preference if the URI sets one (unchanged from et-op today).
5. Ownership, delegation & sequencing (role boundaries)
This is a dev-coder deliverable. Three parts are out of dev-coder scope and delegated — the PR
is therefore Partially implements: #5408:
- Secrets (
secret-manager).- Create
PINBOX_PROXY_SHARED_SECRET(new) in a SOPS file and distribute to bms-4/.env and to Vercel/et-op env. - Distribute the existing
PINBOX24_MONGODB_URIvalue to bms-4/.env (today it lives insecrets/et-operational-platform.env.sops, synced to Vercel only). dev-coderreferences key names only and never writessecrets/*.env.sops.
- Create
- Deploy (
sys-admin/ infra-task on bms-4). After the two keys are inbms-4/.env:docker compose build pinbox-mongo-proxy && docker compose up -d pinbox-mongo-proxy, verifyGET /healthand one authenticated/recordscall, confirm the Traefik cert issued. - et-op route swap (cross-repo follow-up). Change
src/pages/api/pinbox/records.tstofetch()this proxy instead of connecting to rs0 directly. Filed as a comment on #5400 / an et-op issue — not modified in this issue.
Compliance (this PR): docs/pinbox-mongo-proxy-operations.md ops doc created; dev_r_services
registration row attempted (best-effort — dev_r_services is a VIEW, so the write may need a
secret-manager/admin follow-up; noted in the ops doc and PR).
6. Manual test checklist (post-deploy, for the sys-admin deployer)
-
curl https://pinbox-mongo-proxy.bms-4.infra.zintegrowana.online/health→ok(valid TLS cert). -
POST /recordswith a wrong/absentX-Proxy-Key→401. -
POST /recordswith afiltercontaining$where→400 forbidden_operator. -
POST /recordswith the correct key and an empty body →200 { records, count }, count ≤MAX_LIMIT. - Container logs contain no URI, password, or proxy-key value.
Plan-first design. Related: #5400, infra-src/redis-bridge, docs/designs/2737-et-lager-mongodb-atlas-inventory.md.