Skip to content
connexoecPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Connexo API

The unified backend that replaces Supabase for sellers/ (Connexo Ventas) and clients/ (Connexo profiles/businesses).

Implements docs/backend-migration-research.md. Section numbers below refer to that document.

Stack — TypeScript (strict) on Node 24 · NestJS · PostgreSQL · Prisma · Zod · Socket.IO · BullMQ + Redis · S3-compatible storage · pnpm · Docker.


Quick start

cp .env.example .env          # then fill in JWT_ACCESS_SECRET / JWT_REFRESH_SECRET
docker compose up -d          # Postgres + Redis + MinIO + migrations + the API
curl http://localhost:3000/api/health

up applies prisma migrate deploy in a one-shot migrate container and starts the API only once it exits 0, so an empty volume comes up migrated. To re-run migrations by hand later:

docker compose run --rm migrate

Note the runtime image carries node and the Prisma binary, but no pnpm — inside the container, call node_modules/.bin/prisma, not pnpm prisma.

Generate the two JWT secrets with openssl rand -hex 32. The app validates its whole environment at boot and refuses to start on a bad value, rather than failing later at send time (§5b.3).

Running the API on the host instead:

pnpm install
docker compose up -d postgres redis minio minio-init
pnpm prisma migrate dev
pnpm start:dev            # OpenAPI at /api/docs outside production

Verifying a change

pnpm test          # unit tests for the commission / tier policies
pnpm test:smoke    # end-to-end against a running API (see test/smoke.cjs)

pnpm test:smoke needs DATABASE_URL in the environment and the API up. It covers the paths that carry money or secrets: server-side pricing, both wallet sides of a sale and its deletion, cross-context token rejection, and the response allow-lists on public endpoints.


Layout

One deployable, organised by bounded context → feature module (§5b.1).

src/
├─ config/          Zod-validated environment; fails fast at boot
├─ shared/          Cross-cutting, no business logic
│  ├─ prisma/       The single PrismaService
│  ├─ auth/         JWT + bcrypt, guards, @CurrentUser() / @Roles()
│  ├─ realtime/     RealtimeGateway + RealtimePublisher port
│  ├─ storage/      StoragePort → S3 or local adapter
│  ├─ push/         PushPort → web-push adapter + BullMQ processor
│  ├─ mail/         MailPort → Resend adapter
│  ├─ alerts/       AlertPort → Slack / Discord / email fan-out
│  ├─ ai/           CatalogVisionPort (Gemini), CopywriterPort (Anthropic)
│  ├─ payments/     PaymentGatewayPort → PayPhone adapter
│  ├─ notifications/ Shared notification + push-subscription services
│  └─ errors/       Domain exceptions + one global exception filter
├─ sellers/         auth · profiles · sales · inventory · sedes · academy · dev
└─ clients/         auth · profiles · links · analytics · chat · admin ·
                    telemetry · ai · public · verticals

Inside a feature: *.controller.ts (HTTP only) → use-cases/ (one business operation per class) → repositories/ (persistence) → domain/ (pure policies, no I/O).

The 25 owner-scoped resources

§4.2 lists ~25 near-identical CRUD resources across the six verticals. They are declared as data in clients/verticals/resource-definitions.ts and served by one generated controller each (owner-crud/). The ownership check therefore exists once, instead of being re-implemented 25 times. Resources with real rules — orders, reservations, stock — keep their own use cases.


What replaced what

Supabase Here
PostgREST table access NestJS controllers → use cases → Prisma repositories
~60 RPC functions Use cases under clients/public/ and clients/admin/
Supabase Auth shared/auth (JWT access + rotating refresh, bcrypt)
RLS policies JwtAuthGuard (deny by default), RolesGuard, owner-scoped queries
Storage bucket assets StoragePort → S3/R2, presigned PUT for video
Realtime (23 subscriptions) One authenticated socket per user, room-scoped
8 Edge Functions Routes and BullMQ processors (below)
DB webhooks / pg_net triggers Domain events → queued jobs
expire_trials run by hand @Cron nightly, plus POST /admin/jobs/expire-trials

Edge functions

Function Now
sendPush shared/push/push.processor.ts
notifyError POST /api/telemetry/errors → alerts queue
notifyAdminEvent clients/admin/audit.service.ts → alerts queue
extractCatalog POST /api/clients/ai/extract-catalog
generateCopySuggestions POST /api/clients/ai/copy-suggestions
payphone-process and the missing payphone_charge POST /api/public/profiles/:ownerId/payments/payphone
startChat (missing from the repo) POST /api/clients/me/chats

Security fixes carried out

Each item is §6 of the research doc.

# Finding Fix
1 Sellers stored plaintext passwords, compared in the browser bcrypt, verified server-side. password → password_hash, never selected outside the login flow
2 VITE_SUPER_ADMIN_PASSWORD shipped in the public JS bundle Gone. A super admin is a row with role = SUPER_ADMIN
3 Commission / wallet / tier computed in the browser and written directly PricingPolicy + CommissionPolicy + TierPolicy, server-side. The request cannot carry amount, commission or sellerId
4 purgeAllData callable by any browser /sellers/dev/*, triple-gated: env flag, non-production, SUPER_ADMIN. The env schema refuses the flag in production outright
5 payphone_charge took the merchant's token from the browser Credentials read from barber_payment_secrets server-side; the request body has no token field
6 AI and push functions deployed --no-verify-jwt Authenticated and individually rate-limited
7 RLS gave anon SELECT on all profile columns The public profile response is an explicit allow-list; paymentGateways and calendarWebhookUrl never leave the server
8 Non-atomic read-then-write on wallets and stock prisma.$transaction with atomic increment / guarded decrement
9 ChatList subscribed to every chat in the database Chat rooms are membership-checked before a socket may join
10 env files not git-ignored .gitignore covers .env, .env.* and a bare env

Two more, beyond the list:

  • deleteSale never reverted the parent distributor's override (§3.3), so deleting a sale left the distributor permanently overpaid. The override is now recorded on the sale row and both wallets are reverted in one transaction.
  • Public order totals came from the browser. place_gastro_order and friends accepted p_total_price. Totals are recomputed from the owner's own catalogue; the submitted value is ignored.

Request logging

Every request that reaches the process produces one log line. In production that line is JSON on stdout, which is what Railway, Render, Fly, CloudWatch and Loki ingest and parse into queryable fields.

{"time":"2026-09-16T02:35:49.874Z","level":"info","msg":"http_request",
 "env":"production","requestId":"574b700e-…","method":"POST",
 "url":"/api/sellers/sales","statusCode":201,"durationMs":25.82,
 "ip":"200.31.х.х","userAgent":"…","userId":"269feb15-…",
 "authContext":"sellers","role":"SELLER","bytes":469}

Locally it is a readable line instead:

WARN [HTTP] POST /api/sellers/auth/login 401 203.05ms [anon] ACCOUNT_NOT_FOUND (6b500d76)

It is middleware, not an interceptor (shared/observability/). A Nest interceptor runs after the guards, so everything rejected by JwtAuthGuard (401), RolesGuard (403) or ThrottlerGuard (429) would never be logged — which is precisely the traffic worth watching on a deployed service. Middleware also catches 404s for routes that do not exist, and requests the client aborts mid-flight ("aborted": true).

Levels — error for 5xx, warn for 4xx or anything slower than HTTP_LOG_SLOW_MS, info otherwise. So level:"error" alone is a usable alerting filter.

Correlation — each request gets an x-request-id (an inbound one from a load balancer is reused when it looks sane). It comes back as a response header and inside every error envelope, so a user can quote the id from a failure and it will match the log line. RequestContextStore.requestId() reaches it from anywhere without threading it through signatures.

errorCode — the global exception filter stamps the domain error code onto the entry, so failures are greppable by cause (INSUFFICIENT_STOCK, DATABASE_ERROR) and not only by status.

What is deliberately not logged

Redaction is a security control here, not tidiness — these requests carry passwords, PayPhone merchant credentials, Web Push keys and clinical data.

  • Authorization and Cookie never appear: header capture is an allow-list (HTTP_LOG_HEADERS=true), so a header a proxy adds tomorrow does not silently start being logged.
  • Query values are redacted by key. ?identifier= on a member lookup is an email or phone number and ?memberCode= unlocks a clinical record, so the raw URL is never logged — keys are kept, sensitive values become [redacted].
  • Bodies are off by default (HTTP_LOG_BODY). Even when enabled, redact() blanks secrets and PII, truncates long strings and handles circular refs.

src/shared/observability/redact.spec.ts pins this contract (14 tests): a regression there is a data leak, not a cosmetic bug.

Configuration

Variable Default Purpose
HTTP_LOG_ENABLED true Master switch
HTTP_LOG_FORMAT auto auto = JSON in production, pretty elsewhere
HTTP_LOG_SLOW_MS 1000 Above this, a 2xx is logged at warn
HTTP_LOG_IGNORE_PATHS /api/health Health probes would drown the log
HTTP_LOG_HEADERS false Adds the allow-listed headers
HTTP_LOG_BODY false Adds the redacted body. Debugging only
TRUST_PROXY 0 Proxy hops in front of the app

Set TRUST_PROXY=1 when deploying behind a platform router. Without it Express reads the proxy's address, so every log line shows the same IP — and, more seriously, ThrottlerGuard buckets all traffic as one client, meaning one busy user can rate-limit everybody.

Known deviations from the research doc

Worth stating plainly rather than discovering later.

  • Repositories are not universal. §5b.2 (Dependency Inversion) says use cases should depend on repository ports and only adapters should import Prisma. sellers/sales and sellers/profiles do that. Most clients/ use cases and the plain-CRUD controllers inject PrismaService directly, because a port per vertical would have roughly tripled the surface for no behavioural gain. ESLint reports these as warnings (39 of them) so the debt stays visible; eslint.config.mjs has the rule. Worth paying down for anything that grows real rules.
  • The AI copy adapter does not set output_config.effort. low would suit four short marketing lines, but @anthropic-ai/sdk 0.68 does not type the parameter. Add it when the SDK exposes it, rather than casting past the types.
  • e2e coverage is a smoke suite, not per-endpoint tests. §5b.6 asks for Supertest coverage of every endpoint in §3.4/§4. What exists is test/smoke.cjs (35 assertions over the money and secret-bearing paths) plus 38 unit tests on the commission/tier policies. The per-endpoint suite and the socket.io-client gateway tests are still to write.
  • clients.profiles is modelled as ~70 explicit columns. That mirrors what the frontends read today. If the Phase-0 dump shows a different shape, the db pull wins.

Still to do before cutover

Phase 0 of §8 is still blocking. The live SQL is in neither repo, so the Prisma schema here was reconstructed from the frontend call sites and the TypeScript types. Before migrating real data:

  1. pg_dump --schema-only both projects (wxkqmotksjcjwhytodvx, aisjtkezgumawgjmwckb).
  2. Restore locally and run pnpm prisma:pull. The dump wins over schema.prisma; @map/@@map are already in place so the names in code survive the pull.
  3. supabase functions download payphone_charge startChat — both are deployed but absent from the repo, so their exact behaviour is still inferred.
  4. Read the RLS policies. They are the current authorization spec, and the guards here were written from the frontend's behaviour, not from them.
  5. Export auth.users into clients.users. The bcrypt hashes move as-is, so no client needs to reset their password (§4.1).
  6. Hash the sellers' plaintext passwords into password_hash and set must_change_password = true for anyone still on connexo123.

Also open, from §9: whether the two apps should share one identity, and whether the sellers offline mode survives. This backend assumes no to both — sellers' localStorage business fallbacks should be dropped, keeping only session and UI caches (§7).


Notes on the container

  • node:24-bookworm-slim, not Alpine: Prisma ships a first-class debian-openssl-3.0.x engine, so there is no musl guesswork.
  • Runs as the stock node user (uid 1000), read-only root filesystem, all capabilities dropped.
  • dumb-init is PID 1, so SIGTERM reaches Node and enableShutdownHooks() closes Prisma, Redis and BullMQ cleanly.
  • pnpm-workspace.yaml must be present in the install stages: from pnpm 10 it carries onlyBuiltDependencies, and without it bcrypt's native binding is silently skipped — the image builds fine and dies at startup.
  • prisma is a runtime dependency, not a dev one. The client is generated against the production tree, and the image can run prisma migrate deploy.

Scaling

main.ts installs the Socket.IO Redis adapter, so more than one instance works without a user on instance A missing an event emitted by instance B. BullMQ workers run in-process; move them to their own deployment by booting the same image with the HTTP server disabled if queue volume warrants it.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages