Skip to content

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

bash
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:

ts
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, unmetered

Rate 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 friends

Everything in router/index.ts is mounted under /api/v1.

Domains it owns

DomainRoutesKey services
AuthauthRoutesauth, settings
Users & profilesuserRoutesuser, settings, follow
ContentpostRoutes, feedRoutes, reactionRoutespost, feed, comment, reaction, impression
MediamediaRouteslib/upload, enqueues to @playpals/queue
MessagingconversationRoutes, channelRoutes, channelRoomRoutesconversation, channel, channelRoom
Livelivestream, channelRoomRouteslivestream, livekit
CommerceproductRoutes, walletRoutes, subscriptionRoutesproduct, wallet, subscription
Discoverydiscover, trendingRoutes, searchRoutesdiscover, trending, search
SessionssessionRoutes, availabilityRoutessession, availability
NotificationsnotificationRoutesnotify, notification
WebhookswebhookRoutesRevenueCat, Mux
InterserviceinterserviceRoutesAuthorization callbacks for esu

Full endpoint list: API Reference.

Caching

Redis caches the two expensive read paths:

CacheKeyTTLInvalidation
Feedfeed|u:<userId>|<params>60 sinvalidateUserFeedCache(userId) on post create and media completion
Trendingper trending endpoint5 minTTL only

invalidateUserFeedCache uses redis.keys() to find matching keys:

ts
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:

  • PresignedPOST /media/upload/url returns a 5-minute presigned PUT; the client uploads directly and then calls POST /media/upload/complete.
  • Multipartmulter-s3 streams through the API for POST /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 esu at POST {REALTIME_SERVICE_URL}/admin/emit/* with X-Realtime-Admin-Token.
  • Serves GET /api/v1/dm/:conversationId/authz and GET /api/v1/room/:roomId/authz, which esu calls to authorize joins.

Notable libraries

FilePurpose
lib/jwt.tsToken signing and verification
lib/redis.tsShared ioredis client
lib/upload.tsSpaces client, multer-s3 configs, public URL builder
lib/mux.tsMux SDK singleton
lib/livekit.tsRoom provisioning and access token minting
lib/roomAccess.tscanAccessRoom — the channel paywall logic
lib/money.tsCurrency helpers; all amounts are integer cents
lib/offensive-words-validator/Trie-based profanity filter for usernames
lib/count.tsShared aggregate count helpers

Configuration

VariableDefaultNotes
PORT3000Set to 4000 everywhere in practice
ENV / NODE_ENVdevelopment
DATABASE_URLRequired
JWT_SECRET"supersecretkey"Must be set in production
RESET_SECRET"supersecretkey"Must be set in production
JWT_EXPIRATION_MINUTES60Read into config but unused — generateToken hard-codes 7 days
REDIS_URL / REDIS_HOST / REDIS_PORT / REDIS_PASSWORDlocalhostREDIS_URL wins
WEB_APP_URLhttp://localhost:3000CORS origin
REALTIME_SERVICE_URLesu base; the /admin prefix is added by the helper
REALTIME_ADMIN_TOKENShared with esu
SPACES_*Object storage
MUX_ACCESS_TOKEN / MUX_SECRET_KEY / MUX_WEBHOOK_SECRETVideo
LIVEKIT_HOST / LIVEKIT_WS_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRETVoice
REVENUECAT_WEBHOOK_SECRETPurchase webhook auth
STRIPE_PUBLISHABLE_KEY / STRIPE_SECRET_KEYPresent 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.

bash
cd apps/api
pnpm test           # fails: no tests found
pnpm test:watch
pnpm test:coverage

The scaffolding is ready to use; Testing lists the highest-value places to start.

Deployment

PropertyValue
Manifestinfra/k8s/api-deployment.yaml
Replicas3, rolling update with maxUnavailable: 0
ProbesLiveness and readiness on GET /health port 4000
ResourcesRequests and limits set per container
SecurityNon-root (uid 1001), allowPrivilegeEscalation: false, all capabilities dropped
PlacementPod anti-affinity across nodes
Ingressapi.playpalz.gg

Build and push:

bash
./scripts/build-api.sh [tag]

Known issues

  • The error middleware reads err.code where HttpError sets .status, so thrown HttpErrors respond 500. See Coding Standards.
  • The Mux webhook does not verify its signature. See Livestreaming.
  • POST /webhooks/stripe is an empty handler.
  • forgotPassword and resetPassword service functions are empty stubs.
  • controllers/channelOLD.ts and services/channelOLD.ts are dead code.
  • router/test.ts exposes GET /api/v1/test-message in production.
  • express-rate-limit is not proxy-aware, so the limit is effectively global. See above.

Internal documentation — PlayPalz platform