api — REST API
@playpals/api · port 4000 · apps/api
The core service. Every REST endpoint, all authentication, all authorization, and the write path for almost every domain. Three replicas in production.
Running it
pnpm --filter @playpals/api dev # tsx watch, reads .env
pnpm --filter @playpals/api build # tsc → dist/
curl http://localhost:4000/health # {"status":"ok"}Request pipeline
apps/api/src/app.ts assembles middleware in this order — the order matters:
app.use(helmet()); // security headers
app.use(cors({ origin: WEB_APP_URL, credentials: true }));
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(compression());
app.use(pinoHttp({ logger }));
app.use("/api/v1", rateLimit({ windowMs: 60_000, limit: 300 }));
app.use("/api/v1", router);
app.use(errorHandler); // terminal
app.get("/health", …); // outside /api/v1, unmeteredRate limiting is 300 requests per minute per IP, applied only to /api/v1. /health is deliberately outside it so probes never consume budget.
Rate limiting behind an ingress
express-rate-limit keys on the client IP. Behind nginx-ingress every request appears to come from the ingress pod unless trust proxy is configured, which it is not. In production the limit is therefore effectively global rather than per-client. Set app.set("trust proxy", 1) to fix it.
Source layout
src/
├── app.ts Express assembly
├── server.ts listener
├── configs/index.ts typed env config
├── router/ 29 route files, aggregated in index.ts
├── controllers/ 30 controllers
├── services/ 32 services — business logic and Prisma access
├── middleware/ authenticated.ts, error.ts, upload.ts
├── schemas/ shared zod schemas (channel, dm, link)
├── modules/revenuecat/ self-contained RevenueCat integration
├── lib/ jwt, redis, logger, mux, livekit, upload, money,
│ roomAccess, offensive-words-validator
└── types/ AuthRequest and friendsEverything in router/index.ts is mounted under /api/v1.
Domains it owns
| Domain | Routes | Key services |
|---|---|---|
| Auth | authRoutes | auth, settings |
| Users & profiles | userRoutes | user, settings, follow |
| Content | postRoutes, feedRoutes, reactionRoutes | post, feed, comment, reaction, impression |
| Media | mediaRoutes | lib/upload, enqueues to @playpals/queue |
| Messaging | conversationRoutes, channelRoutes, channelRoomRoutes | conversation, channel, channelRoom |
| Live | livestream, channelRoomRoutes | livestream, livekit |
| Commerce | productRoutes, walletRoutes, subscriptionRoutes | product, wallet, subscription |
| Discovery | discover, trendingRoutes, searchRoutes | discover, trending, search |
| Sessions | sessionRoutes, availabilityRoutes | session, availability |
| Notifications | notificationRoutes | notify, notification |
| Webhooks | webhookRoutes | RevenueCat, Mux |
| Interservice | interserviceRoutes | Authorization callbacks for esu |
Full endpoint list: API Reference.
Caching
Redis caches the feed read path:
| Cache | Key | TTL | Invalidation |
|---|---|---|---|
| Feed | feed|u:<userId>|<params> | 60 s | invalidateUserFeedCache(userId) on post create and media completion |
Trending isn't cached. It's precomputed by the trending BullMQ jobs in src/jobs/trending.ts (daily full build at 05:00 UTC, hourly rising build), and requests only read the stored lists. See Trending.
invalidateUserFeedCache uses redis.keys() to find matching keys:
const keys = await redis.keys(`feed|u:${userId}|*`);
if (keys.length) await redis.del(keys);KEYS blocks Redis
KEYS is O(N) over the entire keyspace and blocks the server while it runs. It is called on every post creation. At current volume it is fine; at scale it will cause latency spikes across every service sharing that Redis. Replace with SCAN, or track the key set explicitly in a Redis set per user.
Uploads
Two paths, both landing in DigitalOcean Spaces:
- Presigned —
POST /media/upload/urlreturns a 5-minute presigned PUT; the client uploads directly and then callsPOST /media/upload/complete. - Multipart —
multer-s3streams through the API forPOST /posts, avatars, banners, and channel banners.
Both end by enqueuing to the media-processing queue. See Media Pipeline.
Interservice surface
The API is both a client and a server of the internal channel:
- Calls out to
esuatPOST {REALTIME_SERVICE_URL}/admin/emit/*withX-Realtime-Admin-Token. - Serves
GET /api/v1/dm/:conversationId/authzandGET /api/v1/room/:roomId/authz, whichesucalls to authorize joins.
Notable libraries
| File | Purpose |
|---|---|
lib/jwt.ts | Token signing and verification |
lib/redis.ts | Shared ioredis client |
lib/upload.ts | Spaces client, multer-s3 configs, public URL builder |
lib/mux.ts | Mux SDK singleton |
lib/livekit.ts | Room provisioning and access token minting |
lib/roomAccess.ts | canAccessRoom — the channel paywall logic |
lib/money.ts | Currency helpers; all amounts are integer cents |
lib/offensive-words-validator/ | Trie-based profanity filter for usernames |
lib/count.ts | Shared aggregate count helpers |
Background jobs
The API runs these in-process. Each one is listed in src/jobs/registry.ts, which is also what the admin Jobs page shows.
| Job | Queue | Schedule | Does |
|---|---|---|---|
trending-daily | trending | Daily 05:00 UTC | Scores the week's posts, rebuilds every trending list (Trending) |
trending-rising | trending | Hourly at :10 | Re-scores trending posts; refreshes the Explore feed, clips and discovery slots |
sweep | session-lifecycle | Every minute | Expires lapsed holds; sends session and VIP renewal reminders |
end | session-lifecycle | On demand | Closes a session's room at its end time |
fanout / deliver | live-notify | On demand (go live) | Pages a host's followers, then notifies them in batches |
sweep | live-sweeper (not BullMQ) | Every minute, one replica | Ends phone broadcasts whose host vanished |
TRENDING_WORKER, SESSION_WORKER and LIVE_NOTIFY_WORKER set to false stop a replica running that worker.
The registry also lists anansi's jobs (queue anansi: payout-calculate, payout-transfer, payout-retry, maturation, reconciliation). anansi runs them and records their history in the same Redis keys; "Run now" on maturation or reconciliation asks anansi over its admin API (ANANSI_URL, ANANSI_ADMIN_SECRET). The payout phases need a month, so they're run from the Payouts page instead (anansi).
Observability
Every run goes through runObserved (src/lib/jobRuns.ts), so all jobs share one contract:
- Logs (module
jobs):job completedat info andjob failedat error, with{ job, queue, jobId, trigger, attempt, durationMs, result }(+erron failure).triggerisscheduled,manual(admin "Run now"),startup(trending bootstrap) orevent. High-volume jobs (deliver,end) log success at debug, so production only sees their failures. A failure is rethrown, so BullMQ's retries still apply. Each attempt is logged. - History: the last 50 runs per job are kept in Redis at
jobs:runs:<queue>:<job>(30-day TTL). A run with nothing to do (the live sweeper on a replica without the lock) isn't recorded. Recording never fails a job.
Where to look:
- Admin → Jobs (
/jobs): health per job, recent runs and failed attempts still in the queue, plus "Run now" for the trending builds and the session sweep. A scheduled job is not running when it has no success within two of its intervals plus 5 minutes, and failing when its latest run failed. - Grafana → API jobs row: completions and failures per job, p95 duration, trending build output and a run log. See
infra/grafana/README.md.
To add a job: add it to src/jobs/registry.ts and wrap its processor in runObserved(JOBS.<name>, fn, job). It then shows up on both surfaces.
Configuration
| Variable | Default | Notes |
|---|---|---|
PORT | 3000 | Set to 4000 everywhere in practice |
ENV / NODE_ENV | development | |
DATABASE_URL | — | Required |
JWT_SECRET | "supersecretkey" | Must be set in production |
RESET_SECRET | "supersecretkey" | Must be set in production |
JWT_EXPIRATION_MINUTES | 60 | Lifetime of refreshable (mobile) access tokens; legacy tokens stay 7 days |
REFRESH_TOKEN_TTL_DAYS | 90 | Sliding lifetime of a refresh session, extended on every refresh |
REDIS_URL / REDIS_HOST / REDIS_PORT / REDIS_PASSWORD | localhost | REDIS_URL wins |
WEB_APP_URL | http://localhost:3000 | CORS origin |
REALTIME_SERVICE_URL | — | esu base; the /admin prefix is added by the helper |
REALTIME_ADMIN_TOKEN | — | Shared with esu |
SPACES_* | — | Object storage |
MUX_ACCESS_TOKEN / MUX_SECRET_KEY / MUX_WEBHOOK_SECRET | — | Video |
LIVEKIT_HOST / LIVEKIT_WS_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET | — | Voice |
REVENUECAT_WEBHOOK_SECRET | — | Purchase webhook auth |
STRIPE_PUBLISHABLE_KEY / STRIPE_SECRET_KEY | — | Present in the deployment |
TRENDING_WORKER | true | false stops this replica scheduling and running the trending builds |
Full matrix: Environment Variables.
Testing
Jest, ts-jest, and supertest are configured — with a Prisma mock, a setup file, and coverage thresholds — but there are no test files. pnpm test therefore exits non-zero because testMatch finds nothing.
cd apps/api
pnpm test # fails: no tests found
pnpm test:watch
pnpm test:coverageThe scaffolding is ready to use; Testing lists the highest-value places to start.
Deployment
| Property | Value |
|---|---|
| Helm values | infra/helm/values/production/api.yaml (release playpalz-api) |
| Replicas | 3, rolling update with maxUnavailable: 0 |
| Probes | Liveness and readiness on GET /health port 4000 |
| Resources | Requests and limits set per container |
| Security | Non-root (uid 1001), allowPrivilegeEscalation: false, all capabilities dropped |
| Placement | Pod anti-affinity across nodes |
| Ingress | api.playpalz.gg |
Build and push:
./scripts/build-api.sh [tag]Known issues
- The error middleware reads
err.codewhereHttpErrorsets.status, so thrownHttpErrors respond 500. See Coding Standards. - The Mux webhook does not verify its signature. See Livestreaming.
POST /webhooks/stripeis an empty handler.forgotPasswordandresetPasswordservice functions are empty stubs.controllers/channelOLD.tsandservices/channelOLD.tsare dead code.router/test.tsexposesGET /api/v1/test-messagein production.express-rate-limitis not proxy-aware, so the limit is effectively global. See above.
