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 feed read path:

CacheKeyTTLInvalidation
Feedfeed|u:<userId>|<params>60 sinvalidateUserFeedCache(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:

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:

  • Presigned — POST /media/upload/url returns a 5-minute presigned PUT; the client uploads directly and then calls POST /media/upload/complete.
  • Multipart — multer-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

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.

JobQueueScheduleDoes
trending-dailytrendingDaily 05:00 UTCScores the week's posts, rebuilds every trending list (Trending)
trending-risingtrendingHourly at :10Re-scores trending posts; refreshes the Explore feed, clips and discovery slots
sweepsession-lifecycleEvery minuteExpires lapsed holds; sends session and VIP renewal reminders
endsession-lifecycleOn demandCloses a session's room at its end time
fanout / deliverlive-notifyOn demand (go live)Pages a host's followers, then notifies them in batches
sweeplive-sweeper (not BullMQ)Every minute, one replicaEnds 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 completed at info and job failed at error, with { job, queue, jobId, trigger, attempt, durationMs, result } (+ err on failure). trigger is scheduled, manual (admin "Run now"), startup (trending bootstrap) or event. 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 ​

VariableDefaultNotes
PORT3000Set to 4000 everywhere in practice
ENV / NODE_ENVdevelopment
DATABASE_URL—Required
JWT_SECRET"supersecretkey"Must be set in production
RESET_SECRET"supersecretkey"Must be set in production
JWT_EXPIRATION_MINUTES60Lifetime of refreshable (mobile) access tokens; legacy tokens stay 7 days
REFRESH_TOKEN_TTL_DAYS90Sliding lifetime of a refresh session, extended on every refresh
REDIS_URL / REDIS_HOST / REDIS_PORT / REDIS_PASSWORDlocalhostREDIS_URL wins
WEB_APP_URLhttp://localhost:3000CORS 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_WORKERtruefalse 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.

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
Helm valuesinfra/helm/values/production/api.yaml (release playpalz-api)
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