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 two expensive read paths:
| Cache | Key | TTL | Invalidation |
|---|---|---|---|
| Feed | feed|u:<userId>|<params> | 60 s | invalidateUserFeedCache(userId) on post create and media completion |
| Trending | per trending endpoint | 5 min | TTL only |
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 |
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 | Read into config but unused — generateToken hard-codes 7 days |
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 |
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 |
|---|---|
| Manifest | infra/k8s/api-deployment.yaml |
| 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.
