REST API Overview
This page covers behavior that applies across the whole REST API, so the per-resource pages don't have to repeat it. Read this first.
Route mounting
Section titled “Route mounting”routes/index.js auto-mounts every subdirectory of routes/ that has an index.js. The directory name becomes the mount path, with three special cases:
| Directory | Mounted at |
|---|---|
home |
/ |
well-known |
/.well-known |
config |
/config.json |
anything else, e.g. posts |
/posts |
middleware and utils subdirectories are explicitly skipped -- they're not routes.
One introspection endpoint lives at the top level of this mount:
GET /__routes-- no auth. Dumps{ total, routes: [{ methods, path }] }for every mounted route except itself.
To wipe a test database, connect to Mongo directly (mongosh <MONGO_URI> --eval 'db.dropDatabase()') or use scripts/wipe.js --runId=<id> for a scoped wipe.
Body parsing (parsed three times)
Section titled “Body parsing (parsed three times)”JSON body parsing happens independently at three layers, each with its own limit and content-type matcher:
- App level (
index.js):express.json({ limit: "1mb" })plusurlencoded. routes/index.js: re-parses JSON with a larger limit (JSON_LIMITenv, default2mb) and a custom type matcher acceptingapplication/json,application/activity+json,application/ld+json,text/json,text/activity+json, and anything ending in+json.routes/outbox/index.js: parses JSON a third time with the same matcher, independent of mount order -- so outbox body parsing doesn't depend on where it lands relative to theroutes/index.jsparser.
If you're sending a large activity payload (e.g. a big embedded object) and hitting unexpected 413s, check which of these three limits you're actually up against -- it's usually the app-level 1MB default, not the outbox-specific one.
Origin allowlist is built from:
DOMAINenv (bothhttps://andhttp://variants)CORS_ORIGINenv (comma-separated extra origins)localhost:5173andlocalhost:3000, only outside production
Requests with no Origin header -- native clients, server-to-server federation calls -- always pass. credentials: true.
Auth: the route() wrapper
Section titled “Auth: the route() wrapper”Nearly every handler in the codebase is wrapped by routes/utils/route.js's route(handler, opts). The handler receives a single destructured argument:
route(({ req, query, params, body, user, set, setStatus }) => { // query, params, body default to {} if absent // user is the authenticated user doc, or undefined set("key", value) // builds the response body -- setting `error` to a falsy value is a silent no-op setStatus(201) // overrides the default 200})Auth resolution (attachUserFromToken): tries an RS256 JWT first (via jose, key = settings.publicKey, issuer https://<domain>), then falls back to HMAC via JWT_SECRET/JWT_KEY. Accepted headers, in no particular priority order -- first one present wins:
Authorization: Bearer <jwt>orAuthorization: Token <jwt>x-auth-tokenx-access-tokenx-tokenauth-tokenx-jwt
Who needs auth: SAFE_METHODS (GET, HEAD, OPTIONS) default to allowUnauth: true. Every other method requires auth unless the route explicitly passes allowUnauth: true.
Registration special case: allowUnauthCreateUser (default true specifically on /outbox) lets an unauthenticated POST through only when body.type === "Create" and the object being created is a User/Person. This is the shared mechanism behind both POST /register and registration-via-POST /outbox.
Failure modes:
- No
req.user.idand the route doesn't allow unauth ->401 { "error": "Unauthorized" }. - Uncaught handler error ->
500 { "error": "<message>" }. - Default success status is
200unless the handler callssetStatus.
Pagination & collection responses
Section titled “Pagination & collection responses”Most list endpoints go through makeCollection() (routes/utils/makeCollection.js), which wraps route():
makeCollection({ model, buildQuery(req, { query, user }) { /* ... */ }, select, sort = { createdAt: -1 }, sanitize = (doc) => doc, basePath(req) { /* ... */ }, defaultLimit = 20, maxLimit = 100, routeOpts,})Query params: ?page= (default 1, min 1), ?limit= (default 20, clamped to 1--100).
Response is an ActivityStreams collection via activityStreamsCollection() (routes/utils/oc.js):
Root form (no explicit page requested, or a single-page result):
{ "@context": "...", "type": "OrderedCollection", "id": "...", "totalItems": 42, "first": "...", "last": "...", "orderedItems": [ ... ]}Paginated form:
{ "@context": "...", "type": "OrderedCollectionPage", "id": "...", "partOf": "...", "orderedItems": [ ... ], "totalPages": 5, "totalItems": 42, "currentPage": 2, "next": "...", "prev": "..."}Rate limiting
Section titled “Rate limiting”In-memory, per-process, per-IP counters (routes/middleware/rateLimiter.js) -- not distributed, so counts reset per process and don't share state across horizontally-scaled instances. All rate limiting is bypassed entirely if RATE_LIMITING_ENABLED=false.
| Limiter | Window | Limit | Applies to |
|---|---|---|---|
strictRateLimiter |
5 min | 20 req | /auth/* (except /auth/me, /auth/verify-email), /register |
inboxRateLimiter |
15 min | 100 req | POST /inbox |
outboxRateLimiter |
15 min | 200 req | POST /outbox |
429 responses include Retry-After and X-RateLimit-* headers.
Outbox deduplication (activityDeduplicator)
Section titled “Outbox deduplication (activityDeduplicator)”A separate middleware, also on POST /outbox: hashes actorId|type|objectType|to|targetId|content (SHA-256) and rejects an identical resubmission within 30 seconds with 409 { "error": "Duplicate activity" }. The lock is released immediately if the request ultimately errors (status >= 400) -- so a failed-then-corrected resubmit isn't blocked. React activities are exempt from this check entirely, since React is toggle semantics (rapid react/unreact/react is a legitimate real usage pattern, not a duplicate submit).
This is a different mechanism from the activity-level idempotency keys (dedupeKey, remoteId) described in the Activities docs -- this one operates purely at the HTTP layer before an activity is ever parsed.
SPA deep-link guard / content negotiation
Section titled “SPA deep-link guard / content negotiation”A recurring pattern across posts, circles, groups, pages, users, servers, and admin routes: when a GET request's Accept header includes text/html (and not application/activity+json/application/ld+json), the request is deferred to Express's next('router') so the built frontend's index.html (the SPA) is served instead of a JSON response. The top-level guard in routes/index.js applies this to first path segments in circles, groups, users, posts, pages, profile, search, notifications, servers, discover, admin. ?rss in the query string bypasses this guard for feed endpoints.
Several individual route files additionally implement their own local wantsHTML() guard doing the same check at finer grain (e.g. per-detail-route rather than per-path-segment).
Practical implication: if you're building a non-browser client and getting HTML back instead of JSON, check your Accept header -- send Accept: application/json (or nothing meaningfully HTML-like) explicitly rather than relying on a browser-default Accept: text/html,....
The makeGetById() helper
Section titled “The makeGetById() helper”Used by several detail routes (GET /groups/:id, GET /bookmarks/:id, GET /users/:id, and others). Calls Kowloon.get.getObjectById(id, { viewerId, mode, enforceLocalVisibility, canView }).
Modes:
local(default) -- local DB only.remote-- fetch from the object's origin server only.prefer-local-- local first, falls back to a remote fetch (and typically caches the result).both-- checks both, used where local caching of remote objects is expected.
Result is passed through sanitizeObject, which audience-gates User personal fields via getViewerContext (so, e.g., a private email field is stripped for non-owner viewers automatically).
Error mapping:
| Thrown error name | HTTP status |
|---|---|
NotAuthorized |
403 |
NotFound |
404 |
BadRequest |
400 |
| anything else | 500 |
Returns { item: <sanitized> } on success; 404 { "error": "Not found" } if the lookup comes back empty even without a thrown error.
Background workers
Section titled “Background workers”Not HTTP routes, but started alongside the HTTP server and relevant to understanding system behavior: an outbox federation delivery worker, a poll worker (pulls content from remote servers this server subscribes to), and a nightly garbage-collection worker. See the Federation page for how these interact with /inbox and /outbox.
Health checks
Section titled “Health checks”GET /health is served by the auto-mounted routes/health router -- CORS-open (Access-Control-Allow-Origin: *, so the install wizard can poll it cross-origin) and returns 503 on a disconnected DB. See Config, Health, Themes, Push & Recommendations for the full response shape.