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.md documents a sibling Vercel app deliberately keeping serverless egress off direct rs0 exposure for the same reason; and infra-src/redis-bridge already 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, mongodb driver. Same layout, tsconfig, Dockerfile (multi-stage node:20-alpine, non-root USER node) and vitest setup as redis-bridge.
  • Domain: pinbox-mongo-proxy.bms-4.infra.zintegrowana.online (Traefik host rule + TLS challenge, same label block as redis-bridge).
  • Runs in: bms-4/docker-compose.yml as a new service pinbox-mongo-proxy.

2.2 Endpoints (deliberately narrow — find-only, single collection)

MethodPathAuthBehaviour
GET/healthnone200 ok — liveness only, never touches Mongo
POST/recordsX-Proxy-KeyRuns 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-Key header must equal env PINBOX_PROXY_SHARED_SECRET (constant-time compare). Missing/wrong → 401. This is a new SOPS-managed key — creation is delegated to secret-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 find is issued. No insert/update/delete/aggregate path exists in the code.
  • Operator-injection guard: the request filter/projection/sort must be plain JSON objects, and the filter is 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 a find filter can turn into code execution.
  • Result cap: limit defaults to MAX_LIMIT (default 1000) and is clamped to it; skip/limit must 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 MongoClient is 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 as 502.
  • 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 Mongo

Contract 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 whatever filter/projection the 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

NameRequiredPurpose
PINBOX24_MONGODB_URIyeset_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_SECRETyesPre-shared auth secret. New keysecret-manager creates it in SOPS and distributes to bms-4 and to Vercel (et-op) so both ends share it (§5).
MONGO_DB_NAMEno (w4_db)Fixed database.
MONGO_COLLECTIONno (regRecords)Fixed collection.
MAX_LIMITno (1000)Hard cap on returned documents.
PORTno (3000)Listen port inside the container.

3. Files

New

FilePurpose
infra-src/pinbox-mongo-proxy/src/index.tsHono app — health + /records, auth, guards, Mongo client.
infra-src/pinbox-mongo-proxy/tests/proxy.test.tsvitest — auth, injection guard, pagination bounds, happy path (mongodb mocked).
infra-src/pinbox-mongo-proxy/package.jsondeps: hono, @hono/node-server, mongodb.
infra-src/pinbox-mongo-proxy/tsconfig.jsoncopy of redis-bridge tsconfig.
infra-src/pinbox-mongo-proxy/Dockerfilecopy of redis-bridge multi-stage Dockerfile.
infra-src/pinbox-mongo-proxy/.dockerignorecopy of redis-bridge.
infra-src/pinbox-mongo-proxy/README.mdendpoints, env, deploy, shared-secret note.
docs/pinbox-mongo-proxy-operations.mdops runbook + compliance registration.
docs/designs/5408-pinbox-mongo-proxy.mdthis document.

Modified

FileChange
bms-4/docker-compose.ymladd 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. Traefik exposedbydefault=false means 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 in bms-4/.env before docker compose up, or requireEnv exits the container on boot (fail-closed, by design). Ordering is enforced in §5.
  • rs0 read load: one more read client on rs0. find is bounded by MAX_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:

  1. 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_URI value to bms-4/.env (today it lives in secrets/et-operational-platform.env.sops, synced to Vercel only).
    • dev-coder references key names only and never writes secrets/*.env.sops.
  2. Deploy (sys-admin / infra-task on bms-4). After the two keys are in bms-4/.env: docker compose build pinbox-mongo-proxy && docker compose up -d pinbox-mongo-proxy, verify GET /health and one authenticated /records call, confirm the Traefik cert issued.
  3. et-op route swap (cross-repo follow-up). Change src/pages/api/pinbox/records.ts to fetch() 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/healthok (valid TLS cert).
  • POST /records with a wrong/absent X-Proxy-Key401.
  • POST /records with a filter containing $where400 forbidden_operator.
  • POST /records with 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.