- Passwords hashed with Argon2id (
argon2package,apps/api/src/lib/password.ts), never stored or logged in plaintext. - Sessions are opaque random tokens (32 bytes, base64url); only a SHA-256
hash is stored server-side (
Session.tokenHash), so a database read never discloses a usable session token. - Session cookie:
httpOnly,SameSite=Lax,Securein production, 30-day expiry, server-side revocation on logout (Session.revokedAt). - Login/registration are rate-limited (10 requests/min/IP via
@fastify/rate-limit) to blunt credential stuffing and enumeration. - Optional TOTP 2FA is implemented end to end (
apps/api/src/lib/totp.ts— hand-rolled RFC 6238 overnode:crypto, verified against the RFC 4226 test vector inapps/api/test/totp.test.ts). Enrollment is two-step:POST /auth/2fa/setupgenerates a secret but does not enable 2FA;POST /auth/2fa/verifyonly flipstwoFactorEnabledafter a real code is confirmed, so a user can't lock themselves out with an unscanned QR code. Login requires the code once enabled; disabling requires both the password AND a current code — a leaked/reused password alone cannot strip 2FA off an account (apps/api/test/two-factor-flow.test.tsasserts a password-only attempt leaves it enabled). The code comparison itself (verifyTotp) usescrypto.timingSafeEqual, not===, so a timing side-channel can't be used to narrow down the correct digits.
- Every state-changing route calls
app.requireAuth(request), which throws a typed 401 if there's no valid session — there is no route that trusts a client-supplied user id. - Ownership checks are explicit and server-side:
ProductService.publishloads the product and comparesproduct.creatorIdto the caller'sCreatorProfile.idbefore mutating anything (spec rule #135 — Creator A cannot touch Creator B's product). - Content access is never decided twice in two different ways.
EntitlementService.canAccessProductis the single call site; the store route is the only place gated content is attached to a response, and it only does so after checkingaccess.allowed(spec rules #61, #133). - Rejects are generic on purpose where they should be: a locked/nonexistent
product both return
PRODUCT_NOT_FOUND-shaped responses rather than leaking which case it was. TIER-gated visibility fails closed: if a product is markedvisibility: TIERbut was never actually assigned arequiredTierId(a state that should be unreachable — creation validates it — but the access check doesn't trust that), it denies everyone rather than falling back to "any subscriber of this creator," which would silently downgrade a tier-gated product to a subscribers-only one and leak access to subscribers on a cheaper tier.apps/api/test/tier-gating.test.tsasserts a subscriber to a $2 tier cannot see a post gated to the creator's $20 tier.
- Every uploaded file (post attachment or avatar) is stored on disk under a
server-generated UUID key — never the client-supplied filename — so
nothing about the stored path is attacker-influenced (
StorageService,apps/api/src/lib/storage.ts). - Post attachments are gated exactly like the post's own text content: the
download route (
GET /products/:id/attachments/:attachmentId/file) re-runsEntitlementService.canAccessProducton every request rather than trusting a prior decision — the URL itself carries no capability of its own, unlike a signed S3 URL would. - Avatars are deliberately the one upload type never gated (an avatar is
always public, the same as any social platform), but
GET /avatars/:keystill validates:keyagainst the exact{uuid}.{extension}shapeStorageService.saveproduces, via a strict regex, before it's used to build a filesystem path — a../../etc/passwd-style value in the URL never reachesfs.createReadStreamat all, it 404s at the validation step.apps/api/test/media-uploads.test.tsasserts this directly. - Upload size is enforced twice:
@fastify/multipart's registeredfileSizelimit (one byte overMAX_UPLOAD_BYTES, so an oversized file is detectably truncated rather than silently accepted at exactly the limit) throws before a buffer is ever fully read, and each service method re-checks the buffer's actual size as defense in depth. - Avatar uploads are further restricted to a small allowlist of raster
image MIME types (PNG/JPEG/GIF/WebP) — not just "starts with
image/", and deliberately not SVG. SVG is XML that can carry a<script>/event-handler payload; accepting it as an "image" and serving it back unauthenticated from this platform's own origin would be a stored-XSS vector, not just an upload. The MIME allowlist also has to be narrow because the extension embedded in the stored filename has to be trustworthy enough to round-trip back to the correctContent-Typeon serve with no separate metadata store backing it. - Post attachments accept any file type on upload (spec: "any type"), so
the same defense can't live at the accept step — instead, how a file is
served back is allowlisted, not denylisted:
GET /products/:id/attachments/:attachmentId/fileonly setsContent-Disposition: inlinefor a MIME type on a small known-safe list (SAFE_INLINE_MIME_TYPES,apps/api/src/lib/safe-inline-mime.ts); anything else — including a client-claimedimage/svg+xmlortext/htmltrying to pass as an image — is forced to download instead. Both this route and the avatar route also sendX-Content-Type-Options: nosniff, so a browser can't second-guess the declared type and render a mislabeled file as something more dangerous.apps/api/test/media-uploads.test.tscovers both the SVG-avatar rejection and the forced-download behavior directly. - Dual-feed ingestion fetches a URL the creator supplies (
actorUrl) and, in turn, every URL that remote server's own response names (its outbox, its page). Both are classic SSRF surface — a malicious or compromised target could point either at an internal service (169.254.169.254cloud metadata,127.0.0.1, an internal admin panel). Every fetch insideActivityPubFeedProvider— the initial actor fetch, the outbox fetch, and any redirect hop (redirect: "manual", re-validated up to 3 hops) — is checked first byassertPublicHttpUrl(packages/integrations/src/ feed/ssrf-guard.ts): the hostname is resolved via real DNS and every returned address is rejected if it's loopback, RFC1918 private, link-local, or IPv6 unique-local.packages/integrations/src/feed/ ssrf-guard.test.tsandapps/api/test/feed-sources.test.tscover this with both literal-IP cases and a real, listening loopback server that still gets rejected (proving it's the guard, not just a closed port).
- Nothing about price, ownership, or payment success is ever accepted from
the client.
PaymentService.startProductCheckoutreads the price from the database; the frontend only ever displays what the backend returned. Payment.idempotencyKeyandPurchase.paymentIdare unique at the schema level;applyProviderStateruns inside a transaction that treats an already-CONFIRMED/REFUNDEDpayment as a no-op — replay-safe by construction, not just by convention (spec rule #55).- Starting checkout (
startProductCheckout,startSubscriptionCheckout) is also replay-safe against a concurrent request for the same user+product/tier: a Postgres session-scoped advisory lock (pg_advisory_xact_lock,withScopeLockinapps/api/src/modules/payments/service.ts) serializes the check-then-act "does a pending checkout already exist" read against writes for that exact scope, while unrelated checkouts run in parallel.apps/api/test/checkout-race.test.tsfires 10 concurrent checkout requests for the same user+product and asserts exactly onePurchaserow results — without the lock this is a real race (two clicks, two browser tabs, or a client retry could otherwise create two payments for one product). - The
dev-advanceendpoint that lets a client drive the payment state machine is explicitly gated to non-production (if (config.isProduction) throw Errors.notFound()), because in a real deployment payment state must only ever be advanced by verified chain-observation, never by a client request. - No private key or seed phrase field exists in the schema (
Walletonly storesnetwork+address); see DATABASE.md. - Manual/external payment methods (PayPal, bank transfer, etc.) and
anonymous donations are explicitly trust-based, never self-serve:
POST /payments/:id/confirmand/rejectonly succeed for the payment's recipient (resolveRecipientUserIdresolves the creator via the purchase/donation/subscription that owns the payment) — a payer can never confirm their own payment. The payer's own/mark-paidonly sets an informationalpayerMarkedPaidAttimestamp and never changesstatus. - A no-registration donation's
Payment.userIdisnull; itspayment.id(a UUIDv4) doubles as the anonymous payer's capability token — anyone who knows the exact id canGET /payments/:idormark-paid, the same pattern Stripe's client secrets use.GET /payments/:idstill enforces ordinary ownership wheneveruserIdis not null. - Subscription discount codes redeem via an atomic conditional
updateMany(WHERE redeemedCount < maxRedemptions) rather than a read-then-write, so two concurrent redemptions of a limited code can't both succeed past its cap —apps/api/test/manual-payments-and-discounts.test.tscovers this alongside the per-creator scoping (a code from one creator is rejected on another creator's tier).
Every mutating route validates its body with a zod schema before
touching the database; validation failures return a 400 with a
user-safe message (Errors.validation), never a raw Zod error object.
- Email is optional (
User.emailnullable) — the product works pseudonymously (spec rules #4, #6). Session.ipHashstores a truncated SHA-256 of the client IP, not the raw address.- Public creator profile only exposes subscriber count / donation
visibility / purchase counts if the creator explicitly opted in
(
CreatorProfile.showSubscriberCountetc., defaultfalse).
The global Fastify error handler (apps/api/src/server.ts) distinguishes
three cases: a typed AppError (safe message, forwarded as-is), a Zod/
Fastify validation error (generic "Invalid request"), and everything else
(logged with full detail server-side, generic 500 to the client) — no
stack trace or internal error string ever reaches the browser (spec rule
#99).
app.requireAdminis a separate check fromrequireAuth— every/admin/*route re-verifiesrole === "ADMIN"server-side; the frontend hiding the nav link is UX only, not the security boundary (spec rule #136).- Every admin/moderation action (suspend/restore a user, disable/enable a
product, resolve a report, change a federation instance's approval
status) writes to
AuditLogvialogAdminAction(apps/api/src/lib/audit-log.ts) — never with secrets inmetadata(spec rule #86). - Suspending a user (
POST /admin/users/:id/suspend) immediately revokes every one of their activeSessionrows and blocks future logins with a clearACCOUNT_SUSPENDEDerror — it doesn't just flip a flag that's checked lazily. - A disabled/suspended product's entire detail response 404s for non-owners (not just its content) — the same treatment as an unpublished draft, so moderation status isn't inferrable from response shape.
- There is no self-serve "become admin" flow by design; the first admin is
promoted via a direct database update (
UPDATE "User" SET role='ADMIN' WHERE username = '...') — documented here rather than built as a route, since an HTTP endpoint for granting admin is itself a privilege-escalation surface worth not having.
- Webhook signature verification (no real payment provider sends webhooks yet — the mock provider is driven by an authenticated, owner-checked dev-only endpoint instead).
- File upload MIME/size validation and signed-URL delivery (
StorageServicedoesn't exist yet — see ARCHITECTURE.md). - CSRF: deliberately not added as a dedicated double-submit token. The
session cookie is
SameSite=Lax, which already blocks the cookie from being sent on cross-sitePOST/PATCH/DELETEin every modern browser — the primary CSRF vector — so a same-site-only deployment is covered today. A dedicated token becomes worth the added complexity (and the correspondingX-CSRF-Tokenplumbing through every mutating test and the frontend fetch wrapper) once this needs to support embedding or cross-site form submission, or support for browsers that predateSameSitecookie enforcement.