# BIO.RE Developer Portal (/docs) The BIO.RE Developer Portal documents every client-facing endpoint of the [BIO.RE](https://bio.re) creator monetization platform. **19 modules, 175 endpoint pages** โ€” every claim cited back to the backend code (`path:line`), every response shape derived from a real DTO, every error code traced to the exception that throws it. Use the sidebar to browse, the search bar to find a specific endpoint, or the module cards below to jump straight to a section. ## Browse by module [#browse-by-module] ## Quick start by use case [#quick-start-by-use-case] | If you are building... | Start here | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Sign-up + login flow** | [Register](/docs/auth/register) โ†’ [Verify Email](/docs/auth/verify-email) โ†’ [Login](/docs/auth/login) | | **Forgotten password flow** | [Forgot Password](/docs/auth/forgot-password) โ†’ [Reset Password](/docs/auth/reset-password) | | **Two-factor authentication** | [Setup 2FA](/docs/auth/2fa-setup) โ†’ [Verify](/docs/auth/2fa-verify) โ†’ [Login with 2FA](/docs/auth/login-2fa) | | **OAuth (Google, etc.)** | [List Providers](/docs/auth/oauth-providers) โ†’ [OAuth Login](/docs/auth/oauth-login) โ†’ [Link to Account](/docs/auth/oauth-link) | | **Creator onboarding** | [Upgrade to Creator](/docs/creator/upgrade) โ†’ [Update Bio](/docs/creator/bio-page-update) โ†’ [Add Bio Links](/docs/creator/bio-link-add) โ†’ [Stripe Connect](/docs/creator/stripe-connect-initiate) โ†’ [KYC](/docs/creator/kyc-start) | | **Send + receive messages** | [Send Message](/docs/message/send) โ†’ [List Conversations](/docs/message/conversations) โ†’ [Reply](/docs/message/reply) | | **Wallet top-up** | [Wallet Packages](/docs/payment/wallet-packages) โ†’ [Load Wallet](/docs/payment/wallet-load) โ†’ [Activity](/docs/payment/wallet-activity) | | **Bio page traffic instrumentation** | [Create Session](/docs/analytics/session-create) โ†’ [Record Pageview](/docs/analytics/pageview) โ†’ [Heartbeat](/docs/analytics/session-heartbeat) โ†’ [Page Leave](/docs/analytics/pageview-leave) | | **Creator analytics dashboard** | [Overview](/docs/creator-analytics/overview) โ†’ [Traffic](/docs/creator-analytics/traffic) โ†’ [Trend](/docs/creator-analytics/trend) โ†’ [Links](/docs/creator-analytics/links) โ†’ [CSV Export](/docs/creator-analytics/export) | | **Notification preferences UI** | [List Notifications](/docs/notification/list) โ†’ [Preferences](/docs/notification/preferences) โ†’ [Update Preferences](/docs/notification/preferences-update) โ†’ [Web Push Subscribe](/docs/notification/webpush-subscribe) | | **GDPR data export / delete** | [Request Export](/docs/user/gdpr-export) โ†’ [Check Status](/docs/user/gdpr-export-status) โ†’ [Download](/docs/user/gdpr-export-download) โ†’ [Request Delete](/docs/user/gdpr-delete) | | **Localization** | [List Locales](/docs/i18n/locales) โ†’ [Bundle](/docs/i18n/bundle) โ†’ [Namespace Bundle](/docs/i18n/namespace) | | **Support ticketing** | [List My Tickets](/docs/support/list) โ†’ [Create](/docs/support/create) โ†’ [Reply](/docs/support/reply) | ## How to read an endpoint page [#how-to-read-an-endpoint-page] Every endpoint page follows the same shape: 1. **Header** โ€” method + path + scope badge (๐ŸŒ Public / ๐Ÿ”‘ Bearer / ๐Ÿ‘ค Admin) + rate limit. 2. **Callouts** โ€” non-obvious behavior, gotchas, kill switches, vendor abstraction notes. 3. **Request** โ€” path / query / body / header tables, with `class-validator` rules preserved. 4. **Response** โ€” success envelope + every error code with `i18nKey`. 5. **Side effects** โ€” DB writes, queued jobs, audit log entries, kill-switch gates, cache invalidations. 6. **Code samples** โ€” `curl` + `TypeScript fetch` + `TanStack Query` hook (copy-paste ready). 7. **Try it** โ€” interactive playground with auto-generated cURL/JS/Go/Python/Java/C# samples. 8. **Source** โ€” `path:line` references back to the backend code (5-source verify: controller + DTO + service + Prisma + cache/util). ## Conventions across the API [#conventions-across-the-api] * **Response envelope** โ€” every successful response is `{ "success": true, "data": T }`. Errors are `{ "success": false, "error": { code, message, i18nKey?, details?, correlationId } }`. * **Refresh tokens** โ€” set as `httpOnly`, `secure`, `SameSite=Strict` cookie scoped to `.bio.re`. Browsers send + receive automatically; mobile clients can pass via body. * **Idempotency** โ€” mutating endpoints accept `Idempotency-Key` header where supported; the side-effects table notes which writes are atomic. * **Locales** โ€” 20 active locales, error responses include `i18nKey` for UI mapping. * **Admin-managed providers** โ€” every external integration (email, SMS, push, captcha, KYC, payment, OAuth, storage) is selected by admin at runtime. The portal **never names the active vendor** (e.g., "SendGrid" / "Twilio"); endpoints reference the abstraction (`external.email.active_provider`, etc.). Frontend reads the abstract token; the backend resolves to whichever vendor is currently active. Stripe is the lone exception โ€” load-bearing infra, not a swappable vendor. * **Two databases** โ€” most endpoints write to the main Postgres (`@biore/prisma`). Analytics tracking endpoints write to a separate Analytics DB (`@biore/prisma-analytics`) โ€” joins between the two happen in application code. ## Machine-readable surfaces [#machine-readable-surfaces] | Surface | URL | Purpose | | ----------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------- | | **OpenAPI spec** | [`/openapi/full.json`](/openapi/full.json) | Full Swagger spec โ€” feeds the "Try it" playground on every endpoint page (945 KB). | | **Public-only OpenAPI** | [`/openapi/public.json`](/openapi/public.json) | Public endpoints only. | | **User-auth OpenAPI** | [`/openapi/user.json`](/openapi/user.json) | Public + JWT-bearer endpoints. | | **Admin OpenAPI** | [`/openapi/admin.json`](/openapi/admin.json) | Admin-scope endpoints. | | **Sitemap** | [`/sitemap.xml`](/sitemap.xml) | Every URL on this site (179 entries). | | **`llms.txt`** | [`/llms.txt`](/llms.txt) | Compact module + endpoint index for LLMs. | | **`llms-full.txt`** | [`/llms-full.txt`](/llms-full.txt) | Full content of every endpoint page in one file (1.4 MB) for LLM consumption. | | **Per-page Markdown** | `/llms.mdx/docs//content.md` | Raw MDX of any individual page. | # Record Page Leave (/docs/analytics/pageview-leave) `PATCH /api/v1/analytics/pageview/:id/leave` โ€” ๐ŸŒ **Public** ยท Rate limit: **10 req / minute** Finalizes the duration and scroll depth of a single pageview. Earlier pageviews in the same session get their `duration` back-filled automatically by the next [`POST /pageview`](/docs/analytics/pageview), so this endpoint really only matters for the **last** pageview of a session โ€” the one followed by a tab close, navigation away, or full app exit. **Use `navigator.sendBeacon` from `pagehide` / `beforeunload`.** A regular `fetch` from those handlers usually gets cancelled by the browser. `sendBeacon` is queued by the browser and delivered after the page unloads. The body is JSON; the server will accept it. **`duration` is capped at 3600s server-side.** `Math.min(duration, 3600)` โ€” anything you pass over an hour gets clamped. **`scrollDepth` is clamped to `[0, 100]`** before write โ€” pass percentages, not pixel values. **Silent failures.** `update.catch(() => {})` โ€” if the pageview id is wrong or the row's been pruned, you still get `200 { ok: true }` and no error. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Required | Notes | | ----- | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `id` | string (UUID) | โœ“ | The pageview row id from [`POST /analytics/pageview`](/docs/analytics/pageview). Not validated server-side โ€” bad ids no-op silently. | ### Body โ€” `PageLeaveDto` [#body--pageleavedto] | Field | Type | Required | Validation | Notes | | ------------- | ---------------- | -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `duration` | number (seconds) | โœ“ | `IsNumber Min(0) Max(3600)` | Whole or fractional seconds the page was visible. DTO validates `[0, 3600]`; service additionally `Math.min(duration, 3600)` as belt-and-braces. | | `scrollDepth` | number (percent) | optional | `IsNumber Min(0) Max(100)` | Furthest scroll position as a 0-100 percentage. DTO validates the range; service additionally `Math.min(Math.max(value, 0), 100)`. | ### Headers [#headers] | Header | Required | Notes | | -------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type: application/json` | โœ“ | `sendBeacon` will send `application/x-www-form-urlencoded` or `Blob` mime-type by default โ€” wrap your JSON in a typed `Blob` to keep it `application/json`. See the sample. | No auth. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofpageleaveresultdto] ```json { "success": true, "data": { "ok": true } } ``` Same caveats as heartbeat โ€” `ok: true` is "request reached service," not "row was updated." ### Errors [#errors] | HTTP | Reason | | ----- | ------------------------------------------------------------------------------------------------------ | | `400` | DTO validation (`duration` outside `[0, 3600]`, `scrollDepth` outside `[0, 100]`, missing `duration`). | | `429` | Over 10 req/min from this IP. | ## Side effects [#side-effects] 1. `ThrottleGuard` (10 req / 60s). 2. `analyticsDb.analyticsPageView.update({ where: { id }, data: { duration: Math.min(data.duration, 3600), scrollDepth: clamped(0..100) ?? null } }).catch(() => {})`. Single SQL UPDATE; mis-id silently swallowed. 3. Return `{ ok: true }`. ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/analytics/pageview/pv-uuid/leave \ -H 'Content-Type: application/json' \ -d '{ "duration": 47, "scrollDepth": 65 }' ``` ```ts async function pageLeave( pageViewId: string, data: { duration: number; scrollDepth?: number }, ): Promise { await fetch(`https://api.bio.re/api/v1/analytics/pageview/${pageViewId}/leave`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }).catch(() => {}); } ``` ```ts // Fires reliably during page unload โ€” fetch usually gets cancelled in unload handlers. function leaveBeacon( pageViewId: string, data: { duration: number; scrollDepth?: number }, ): boolean { const url = `/api/v1/analytics/pageview/${pageViewId}/leave`; const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); return navigator.sendBeacon(url, blob); } // Wire it up โ€” pagehide is more reliable than beforeunload on mobile Safari. window.addEventListener('pagehide', () => { if (!lastPageViewId) return; const duration = Math.floor((Date.now() - mountedAt) / 1000); const scrollDepth = Math.round( (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100, ); leaveBeacon(lastPageViewId, { duration, scrollDepth }); }); ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `168โ€“177` (`pageLeave`) | | DTO (request) | `apps/api-core/src/modules/analytics/dto/tracking.dto.ts` | `38โ€“41` (`PageLeaveDto`) | | DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `43โ€“46` (`PageLeaveResultDto`) | | Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `205โ€“213` (`pageLeave` โ€” duration cap + scrollDepth clamp + silent fail) | | Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsPageView.duration / scrollDepth` lines `112โ€“113` | # Record Page View (/docs/analytics/pageview) `POST /api/v1/analytics/pageview` โ€” ๐ŸŒ **Public** ยท Rate limit: **30 req / minute** Records one pageview against an existing analytics session. Each successful call does three things in the database โ€” creates the pageview row, atomically increments the parent session's counter (and flips the `bounced` flag from `true` โ†’ `false` on the second view), and back-fills `duration` on the previous pageview. All three are best-effort: failures are swallowed and the response is `200 { id: null }` so client instrumentation never errors out a page render. **Atomic counter update prevents the bounce-flag race.** The session's `pageViews` increment and the `bounced` flag flip happen in a single SQL `UPDATE ... SET pageViews = pageViews + 1, bounced = CASE WHEN pageViews + 1 >= 2 THEN false ELSE true END`. Earlier read-then-write versions had a race where two concurrent pageviews could both observe `pageViews = 1` and incorrectly set `bounced = true`. **Previous pageview's `duration` is back-filled here, not on `pageLeave`.** When this endpoint runs, it looks up the user's most-recent prior pageview in the same session and sets its `duration = (this.enteredAt - prev.enteredAt) / 1000`, **capped 0โ€“3600s** (anything outside that range is silently dropped). The dedicated [`pageLeave`](/docs/analytics/pageview-leave) endpoint is only needed to capture duration on the *last* view in a session (no follow-up pageview will ever back-fill it). **Silent failures.** If the session doesn't exist (expired / cleaned up / wrong id), the page-view insert may fail or the session UPDATE will simply match zero rows. Either way the endpoint returns `200 { id: null }` (or `{ id }` from the new row even when the session update no-ops). **Don't treat a non-null `id` as proof the session counter advanced** โ€” the row itself can land while the session UPDATE matches nothing. ## Request [#request] ### Body โ€” `RecordPageViewDto` [#body--recordpageviewdto] | Field | Type | Required | Validation | Notes | | ------------- | ------------- | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `sessionId` | string (UUID) | โœ“ | `IsString` | The session id from [`POST /analytics/session`](/docs/analytics/session-create). Server doesn't validate it exists โ€” non-matching ids land an orphan pageview row. | | `url` | string | โœ“ | `IsString` | Full URL of the viewed page. Stored verbatim and used as the session's `exitPage`. | | `path` | string | โœ“ | `IsString` | Normalized path (e.g. `/creators/alice`, `/messages`). | | `title` | string | optional | `IsString` | `document.title`. | | `bioPageId` | string (UUID) | optional | `IsString` | The bio page being viewed (if applicable). | | `linkClicked` | string (UUID) | optional | `IsString` | If this view was triggered by a bio link click, the id of that link. Use to reconstruct the click flow. | ### Headers [#headers] | Header | Required | Notes | | -------------------------------- | -------- | --------- | | `Content-Type: application/json` | โœ“ | Standard. | No auth โ€” endpoint is `@Public()`. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofpageviewcreateddto] ```json { "success": true, "data": { "id": "pv-uuid-...", "enteredAt": "2026-04-30T10:00:01.123Z" } } ``` | Field | Type | Notes | | ----------- | --------------------- | --------------------------------------------------------------------------- | | `id` | string (UUID) \| null | New pageview row id, or `null` when the row insert threw (caught + logged). | | `enteredAt` | string (ISO 8601) | When the row was created (DB `now()`). Only present when `id` is non-null. | ### Errors [#errors] | HTTP | Reason | | ----- | ------------------------------------------------------ | | `400` | DTO validation (missing `sessionId` / `url` / `path`). | | `429` | Over 30 req/min from this IP. | DB / session-not-found failures are **not** errors โ€” they become `200 { id: null }`. ## Side effects [#side-effects] 1. `ThrottleGuard` (30 req / 60s). 2. `analyticsDb.analyticsPageView.create({ data: { id: randomUUID(), sessionId, url, path, title, bioPageId, linkClicked } })`. 3. **Atomic session update** via `analyticsDb.$executeRaw`: ```sql UPDATE "AnalyticsSession" SET "pageViews" = "pageViews" + 1, "exitPage" = $url, "lastActivityAt" = NOW(), "bounced" = CASE WHEN "pageViews" + 1 >= 2 THEN false ELSE true END WHERE id = $sessionId::uuid ``` `.catch(() => {})` โ€” failures swallowed. 4. **Back-fill prev `duration`**: `findFirst({ sessionId, id: NOT this.id, orderBy: enteredAt desc })` โ†’ if found, `update prev.duration = (this.enteredAt - prev.enteredAt) / 1000` when `0 < duration < 3600`. Out-of-range or query failure โ†’ silently skipped. 5. Return `{ id, enteredAt }` (or `{ id: null }` on row-create exception). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/analytics/pageview \ -H 'Content-Type: application/json' \ -d '{ "sessionId": "ses-uuid-from-create", "url": "https://bio.re/alice", "path": "/alice", "title": "Alice on bio.re", "bioPageId": "11111111-2222-3333-4444-555555555555" }' ``` ```ts type RecordPageViewInput = { sessionId: string; url: string; path: string; title?: string; bioPageId?: string; linkClicked?: string; }; type PageViewResult = { id: string | null; enteredAt?: string }; async function recordPageView(input: RecordPageViewInput): Promise { const res = await fetch('https://api.bio.re/api/v1/analytics/pageview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); return json.data; } ``` ```ts // React Router / Next.js example โ€” fire one pageview per route change. let lastPageViewId: string | null = null; useEffect(() => { if (!sessionId) return; // Fire-and-forget; capture id so pageLeave can finalize duration when leaving. recordPageView({ sessionId, url: location.href, path: location.pathname, title: document.title, }).then(({ id }) => { lastPageViewId = id; }); }, [pathname]); // On unmount / before route change, send the leave event for accurate last-view duration: useEffect(() => { return () => { if (lastPageViewId) { const duration = Math.floor((Date.now() - mountedAt.current) / 1000); pageLeave(lastPageViewId, { duration, scrollDepth: getScrollPercent() }); } }; }, []); ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `146โ€“155` (`recordPageView`) | | DTO (request) | `apps/api-core/src/modules/analytics/dto/tracking.dto.ts` | `29โ€“36` (`RecordPageViewDto`) | | DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `24โ€“32` (`PageViewCreatedDto`) | | Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `139โ€“193` (`recordPageView` โ€” atomic UPDATE, prev-duration back-fill) | | Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsPageView` lines `102โ€“119`, `AnalyticsSession.pageViews / bounced / exitPage / lastActivityAt` `46โ€“96` | # Create Analytics Session (/docs/analytics/session-create) `POST /api/v1/analytics/session` โ€” ๐ŸŒ **Public** ยท Rate limit: **10 req / minute** Opens a new analytics session โ€” the parent record for subsequent pageviews and heartbeats. Public so the SSR shell can call it before any login. The server augments the request with **server-side UA parsing** and **GeoIP lookup**, hashes the supplied visitorId, then writes the row to the **separate Analytics DB**. **This endpoint silently rejects four kinds of requests with `200 { id: null }`:** (1) bot user-agents (UA matched against a regex of common crawler tokens), (2) requests where `consentAnalytics !== 'granted'` (GDPR gate), (3) requests with `bioPageId` but no valid HMAC tracking token, and (4) DB write failures (caught, logged, swallowed). Always check `data.id` โ€” `null` means "tracking did not happen, don't bother sending pageviews against this session." **HMAC tracking token required when `bioPageId` is set.** The token is generated server-side as `HMAC-SHA256(TRACKING_HMAC_SECRET, ':<5min-bucket>')[:32]`, embedded in the SSR-rendered bio page, and validated against the current and previous 5-minute bucket (10-min validity). **There is no public endpoint to obtain this token** โ€” it lives in the SSR HTML. Custom integrations that don't render through the platform SSR should omit `bioPageId` (you can still track the session, but it won't be linked to a bio page). **Server-side UA + GeoIP win over client-supplied values.** Client may send `device`, `browser`, `os`, `osVersion`, `timezone` โ€” but the server runs `UAParser` on the request `User-Agent` header and overrides those fields when it can parse them. GeoIP is derived from the request IP (`cf-connecting-ip` > `x-forwarded-for[0]` > `req.ip`). **The IP itself is never persisted** โ€” only the derived `country / city / region / continent / timezone` columns land in the row. **`visitorId` is one-way hashed before write.** Client supplies its anonymous localStorage UUID; server runs `SHA256(visitorId)` and stores the hash (per GDPR Article 4(5) on pseudonymization). Joins across sessions still work because the hash is deterministic, but the original visitorId never reaches the DB. ## Request [#request] ### Body โ€” `CreateSessionDto` [#body--createsessiondto] | Field | Type | Required | Validation | Notes | | -------------------------------------------------------------------- | ------------- | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `visitorId` | string | โœ“ | `IsString` | Anonymous client-side UUID (e.g. from `localStorage`). Hashed before write. | | `landingPage` | string | โœ“ | `IsString` | First URL the user landed on (full URL). | | `consentAnalytics` | string | โœ“ for tracking | `IsString` `MaxLength(50)` | **Must be `'granted'`** for the row to land. Anything else โ†’ `200 { id: null }`. | | `bioPageId` | string (UUID) | optional | `IsString` | When set, **also** requires a valid `trackingToken` โ€” otherwise the session is rejected. | | `creatorId` | string (UUID) | optional | `IsString` | Denormalized creator id (for fast creator analytics joins). No FK validation โ€” application-level link to the main DB. | | `referrer` | string | optional | `IsString` | Document.referrer. Server extracts `referrerDomain` from this (`new URL(referrer).hostname.replace('www.', '')`); falls back to `'direct'`. | | `utmSource` / `utmMedium` / `utmCampaign` / `utmTerm` / `utmContent` | string | optional | `IsString` | Standard UTM params from the URL query string. Stored verbatim. | | `device` / `browser` / `os` / `osVersion` | string | optional | `IsString` (`osVersion` `MaxLength(20)`) | **Overridden by server-side UA parse when possible** โ€” see callout. | | `screenWidth` / `screenHeight` | integer | optional | `Min(0) Max(7680)` | From `window.screen.*`. | | `connectionType` | string | optional | `MaxLength(20)` | From `navigator.connection.effectiveType` (e.g. `'4g'`, `'wifi'`). | | `timezone` | string | optional | `MaxLength(50)` | Client timezone. Server overrides with GeoIP timezone if available. | | `language` | string | optional | `IsString` | `navigator.language`. | | `trackingToken` | string | optional\* | `IsString` | **Required when `bioPageId` is present**, otherwise ignored. See HMAC callout. | ### Headers [#headers] | Header | Required | Notes | | --------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------ | | `User-Agent` | (always sent) | Server runs the bot regex AND `UAParser` on this header. | | `cf-connecting-ip` / `x-forwarded-for` / `cf-ipcountry` / `cf-ipcity` | optional | Used as the IP source for GeoIP and as fallback country/city when the GeoIP DB has no row. | | `Content-Type: application/json` | โœ“ | Standard. | No `Authorization` โ€” endpoint is `@Public()`. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofsessioncreateddto] ```json { "success": true, "data": { "id": "ses-uuid-...", "createdAt": "2026-04-30T10:00:00.000Z" } } ``` When tracking is rejected: ```json { "success": true, "data": { "id": null } } ``` | Field | Type | Notes | | ----------- | --------------------- | --------------------------------------------------------------------------------------- | | `id` | string (UUID) \| null | Session id. **`null` is a soft no-op** โ€” see warn callout for the four rejection paths. | | `createdAt` | string (ISO 8601) | Only present when `id` is non-null. The server timestamp the row got. | Hold on to `id` and pass it to subsequent pageview / heartbeat / identify calls. ### Errors [#errors] | HTTP | Code | Reason | | ----- | ------------------------- | ----------------------------------------------------------------------------- | | `400` | (DTO validation) | Missing `visitorId` or `landingPage`; `screenWidth/Height` out of range; etc. | | `429` | `THROTTLE_LIMIT_EXCEEDED` | Over 10 req/min from this IP. | This endpoint **never returns 403/404/500 on tracking rejection** โ€” those become `200 { id: null }` to keep the client UX simple. Real failures (validation, throttle) still bubble up. ## Side effects [#side-effects] 1. `ThrottleGuard` increments `throttle::POST:/api/v1/analytics/session` (10-req / 60s). 2. **Bot filter**: regex against `User-Agent` (`/bot|crawl|spider|slurp|baidu|yandex|bing|google|facebook|twitter|linkedin|whatsapp|telegram|preview|fetch|curl|wget|python|java|php|ruby|go-http/i`). Match โ†’ return `{ id: null }`. 3. **GDPR gate**: `consentAnalytics !== 'granted'` โ†’ return `{ id: null }`. 4. **UA parse**: `UAParser(userAgent)` โ†’ derive `device` (`mobile` / `tablet` / `desktop`), `browser`, `os`, `osVersion`. These override the client's values (with a fall-through to the client value when UA parse can't determine it). 5. **GeoIP** (scoped inside `try/catch` so the raw IP **never** leaks to error logs): * Resolve client IP: `cf-connecting-ip` > `x-forwarded-for[0].trim()` > `req.ip`. * `geoipService.lookup(ip)` โ†’ `country, city, region, continent, timezone`. * Fall-through: `country` and `city` use `cf-ipcountry` / `cf-ipcity` headers if GeoIP has no row. * On exception โ†’ silent catch, all geo fields stay `null`. **The raw IP is never persisted, never logged.** 6. **HMAC tracking token check** (only when `bioPageId` is set): rebuild HMAC against current and previous 5-min bucket; mismatch โ†’ log debug `'rejected as spam'`, return `null`. 7. **Hash visitorId**: `SHA256(visitorId).hex` โ€” replaces the original. 8. Extract `referrerDomain` from `referrer`: `new URL(referrer).hostname.replace('www.', '')`; URL-parse failure โ†’ `'direct'`; missing referrer โ†’ `'direct'`. 9. **Write row to Analytics DB**: `analyticsDb.analyticsSession.create({ data: {...} })`. On exception โ†’ log error, return `{ id: null }`. 10. Return `{ id, createdAt }` (or `{ id: null }`). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/analytics/session \ -H 'Content-Type: application/json' \ -d '{ "visitorId": "anon-localStorage-uuid", "landingPage": "https://bio.re/alice", "consentAnalytics": "granted", "referrer": "https://www.instagram.com/alice", "utmSource": "instagram", "utmMedium": "story", "utmCampaign": "spring-launch", "screenWidth": 390, "screenHeight": 844, "language": "en-US", "bioPageId": "11111111-2222-3333-4444-555555555555", "trackingToken": "<32-hex-from-SSR-html>" }' ``` ```ts type CreateSessionInput = { visitorId: string; landingPage: string; consentAnalytics: 'granted' | 'denied' | string; // must be 'granted' bioPageId?: string; creatorId?: string; referrer?: string; utmSource?: string; utmMedium?: string; utmCampaign?: string; utmTerm?: string; utmContent?: string; trackingToken?: string; device?: string; browser?: string; os?: string; osVersion?: string; screenWidth?: number; screenHeight?: number; connectionType?: string; timezone?: string; language?: string; }; type CreateSessionResult = { id: string | null; createdAt?: string }; async function createSession(input: CreateSessionInput): Promise { const res = await fetch('https://api.bio.re/api/v1/analytics/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); return json.data; // { id: string | null, createdAt? } } ``` ```ts // 1. Read consent state from your cookie banner / preference store. const consent = readAnalyticsConsent(); // 'granted' | 'denied' // 2. Stable anonymous id from localStorage. const visitorId = (() => { let v = localStorage.getItem('bre.vid'); if (!v) { v = crypto.randomUUID(); localStorage.setItem('bre.vid', v); } return v; })(); // 3. Open the session โ€” `id: null` is a soft no-op, don't surface as an error. const { id: sessionId } = await createSession({ visitorId, landingPage: location.href, consentAnalytics: consent, referrer: document.referrer || undefined, utmSource: new URL(location.href).searchParams.get('utm_source') || undefined, bioPageId: window.__BIO_PAGE_ID__, // injected by SSR trackingToken: window.__TRACKING_TOKEN__, // injected by SSR screenWidth: screen.width, screenHeight: screen.height, language: navigator.language, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); // 4. If we got an id, instrument the rest of the page lifecycle. if (sessionId) { recordPageView({ sessionId, url: location.href, path: location.pathname }); setInterval(() => sessionHeartbeat(sessionId), 30_000); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | | | | -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ---------------- | | Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `82โ€“144` (`createSession` โ€” bot, GDPR, UA, GeoIP, IP-source priority) | | | | | Bot regex | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `190โ€“193` (`isBot` โ€” \`bot | crawl | spider | ...\` UA tokens) | | DTO (request) | `apps/api-core/src/modules/analytics/dto/tracking.dto.ts` | `5โ€“27` (`CreateSessionDto`) | | | | | DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `12โ€“20` (`SessionCreatedDto`) | | | | | Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `52โ€“137` (`createSession` โ€” token check, hash, referrerDomain, DB write), `6โ€“8` (`hashVisitorId` โ€” SHA256), `21โ€“50` (HMAC token gen + verify) | | | | | GeoIP package | `@biore/geoip` | `GeoIPService.lookup()` (returns `{ country, city, region, continent, timezone }`) | | | | | Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsSession` lines `46โ€“96` (Analytics DB โ€” separate from main DB) | | | | # Session Heartbeat (/docs/analytics/session-heartbeat) `PATCH /api/v1/analytics/session/:id/heartbeat` โ€” ๐ŸŒ **Public** ยท Rate limit: **5 req / 30 s** Marks the session as still active and adds 30 seconds to its rolling `duration` counter. Call once every \~30 seconds while the user has the page in the foreground; stop when they background the tab or close the page. **The endpoint hard-codes a `+30` increment** regardless of how long it's actually been since the last beat, so don't call it more often than once per 30s โ€” you'll over-count time on session. **`duration` is incremented by a hard-coded 30s per call.** The server does NOT measure the elapsed wall-clock time between heartbeats. Two beats in 1 second still add 60s to `duration`. The throttle (`@Throttle(5, 30)` โ€” 5 req per 30s) is what protects against accidental spam, but you should still pace your client to one beat per \~30s. **Failures are silent.** Service does `update.catch(() => {})` โ€” if the session id doesn't exist or DB is unhappy, you still get `200 { ok: true }`. No way for the client to detect that the session is gone via this endpoint; only the session-create response's `null` id signals real rejection. **Pair with the Page Visibility API.** The right cadence is "every 30 seconds while `document.visibilityState === 'visible'`." Sending heartbeats from a backgrounded tab inflates active-time metrics. See the code sample. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Required | Notes | | ----- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | โœ“ | Session id from [`POST /analytics/session`](/docs/analytics/session-create). **Not** validated by `ParseUUIDPipe` โ€” any string is accepted; mismatches no-op. | ### Headers [#headers] | Header | Required | Notes | | -------------------------------- | -------- | -------------- | | `Content-Type: application/json` | optional | Body is empty. | No body, no auth. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofheartbeatresultdto] ```json { "success": true, "data": { "ok": true } } ``` `ok: true` means "request reached the service" โ€” not "DB write succeeded" (see warn callout). ### Errors [#errors] | HTTP | Reason | | ----- | ------------------------------------------------------------------------------------------------------ | | `429` | Over 5 req / 30s from this IP. (Throttle is per-IP, not per-session โ€” multiple tabs share the budget.) | ## Side effects [#side-effects] 1. `ThrottleGuard` (5 req / 30s). 2. `analyticsDb.analyticsSession.update({ where: { id }, data: { lastActivityAt: new Date(), duration: { increment: 30 } } }).catch(() => {})`. **Single SQL UPDATE**, no read-then-write. 3. Return `{ ok: true }` regardless of whether any row was matched. ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/analytics/session/ses-uuid/heartbeat ``` ```ts async function sessionHeartbeat(sessionId: string): Promise { await fetch(`https://api.bio.re/api/v1/analytics/session/${sessionId}/heartbeat`, { method: 'PATCH', }).catch(() => {}); // never throw from instrumentation } ``` ```ts // Fire a heartbeat every 30s, but only while the tab is visible. // Pauses on visibilitychange = 'hidden', resumes on 'visible'. function startHeartbeat(sessionId: string): () => void { let timer: number | null = null; const start = () => { if (timer != null) return; timer = window.setInterval(() => sessionHeartbeat(sessionId), 30_000); }; const stop = () => { if (timer != null) { clearInterval(timer); timer = null; } }; const onVisibility = () => { if (document.visibilityState === 'visible') start(); else stop(); }; document.addEventListener('visibilitychange', onVisibility); if (document.visibilityState === 'visible') start(); // Return cleanup return () => { document.removeEventListener('visibilitychange', onVisibility); stop(); }; } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `157โ€“166` (`sessionHeartbeat`) | | DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `36โ€“39` (`HeartbeatResultDto`) | | Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `195โ€“203` (`heartbeat` โ€” fixed `+30` increment, silent failure) | | Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsSession.duration / lastActivityAt` lines `53โ€“54` | # Identify Session After Login (/docs/analytics/session-identify) `PATCH /api/v1/analytics/session/:id/identify` โ€” ๐Ÿ”‘ **Bearer** Sets `AnalyticsSession.userId` for an existing session. Call this **once, right after login or signup completes** โ€” the session was created anonymously (likely before the user authenticated), and this pulls the user identity onto it so all the prior pageviews in that session can be attributed correctly. **Idempotent and overwrite-friendly.** The service does a flat `update({ where: { id }, data: { userId } })` โ€” calling it twice with the same userId is a no-op. Calling it with a different userId overwrites the previous one, so don't share session ids across users (a logout-then-login on the same anonymous session would reattribute prior views to the second user). **No 404 / 401 cross-checks.** The service silently swallows `update` failures (`update.catch(() => {})`). If the session id doesn't exist or the DB rejects the write, the response is still `200 { ok: true }` โ€” you can't programmatically detect a bad id. The auth check (`@CurrentUser('id')` requires a valid bearer) is the only real gate. **`userId` comes from the JWT, not the body.** The endpoint reads `userId` via `@CurrentUser('id')` โ€” there is no way to identify a session as someone else. To unbind, you'd need a separate endpoint (none today). To re-identify after logout, the new session should be created fresh. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Required | Notes | | ----- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `id` | string (UUID) | โœ“ | Session id from [`POST /analytics/session`](/docs/analytics/session-create). Not validated server-side โ€” bad ids no-op. | ### Headers [#headers] | Header | Required | Notes | | ------------------------------------- | -------- | ------------------------------------------------------------------------ | | `Authorization: Bearer ` | โœ“ | Global `JwtAuthGuard`. The user from this token is bound to the session. | No body. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofidentifysessionresultdto] ```json { "success": true, "data": { "ok": true } } ``` ### Errors [#errors] | HTTP | Reason | | ----- | ------------------------------- | | `401` | Missing / invalid bearer token. | The endpoint has no documented `404` or `400` paths โ€” bad session ids and DB failures collapse to a successful response. ## Side effects [#side-effects] 1. Decode bearer (global `JwtAuthGuard`); `userId = req.user.id`. 2. `analyticsDb.analyticsSession.update({ where: { id }, data: { userId } }).catch(() => {})`. Single SQL UPDATE; failures swallowed. 3. Return `{ ok: true }`. **No throttle on this endpoint.** The class registers `@UseGuards(ThrottleGuard)` but this handler has no `@Throttle()` decorator and the guard returns `true` when no decorator is present. In practice clients should call it at most once per session (right after auth completes), so a missing throttle is fine. ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/analytics/session/ses-uuid/identify \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function identifySession( accessToken: string, sessionId: string, ): Promise { await fetch(`https://api.bio.re/api/v1/analytics/session/${sessionId}/identify`, { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}` }, }).catch(() => {}); } ``` ```ts // Run this once, immediately after a successful login/signup. // The session was started anonymously when the page first loaded. async function onLoginSuccess(accessToken: string) { const sessionId = window.__ANALYTICS_SESSION_ID__; // captured from createSession() if (!sessionId) return; await identifySession(accessToken, sessionId); // No need to re-fire; the binding persists for the lifetime of the session. } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ----------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `179โ€“188` (`identifySession`) | | DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `50โ€“53` (`IdentifySessionResultDto`) | | Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `215โ€“220` (`identifyUser` โ€” single UPDATE, silent fail) | | Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsSession.userId` line `49` (application-level FK to main DB User) | | Throttle behavior | `apps/api-core/src/common/guards/throttle.guard.ts` | `43โ€“48` (no decorator โ†’ `return true`) | # Track Event (/docs/analytics/track) `POST /api/v1/analytics/track` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **60 req / minute** Records a named event (signup step, action, button click, โ€ฆ) attributed to the current user. This endpoint coexists with the public session-tracking endpoints โ€” but **`track` writes to the main database** (`FunnelEvent` row in `@biore/prisma`), not the separate Analytics DB. Use it for funnel events that must be reliably joined to the user table; use [`POST /analytics/session`](/docs/analytics/session-create) + [`POST /analytics/pageview`](/docs/analytics/pageview) for traffic instrumentation. **Killable from admin config.** The service reads `analytics.tracking_enabled` (string, default `'true'`). When set to anything other than `'true'`, the endpoint returns `200 { tracked: true }` without writing โ€” clients see success but no row lands. Useful for kill-switching analytics during incidents without redeploying. **`properties` payload is size-capped.** Server reads `analytics.max_event_data_bytes` (default `10000`) and runs `validateEventData(properties, max)`. Anything over the cap throws `400 analytics.event.invalid_data`. Keep payloads small; for large analytics blobs use a dedicated endpoint or batch. **Two databases, one module.** This endpoint writes to `prisma.funnelEvent` (main `@biore/prisma` Postgres). The session/pageview endpoints write to `analyticsDb` (`@biore/prisma-analytics` โ€” a separate Postgres on the Analytics DB service). Funnel events are joinable to the User table; session events are not. Plan your queries accordingly. ## Request [#request] ### Body โ€” `TrackEventDto` [#body--trackeventdto] | Field | Type | Required | Validation | Notes | | ------------ | ------ | -------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `event` | string | โœ“ | `IsString` | Event name (e.g. `signup_step_complete`, `bio_link_click`). No enum โ€” free-form. | | `sessionId` | string | optional | `IsString` | Optional join key โ€” typically the analytics session id from [`POST /session`](/docs/analytics/session-create) so funnel events can be correlated with traffic state. Stored verbatim โ€” server doesn't validate it exists. | | `deviceType` | string | optional | `IsString` | Free-form (`mobile` / `desktop` / `tablet`). Stored as-is on `FunnelEvent.eventData`? **No** โ€” the column is dropped from persistence (only `event`, `userId`, `sessionId`, `eventData` are written). Pass it for API symmetry; it's ignored. | | `locale` | string | optional | `IsString` | Same as `deviceType` โ€” accepted by the DTO but the service drops it from the write. | | `properties` | object | optional | `IsObject` | Arbitrary JSON. Persisted as `FunnelEvent.eventData`. **Size-capped** (see warn callout). | ### Headers [#headers] | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------------------------------------------------------------------- | | `Authorization: Bearer ` | โœ“ | Global `JwtAuthGuard`. `userId` is forwarded from the JWT โ€” clients cannot impersonate. | | `Content-Type: application/json` | โœ“ | Standard. | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseoftrackingresultdto] ```json { "success": true, "data": { "tracked": true } } ``` | Field | Type | Notes | | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tracked` | boolean | Always `true` when the request reaches the service. **Does not** signal that the row landed โ€” both the kill switch (`analytics.tracking_enabled !== 'true'`) and a swallowed-DB-write path can produce `tracked: true` with no DB effect. Treat as a "request accepted" ack only. | The controller hard-codes `@HttpCode(200)` so you don't get the framework default `201`. ### Errors [#errors] | HTTP | Code / `i18nKey` | Reason | | ----- | ------------------------------ | --------------------------------------------------------------------------------------------------- | | `400` | `analytics.event.invalid_data` | `properties` failed `validateEventData` (size cap or invalid shape). Payload may include the limit. | | `400` | (DTO validation) | `event` missing / non-string. | | `401` | (guard) | Missing / invalid bearer token. | | `429` | `THROTTLE_LIMIT_EXCEEDED` | Over 60 req/min from this IP. `retryAfter` (seconds) included in the response. | ## Side effects [#side-effects] 1. Decode bearer (global `JwtAuthGuard`); `userId = req.user.id`. 2. `ThrottleGuard` increments `throttle::POST:/api/v1/analytics/track` in Redis cache (60-req / 60s window). 3. Read kill switch โ€” `configService.getWithDefault('analytics.tracking_enabled', 'true')`. If not `'true'` โ†’ return `{ tracked: true }` with no further work. 4. Read size cap โ€” `configService.getNumberWithDefault('analytics.max_event_data_bytes', 10000)`. Run `validateEventData(properties, max)`. Failure โ†’ throw `400`. 5. Build event row: `{ id: randomUUID(), userId, sessionId, eventType: event, eventData: properties ?? {} }`. 6. `prisma.funnelEvent.create({ data })` โ€” **direct write to the main DB**, no queue. (A historical Redis-queued path was removed because the module never imported `RedisModule`.) 7. Increment `analyticsEventTrackedTotal` Prometheus counter. 8. Log `[analytics] Event tracked: for user ` (debug level). 9. Return `{ tracked: true }` (controller wraps as `{ success: true, data: { tracked: true } }`). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/analytics/track \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "event": "bio_link_click", "sessionId": "session-uuid-from-create", "properties": { "linkId": "abc-123", "position": 2 } }' ``` ```ts type TrackInput = { event: string; sessionId?: string; properties?: Record; }; async function track(accessToken: string, input: TrackInput): Promise { const res = await fetch('https://api.bio.re/api/v1/analytics/track', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); if (!res.ok) { const json = await res.json().catch(() => null); throw Object.assign(new Error(json?.error?.message ?? 'Track failed'), { code: json?.error?.code, }); } // Don't await success โ€” fire-and-forget at the call site if you don't care. } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useTrackEvent() { return useMutation({ mutationFn: async (input: TrackInput) => { const res = await fetch('/api/v1/analytics/track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!res.ok) { // Don't surface to the user โ€” instrumentation failures shouldn't be UX errors. return; } }, // Don't retry โ€” duplicates are worse than missing one event. retry: false, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `52โ€“59` (class), `68โ€“78` (`track`) | | DTO (request) | `apps/api-core/src/modules/analytics/dto/index.ts` | `9โ€“15` (`TrackEventDto`) | | DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `5โ€“8` (`TrackingResultDto`) | | Service | `apps/api-core/src/modules/analytics/analytics.service.ts` | `23โ€“46` (`trackEvent` โ€” kill switch, size cap, direct DB write) | | Validation | `apps/api-core/src/common/json-validators.ts` | `validateEventData` (size cap enforcement) | | Config keys | (admin-managed) | `analytics.tracking_enabled` (string, kill switch), `analytics.max_event_data_bytes` (number, default `10000`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `FunnelEvent` lines `1681โ€“1695` (main DB โ€” NOT analytics DB) | # Get Active Announcements (/docs/content/announcements) `GET /api/v1/public/announcements` โ€” ๐ŸŒ **Public** ยท Rate limit: **60 req / minute** Returns active announcements โ€” site-wide banners / notices admin-managed. Filter is `active: true AND startsAt <= now (or null) AND endsAt >= now (or null)`. **Cap is 50** (server-side `take: 50`). Each item's `content` is sanitized via the same helper as CMS pages (`` blocks (regex with greedy minimum match). * `` blocks (same pattern). * Inline event handlers โ€” any attribute starting with `on` followed by a word (e.g. `onclick="..."`, `onerror='...'`). * ` ``` ```ts async function getEmbedBioPage(username: string): Promise { const res = await fetch(`https://api.bio.re/api/v1/bio/embed/${encodeURIComponent(username)}`); if (res.status === 403) { throw new Error('Embedding is not enabled for this bio page'); } if (res.status === 404) return null; const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Embed fetch failed'), { code: json?.error?.code, }); } return json.data; } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/creator/bio-analytics.controller.ts` | `26โ€“43` (`getEmbedBioPage`) | | DTO (response) | `apps/api-core/src/modules/creator/dto/bio-analytics-response.dto.ts` | `152โ€“206` (`PublicBioPageResponseDto`) | | Service | `apps/api-core/src/modules/creator/bio-analytics.service.ts` | `52โ€“158` (`getCachedBioPage` โ€” shared with `GET /bio/:username`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `BioPage.embedEnabled` | # Add Bio Link (/docs/creator/bio-link-add) `POST /api/v1/creators/:creatorId/links` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **30 req / hour** Appends a `BioLink` to the creator's `BioPage`. Validates the URL (`http(s)://` only, `javascript:` blocked), strips HTML from the title, runs the title through trust-safety moderation, auto-detects embed type (YouTube / Spotify / TikTok / SoundCloud / Twitch / Apple Music) when not explicitly supplied, and respects the admin-managed `bio.max_links` cap with a row-locked count check. **Manual social links require DM-off.** When `isSocial: true` AND the creator has `dmActive: true`, the request is rejected with `400 social_dm_active`. The platform's design choice: while DM is active, social presence must come from OAuth-verified `SocialAccount` rows (via `POST /creators/social/connect`), not manual links โ€” so fan trust signals stay tied to platform-verified ownership. **Embed auto-detection** runs only when `embedType` is not supplied. Detected types: `YOUTUBE` (with `videoId` in meta), `SPOTIFY` (with `contentType` + `contentId`), `TIKTOK`, `SOUNDCLOUD`, `TWITCH`, `APPLE_MUSIC`. Custom embeds via explicit `embedType: 'CUSTOM'` + `embedMeta`. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | ----------- | ------------- | --------------- | ----------------------------------------------------------- | | `creatorId` | string (UUID) | `ParseUUIDPipe` | Must match the bearer's `CreatorProfile.id` (otherwise 403) | ### Body โ€” `CreateBioLinkDto` [#body--createbiolinkdto] | Field | Type | Required | Validation | Notes | | ---------------- | ----------------- | ----------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `title` | string | โœ“ | `MaxLength(100)` | HTML-stripped server-side, then moderation-checked | | `url` | string | โœ“ | `IsUrl()` | Server validates scheme โ€” `javascript:` rejected as `creator.links.invalid_url` | | `icon` | string | optional | `MaxLength(50)` | Free-form icon identifier (your design system) | | `sortOrder` | number | optional | `IsInt`, `Min(0)`, `Max(1000)` | Defaults to current link count (appended at end) | | `active` | boolean | optional | โ€” | Default `true` | | `embedType` | enum | optional | `IsIn(['YOUTUBE','SPOTIFY','TIKTOK','SOUNDCLOUD','TWITCH','APPLE_MUSIC','CUSTOM'])` | Skipped โ†’ server auto-detects from URL | | `embedMeta` | object | optional | `IsObject` | Free-form, e.g. `{ videoId: '...' }` | | `scheduledStart` | string (ISO 8601) | optional | `IsDateString` | Schedule visibility window start | | `scheduledEnd` | string (ISO 8601) | optional | `IsDateString` | Must be **after** `scheduledStart` | | `isSocial` | boolean | optional | โ€” | When `true`, DM must be off and `platform` is required | | `platform` | string | conditional | `MaxLength(30)` | Required when `isSocial: true`. Whitelist: `instagram`, `x`, `youtube`, `tiktok`, `github`, `linkedin`, `facebook`, `kick`, `twitch`, `snapchat`, `threads`, `pinterest`, `discord` | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `201 Created` โ€” `ApiResponseOf` [#201-created--apiresponseofaddlinkresponsedto] ```json { "success": true, "data": { "id": "l1a2b3c4-d5e6-7890-abcd-ef1234567890" } } ``` | Field | Type | Notes | | ----- | ------------- | --------------------------------------------------------------------------------------------------- | | `id` | string (UUID) | The new `BioLink.id` โ€” pass to `PATCH /creators/links/:linkId` and `DELETE /creators/links/:linkId` | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------------- | --------------------------------------------------------------------------------------------------- | | `400` | `creator.links.schedule_invalid` | `scheduledEnd <= scheduledStart` | | `400` | `creator.links.social_dm_active` | `isSocial: true` AND creator has `dmActive: true` | | `400` | `creator.links.invalid_platform` | `isSocial: true` AND `platform` missing or not in whitelist | | `400` | `creator.links.content_flagged` | Moderation flagged the title | | `400` | `creator.links.invalid_url` | URL not `http(s)://` OR contains `javascript:` | | `400` | `creator.links.max_links` | Existing link count `>= bio.max_links` (admin-managed, default 20). Carries `{ maxLinks }` payload. | | `400` | (DTO validation) | Field length / type / enum failures | | `401` | (guard) | Missing / invalid bearer token | | `403` | (`verifyCreatorOwnership`) | `creatorId` does not belong to the bearer's user | | `404` | `creator.bio.not_found` | `BioPage` row missing for this creator | | `429` | (throttle) | Rate limit exceeded (30 req/hour) | ## Side effects [#side-effects] 1. **Ownership check** โ€” `verifyCreatorOwnership(creatorId, userId)` โ†’ 403 on mismatch. 2. Lookup `BioPage` by `creatorId`; throw `not_found` if missing. 3. **Schedule validation** โ€” if both bounds present, reject when `end <= start`. 4. **Social-link gate** โ€” if `isSocial: true`: lookup `creator.dmActive`, reject when `true`; then platform whitelist check. 5. **Moderation gate** (when `title` supplied) โ€” `trustSafetyService.checkContent(input.title)`; flag โ†’ reject. 6. Read `bio.max_links` (admin-managed, default 20). Generate `id = randomUUID()`. Compute `embed = detectEmbedType(input.url)`. 7. **Inside one transaction** with row-lock: * `SELECT ... FOR UPDATE` on the `BioPage` row to prevent concurrent appends racing past the cap. * `count(BioLink where bioPageId)` โ‰ฅ `maxLinks` โ†’ throw `max_links` (with `{ maxLinks }` payload). * `validateUrl(input.url)` (re-asserts `http(s)://` + `javascript:` block). * `stripHtmlTags(input.title)`. * `bioLink.create` with assembled fields. `sortOrder` defaults to current `linkCount` (appended). Embed type/meta default to auto-detected if neither supplied. 8. **Cache invalidation** โ€” `invalidateBioCache(creatorId)`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/creators/c1a2b3c4-d5e6-7890-abcd-ef1234567890/links \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "title": "Watch on YouTube", "url": "https://youtube.com/watch?v=dQw4w9WgXcQ" }' ``` ```ts type EmbedType = 'YOUTUBE' | 'SPOTIFY' | 'TIKTOK' | 'SOUNDCLOUD' | 'TWITCH' | 'APPLE_MUSIC' | 'CUSTOM'; type CreateBioLinkInput = { title: string; url: string; icon?: string; sortOrder?: number; active?: boolean; embedType?: EmbedType; embedMeta?: Record; scheduledStart?: string; scheduledEnd?: string; isSocial?: boolean; platform?: string; // required when isSocial: true }; async function addBioLink(accessToken: string, creatorId: string, input: CreateBioLinkInput): Promise { const res = await fetch(`https://api.bio.re/api/v1/creators/${creatorId}/links`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Add link failed'), { code: json?.error?.code, maxLinks: json?.error?.maxLinks, // present on 'creator.links.max_links' }); } return json.data.id as string; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useAddBioLink(creatorId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: CreateBioLinkInput) => { const res = await fetch(`/api/v1/creators/${creatorId}/links`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Add link failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, maxLinks: json?.error?.maxLinks, }); } return json.data.id as string; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['creators', creatorId, 'bio'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/creator/creator.controller.ts` | `151โ€“161` (`addLink`) | | DTO (request) | `apps/api-core/src/modules/creator/dto/creator.dto.ts` | `47โ€“80` (`CreateBioLinkDto`) | | DTO (response) | `apps/api-core/src/modules/creator/dto/creator-client-response.dto.ts` | `451โ€“454` (`AddLinkResponseDto`) | | Service | `apps/api-core/src/modules/creator/creator.service.ts` | `333โ€“391` (`addLink`), `42โ€“50` (`validateUrl`), `1065โ€“1081` (`detectEmbedType`) | | Moderation | `apps/api-core/src/modules/trust-safety/trust-safety.service.ts` | `checkContent()` | | Config keys | `apps/api-core/src/modules/config/config.service.ts` | `bio.max_links` (admin-managed) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `BioPage`, `BioLink`, `CreatorProfile.dmActive` | # Delete Bio Link (/docs/creator/bio-link-delete) `DELETE /api/v1/creators/links/:linkId` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **30 req / hour** Hard-deletes a `BioLink` row. Ownership is checked via the link's bio page โ†’ creator โ†’ user chain. The owning `creatorId` is **fetched before the delete** so the public bio cache can be invalidated even after the row is gone โ€” failures of that pre-lookup are swallowed and the delete proceeds. **Hard delete, not soft.** Use `PATCH /creators/links/:linkId` with `active: false` if you want a temporarily-hidden link the creator can re-enable. There's no recycle bin or undo flow on this endpoint. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | -------- | ------------- | --------------- | ---------------------------------------------------- | | `linkId` | string (UUID) | `ParseUUIDPipe` | Must belong to a bio page owned by the bearer's user | No body. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `success` | boolean | Always `true` on 200. The link no longer exists; sortOrder of remaining links is **not** automatically compacted (call `POST /creators/:creatorId/links/reorder` if your UI requires gap-free ordering). | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------- | ------------------------------------------- | | `400` | (validation) | `linkId` not a valid UUID | | `401` | (guard) | Missing / invalid bearer token | | `403` | `creator.links.not_owner` | Link's bio page belongs to a different user | | `404` | `creator.links.not_found` | `BioLink` row missing | | `429` | (throttle) | Rate limit exceeded (30 req/hour) | ## Side effects [#side-effects] 1. **Ownership check** โ€” `verifyLinkOwnership(linkId, userId)` โ†’ 403 on mismatch. 2. **Pre-fetch creatorId** โ€” read the link's `bioPage.creatorId` while the row still exists (failures are caught and ignored โ€” the delete still proceeds). 3. `prisma.bioLink.delete({ where: { id: linkId } })`. 4. If the pre-fetch succeeded: `invalidateBioCache(creatorId)`. Otherwise the cache will expire naturally. ## Code samples [#code-samples] ```bash curl -X DELETE https://api.bio.re/api/v1/creators/links/l1a2b3c4-d5e6-7890-abcd-ef1234567890 \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function deleteBioLink(accessToken: string, linkId: string): Promise { const res = await fetch(`https://api.bio.re/api/v1/creators/links/${linkId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Delete link failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useDeleteBioLink(creatorId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (linkId: string) => { const res = await fetch(`/api/v1/creators/links/${linkId}`, { method: 'DELETE' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Delete link failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['creators', creatorId, 'bio'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | ----------------------------------------------------------- | | Controller | `apps/api-core/src/modules/creator/creator.controller.ts` | `176โ€“187` (`deleteLink`), `107โ€“114` (`verifyLinkOwnership`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/creator/creator.service.ts` | `444โ€“462` (`deleteLink`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `BioLink`, `BioPage` (relation chain for ownership) | # Update Bio Link (/docs/creator/bio-link-update) `PATCH /api/v1/creators/links/:linkId` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **30 req / hour** Sparse update of a single `BioLink`. Ownership is checked via the link's bio page โ†’ creator โ†’ user chain. URL changes re-validate (`http(s)://` only, `javascript:` blocked) and re-trigger embed auto-detection. Title changes run through trust-safety moderation. Schedule bounds are validated when both are present. **Embed re-detection on URL change**: when you update `url` without explicitly setting `embedType` / `embedMeta`, the server re-runs `detectEmbedType()` and writes the freshly detected values. To **clear** an auto-detected embed without changing the URL, set `embedType: 'CUSTOM'` with `embedMeta: {}` explicitly. **Cache invalidation is best-effort.** After the DB write, the server tries to look up the owning `creatorId` to purge the public bio page cache. If that lookup fails (e.g. transient DB issue), the cache will expire naturally on its TTL โ€” the update itself is not rolled back. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | -------- | ------------- | --------------- | ---------------------------------------------------- | | `linkId` | string (UUID) | `ParseUUIDPipe` | Must belong to a bio page owned by the bearer's user | ### Body โ€” `UpdateBioLinkDto` [#body--updatebiolinkdto] All fields optional. Send only what you want to change. | Field | Type | Validation | Notes | | ---------------- | ----------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `title` | string | `MaxLength(100)` | HTML-stripped + moderation-checked | | `url` | string | `IsUrl()` | Re-validated; auto-detected embed gets re-applied unless `embedType`/`embedMeta` also supplied | | `icon` | string | `MaxLength(50)` | โ€” | | `sortOrder` | number | `IsInt`, `Min(0)`, `Max(1000)` | โ€” | | `active` | boolean | โ€” | โ€” | | `embedType` | enum | `IsIn(['YOUTUBE','SPOTIFY','TIKTOK','SOUNDCLOUD','TWITCH','APPLE_MUSIC','CUSTOM'])` | Wins over auto-detection | | `embedMeta` | object | `IsObject` | Wins over auto-detected meta | | `scheduledStart` | string (ISO 8601) | `IsDateString` | Pass `null` semantically by sending `undefined` (omit) โ€” no explicit clear-to-null in DTO | | `scheduledEnd` | string (ISO 8601) | `IsDateString` | Must be after `scheduledStart` (when both present) | | `isSocial` | boolean | โ€” | โ€” | | `platform` | string | `MaxLength(30)` | Lowercased server-side; `null` clears | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | ------------------------------------------------------------------------------------------- | | `success` | boolean | Always `true` on 200. Re-fetch via `GET /creators/:creatorId/bio` for the post-write state. | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------------- | ---------------------------------------------- | | `400` | `creator.links.schedule_invalid` | `scheduledEnd <= scheduledStart` | | `400` | `creator.links.content_flagged` | Moderation flagged the new title | | `400` | `creator.links.invalid_url` | URL not `http(s)://` OR contains `javascript:` | | `400` | (DTO validation) | Field length / type / enum failures | | `401` | (guard) | Missing / invalid bearer token | | `403` | `creator.links.not_owner` | Link's bio page belongs to a different user | | `404` | `creator.links.not_found` | `BioLink` row missing | | `429` | (throttle) | Rate limit exceeded (30 req/hour) | ## Side effects [#side-effects] 1. **Ownership check** โ€” `verifyLinkOwnership(linkId, userId)` traverses `BioLink โ†’ BioPage โ†’ CreatorProfile.userId`; mismatch โ†’ 403. 2. Schedule bounds validation when both supplied. 3. Build sparse `data` object: * `title` โ†’ `stripHtmlTags()`. * `url` โ†’ `validateUrl()`. **If supplied AND `embedType`/`embedMeta` not supplied**, re-run `detectEmbedType()` and inject `embedType` + `embedMeta`. * `platform` โ†’ lowercased (or `null` to clear). * `scheduledStart` / `scheduledEnd` โ†’ ISO string parsed to `Date` (or `null`). 4. **Moderation gate** (when `title` supplied) โ€” flag โ†’ reject. 5. `prisma.bioLink.update({ where: { id: linkId }, data })`. 6. **Best-effort cache invalidation** โ€” try to look up the link's `creatorId`; if found, `invalidateBioCache(creatorId)`. Failures are swallowed (cache will expire naturally). ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/creators/links/l1a2b3c4-d5e6-7890-abcd-ef1234567890 \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "title": "Updated title", "active": false }' ``` ```ts type UpdateBioLinkInput = { title?: string; url?: string; icon?: string; sortOrder?: number; active?: boolean; embedType?: EmbedType; embedMeta?: Record; scheduledStart?: string; scheduledEnd?: string; isSocial?: boolean; platform?: string; }; async function updateBioLink(accessToken: string, linkId: string, input: UpdateBioLinkInput): Promise { const res = await fetch(`https://api.bio.re/api/v1/creators/links/${linkId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Update link failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useUpdateBioLink(creatorId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (vars: { linkId: string; input: UpdateBioLinkInput }) => { const res = await fetch(`/api/v1/creators/links/${vars.linkId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(vars.input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Update link failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['creators', creatorId, 'bio'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/creator/creator.controller.ts` | `163โ€“174` (`updateLink`), `107โ€“114` (`verifyLinkOwnership`) | | DTO (request) | `apps/api-core/src/modules/creator/dto/creator.dto.ts` | `82โ€“115` (`UpdateBioLinkDto`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/creator/creator.service.ts` | `393โ€“442` (`updateLink`), `42โ€“50` (`validateUrl`), `1065โ€“1081` (`detectEmbedType`) | | Moderation | `apps/api-core/src/modules/trust-safety/trust-safety.service.ts` | `checkContent()` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `BioLink`, `BioPage` (relation chain for ownership) | # Reorder Bio Links (/docs/creator/bio-links-reorder) `POST /api/v1/creators/:creatorId/links/reorder` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **30 req / hour** Atomic reorder. Submit the **full ordered list** of `linkIds`; the server sets `sortOrder = index` for each, all inside a single Prisma transaction. The server verifies every submitted id belongs to the creator's bio page before writing โ€” partial / cross-creator id sets are rejected wholesale. **Submit the full list โ€” not a delta.** If you omit a link's id from the array, its `sortOrder` is **not** changed. There is no implicit "leave others alone" โ€” your client must always send the complete current ordering as you'd like it persisted. **Cross-creator id rejection.** The service runs `findMany({ where: { bioPageId, id: { in: linkIds } } })` and compares lengths โ€” any id that doesn't belong to this creator's bio page causes the whole call to fail with `400 not_owned`. No partial writes leak. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | ----------- | ------------- | --------------- | ----------------------------------------------------------- | | `creatorId` | string (UUID) | `ParseUUIDPipe` | Must match the bearer's `CreatorProfile.id` (otherwise 403) | ### Body โ€” `ReorderLinksDto` [#body--reorderlinksdto] | Field | Type | Required | Validation | Notes | | --------- | --------- | -------- | ---------------------------- | ---------------------------------------------------------------------------- | | `linkIds` | string\[] | โœ“ | `IsArray()`, `ArrayUnique()` | The full ordered set of link ids. Position in the array becomes `sortOrder`. | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | ----------------------------------------------------------------------------------------------- | | `success` | boolean | Always `true` on 200. Re-fetch via `GET /creators/:creatorId/bio` to read the post-write order. | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------- | ------------------------------------------------------------------------- | | `400` | (DTO validation) | `linkIds` not unique / not an array | | `400` | `creator.links.not_owned` | At least one submitted `linkId` doesn't belong to this creator's bio page | | `401` | (guard) | Missing / invalid bearer token | | `403` | (`verifyCreatorOwnership`) | `creatorId` does not belong to the bearer's user | | `404` | `creator.bio.not_found` | `BioPage` row missing for this creator | | `429` | (throttle) | Rate limit exceeded (30 req/hour) | ## Side effects [#side-effects] 1. **Ownership check** โ€” `verifyCreatorOwnership(creatorId, userId)` โ†’ 403 on mismatch. 2. Lookup `BioPage` by `creatorId`; throw `not_found` if missing. 3. **Cross-creator id verification** โ€” `bioLink.findMany({ where: { bioPageId, id: { in: linkIds } } })` (max 100). If `result.length !== linkIds.length` โ†’ throw `not_owned`. **No writes occur**. 4. **Atomic write** โ€” build N `bioLink.update({ where: { id }, data: { sortOrder: index } })` operations and run them inside a single `prisma.$transaction(updates)` so all positions land or none do. 5. **Cache invalidation** โ€” `invalidateBioCache(creatorId)`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/creators/c1a2b3c4-d5e6-7890-abcd-ef1234567890/links/reorder \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "linkIds": [ "l3a2b3c4-d5e6-7890-abcd-ef1234567890", "l1a2b3c4-d5e6-7890-abcd-ef1234567890", "l2a2b3c4-d5e6-7890-abcd-ef1234567890" ] }' ``` ```ts async function reorderBioLinks(accessToken: string, creatorId: string, linkIds: string[]): Promise { const res = await fetch(`https://api.bio.re/api/v1/creators/${creatorId}/links/reorder`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ linkIds }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Reorder failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useReorderBioLinks(creatorId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (linkIds: string[]) => { const res = await fetch(`/api/v1/creators/${creatorId}/links/reorder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ linkIds }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Reorder failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, // Optimistic UI: locally re-sort, then call mutation; on error revert onSuccess: () => { qc.invalidateQueries({ queryKey: ['creators', creatorId, 'bio'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ------------------------------------------------------------- | ------------------------------ | | Controller | `apps/api-core/src/modules/creator/creator.controller.ts` | `189โ€“200` (`reorderLinks`) | | DTO (request) | `apps/api-core/src/modules/creator/dto/creator-social.dto.ts` | `35โ€“38` (`ReorderLinksDto`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/creator/creator.service.ts` | `464โ€“483` (`reorderLinks`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `BioPage`, `BioLink.sortOrder` | # Update Bio Page (/docs/creator/bio-page-update) `PATCH /api/v1/creators/:creatorId/bio` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **30 req / hour** Sparse update โ€” only fields present in the body are written. **`bio` is HTML-stripped**, **`customCss` is XSS-sanitized** (`expression(...)`, `javascript:`, non-https `url(...)` removed), and **`bio` content runs through the trust-safety moderation gate** before persistence. On success, the cached public bio page for this creator is invalidated. **XSS sanitization is destructive.** Submitted ` ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------------------- | ----------------------------------- | | Controller | `apps/api-core/src/modules/referral/referral.controller.ts` | `49โ€“56` (`trackClick`) | | DTO (response) | `apps/api-core/src/modules/referral/dto/referral-response.dto.ts` | `311โ€“314` (`TrackClickResponseDto`) | | Service | `apps/api-core/src/modules/referral/referral.service.ts` | `49โ€“53` (`trackClick`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `ReferralLink.clicks` (counter) | # Apply Coupon (/docs/referral/coupon-apply) `POST /api/v1/referral/coupon/apply` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **10 req / minute** ยท Kill-switched Validates a coupon code against the active rules + the user's prior usage, calculates the discount with Decimal arithmetic (no float rounding), and records a `CouponUsage` row โ€” all under a `SELECT ... FOR UPDATE` row lock so concurrent applications can't both pass the `maxUses` check. **TOCTOU-safe.** The full validation chain (active flag, time window, applicability, min purchase, total uses, per-user uses) plus the `CouponUsage` insert run inside one transaction with the coupon row locked via `SELECT ... FOR UPDATE`. Two concurrent requests for the last available slot can't both succeed. **Discount is calculated, not stored as money.** The endpoint returns the discount **value** (a number) โ€” the actual money side-effect happens in the calling flow (e.g. wallet load, message send) which deducts this amount from the transaction price. **The server records the usage; the caller is responsible for actually applying the discount to their domain transaction.** ## Request [#request] ### Body โ€” `ApplyCouponDto` [#body--applycoupondto] | Field | Type | Required | Validation | Notes | | ------------------- | ------ | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `code` | string | โœ“ | `IsString()` | The coupon code to apply (case-sensitive โ€” server does NOT uppercase before lookup) | | `transactionAmount` | number | โœ“ | `IsNumber()`, `Min(1)` | The amount the coupon is being applied against, in dollars (NOT cents). Used for `minPurchase` check + percentage calculation. | | `appliesTo` | string | โœ“ | `IsString()` | What domain the coupon is being applied to (e.g. `message`). Server matches against `Coupon.appliesTo` (`'BOTH'` accepts any). | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofapplycouponresponsedto] ```json { "success": true, "data": { "discount": 2.50 } } ``` | Field | Type | Notes | | ---------- | ------ | ------------------------------------------------------------------------------------------------------------------ | | `discount` | number | Calculated discount in dollars (already capped at `transactionAmount` โ€” won't exceed the input). 2 decimal places. | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Payload | Reason | | ----- | -------------------------------- | --------- | -------------------------------------------------------------------- | | `400` | `referral.coupon.invalid` | โ€” | Code unknown OR `Coupon.active = false` | | `400` | `referral.coupon.not_active` | โ€” | `Coupon.startsAt` is in the future | | `400` | `referral.coupon.expired` | โ€” | `Coupon.endsAt` is in the past | | `400` | `referral.coupon.not_applicable` | โ€” | `Coupon.appliesTo !== 'BOTH'` AND `!== submitted appliesTo` | | `400` | `referral.coupon.min_purchase` | `{ min }` | `transactionAmount < Coupon.minPurchase` | | `400` | `referral.coupon.usage_limit` | โ€” | Total `CouponUsage` count `>= Coupon.maxUses` | | `400` | `referral.coupon.already_used` | โ€” | This user's `CouponUsage` count `>= Coupon.perUserLimit` (default 1) | | `400` | (DTO validation) | โ€” | Missing fields / `transactionAmount < 1` | | `401` | (guard) | โ€” | Missing / invalid bearer token | | `429` | (throttle) | โ€” | Rate limit exceeded (10 req/min) | | `503` | `features.referral_disabled` | โ€” | Admin kill switch `REFERRAL` is active | ## Side effects [#side-effects] **Inside one transaction (`timeout: 10s`):** 1. **Lock the coupon row** โ€” `SELECT ... FOR UPDATE` on `Coupon` by `code`. Missing OR `active = false` โ†’ throw `invalid`. 2. **Time window** โ€” reject if `startsAt > now` (`not_active`) OR `endsAt < now` (`expired`). 3. **Applicability** โ€” reject if `coupon.appliesTo !== 'BOTH' && coupon.appliesTo !== submitted appliesTo`. 4. **Minimum purchase** โ€” reject if `coupon.minPurchase` set AND `transactionAmount < minPurchase`. Error carries `{ min }` payload. 5. **Total usage cap** โ€” `couponUsage.count({ where: { couponId } })`; if `>= maxUses`, throw `usage_limit`. 6. **Per-user cap** โ€” `couponUsage.count({ where: { couponId, userId } })`; if `>= perUserLimit` (default 1), throw `already_used`. 7. **Discount calculation** (Prisma.Decimal arithmetic): * `FIXED` type โ†’ `discount = coupon.value`. * `PERCENTAGE` type โ†’ `discount = transactionAmount * (coupon.value / 100)`. * **Cap at transaction amount** โ€” `discount = min(discount, transactionAmount)`. * Round to 2 decimal places. 8. **Record usage** โ€” `couponUsage.create({ id, couponId, userId, amount: discount })`. After the transaction commits, return `{ discount: discountDec.toNumber() }`. Audit log: `[coupon] Applied : $ off for user `. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/referral/coupon/apply \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "code": "WELCOME20", "transactionAmount": 10, "appliesTo": "message" }' ``` ```ts type ApplyCouponInput = { code: string; transactionAmount: number; appliesTo: string; }; type ApplyCouponResult = { discount: number; }; async function applyCoupon(accessToken: string, input: ApplyCouponInput): Promise { const res = await fetch('https://api.bio.re/api/v1/referral/coupon/apply', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Coupon apply failed'), { code: json?.error?.code, min: json?.error?.min, // present on 'min_purchase' }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useApplyCoupon() { return useMutation({ mutationFn: async (input: ApplyCouponInput) => { const res = await fetch('/api/v1/referral/coupon/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Coupon apply failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, min: json?.error?.min, }); } return json.data as ApplyCouponResult; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/referral/referral.controller.ts` | `66โ€“77` (`applyCoupon`) | | DTO (request) | `apps/api-core/src/modules/referral/dto/index.ts` | `5โ€“14` (`ApplyCouponDto`) | | DTO (response) | `apps/api-core/src/modules/referral/dto/referral-response.dto.ts` | `382โ€“385` (`ApplyCouponResponseDto`) | | Service | `apps/api-core/src/modules/referral/referral.service.ts` | `392โ€“442` (`applyCoupon` โ€” TOCTOU-safe transaction with row lock) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `Coupon` (`code` unique, `type` enum FIXED/PERCENTAGE, `appliesTo`, `maxUses`, `perUserLimit`, `minPurchase`, time window), `CouponUsage` (records each use), `enum CouponType` | # Get Referral Dashboard (/docs/referral/dashboard) `GET /api/v1/referral/dashboard` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **30 req / minute** ยท Kill-switched Returns the user's referral dashboard payload โ€” `link` (the user's `ReferralLink` row with counters), `rewards` (last 50 active rewards, ordered newest-first; clawed-back rewards excluded), and `totalEarnings` (decimal as string from the link's running total). **`link: null` is a valid response.** If the user never called `GET /referral/link`, no `ReferralLink` row exists yet, and the dashboard returns `{ link: null, rewards: [], totalEarnings: '0' }`. Clients should call `/referral/link` once to bootstrap the row, or branch on `link === null` and render an "activate referrals" CTA. **`rewards` excludes clawed-back items.** The filter is `clawedBack: false`. If admin has reversed a reward (commission clawback after a refund / chargeback / fraud), it disappears from this list. The `totalEarnings` snapshot on `link` is also adjusted by clawback flows. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofreferraldashboardresponsedto] ```json { "success": true, "data": { "link": { "id": "rl1a2b3c4-d5e6-7890-abcd-ef1234567890", "code": "alice123", "clicks": 142, "signups": 17, "conversions": 5, "totalEarnings": "25.00", "active": true }, "rewards": [ { "id": "rr1a2b3c4-d5e6-7890-abcd-ef1234567890", "triggerType": "FIRST_PAYMENT", "amount": "5.00", "clawedBack": false, "createdAt": "2026-04-29T20:00:00.000Z" } ], "totalEarnings": "25.00" } } ``` ### Top-level fields [#top-level-fields] | Field | Type | Notes | | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `link` | object \| null | The user's `ReferralLink` row (see fields below). `null` until first `/referral/link` call. | | `rewards` | array | Last 50 active `ReferralReward` rows (`clawedBack: false`), ordered `createdAt DESC` | | `totalEarnings` | string (decimal) | Running total from `ReferralLink.totalEarnings`. **Same value as `link.totalEarnings`** when `link` is non-null โ€” duplicated at top level for convenience. | ### `link` fields [#link-fields] | Field | Type | Notes | | --------------- | ---------------- | --------------------------------------------------------------------------------------------- | | `id` | string (UUID) | `ReferralLink.id` | | `code` | string | The shareable referral code | | `clicks` | number | Lifetime click counter (incremented by `POST /referral/click/:code`) | | `signups` | number | Lifetime new-user signups attributed to this code | | `conversions` | number | Lifetime conversion events (paid/recurring payment triggers) | | `totalEarnings` | string (decimal) | Running total of paid-out reward amounts (decimal as string) | | `active` | boolean | When `false`, the link is admin-disabled โ€” clicks still tracked but no signups/rewards record | ### `rewards` item fields (`ReferralDashboardRewardItemDto`) [#rewards-item-fields-referraldashboardrewarditemdto] | Field | Type | Notes | | ------------- | ----------------- | -------------------------------------------------------------------------------- | | `id` | string (UUID) | `ReferralReward.id` | | `triggerType` | enum | `REGISTRATION` (tracking-only, amount=0) / `FIRST_PAYMENT` / `RECURRING_PAYMENT` | | `amount` | string (decimal) | Reward amount (decimal as string). `'0'` for `REGISTRATION` rows. | | `clawedBack` | boolean | Always `false` here (filter excludes true) | | `createdAt` | string (ISO 8601) | When the reward was recorded | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ---------------------------- | -------------------------------------- | | `401` | (guard) | Missing / invalid bearer token | | `429` | (throttle) | Rate limit exceeded (30 req/min) | | `503` | `features.referral_disabled` | Admin kill switch `REFERRAL` is active | ## Side effects [#side-effects] 1. `prisma.referralLink.findUnique({ where: { userId } })`. 2. **Missing link** โ†’ return `{ link: null, rewards: [], totalEarnings: '0' }`. **No second query.** 3. `prisma.referralReward.findMany({ where: { referrerId: userId, clawedBack: false }, orderBy: createdAt desc, take: 50 })`. 4. Return `{ link, rewards, totalEarnings: String(link.totalEarnings) }`. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/referral/dashboard \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type ReferralDashboardLink = { id: string; code: string; clicks: number; signups: number; conversions: number; totalEarnings: string; active: boolean; }; type ReferralDashboardRewardItem = { id: string; triggerType: 'REGISTRATION' | 'FIRST_PAYMENT' | 'RECURRING_PAYMENT'; amount: string; clawedBack: boolean; createdAt: string; }; type ReferralDashboard = { link: ReferralDashboardLink | null; rewards: ReferralDashboardRewardItem[]; totalEarnings: string; }; async function getReferralDashboard(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/referral/dashboard', { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Dashboard fetch failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const referralKeys = { dashboard: () => ['referral', 'dashboard'] as const, }; export function useReferralDashboard() { return useQuery({ queryKey: referralKeys.dashboard(), queryFn: async () => { const res = await fetch('/api/v1/referral/dashboard'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Dashboard fetch failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as ReferralDashboard; }, staleTime: 30_000, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/referral/referral.controller.ts` | `58โ€“64` (`getDashboard`) | | DTO (response) | `apps/api-core/src/modules/referral/dto/referral-response.dto.ts` | `367โ€“376` (`ReferralDashboardResponseDto`), `320โ€“361` (nested `ReferralDashboardLinkDto` + `ReferralDashboardRewardItemDto`) | | Service | `apps/api-core/src/modules/referral/referral.service.ts` | `356โ€“365` (`getDashboard`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `ReferralLink` (`clicks`, `signups`, `conversions`, `totalEarnings`, `active`), `ReferralReward` (filter `clawedBack: false`), `enum ReferralTriggerType` (`REGISTRATION`, `FIRST_PAYMENT`, `RECURRING_PAYMENT`) | # Get Referral Link (/docs/referral/link) `GET /api/v1/referral/link` โ€” ๐Ÿ”‘ **Bearer** ยท Kill-switched Returns the calling user's personal referral link. **Get-or-create**: first read generates a `ReferralLink` row (code derived from `User.username`, falling back to an 8-char UUID slice on collision); every subsequent read returns the existing row. **Cross-table uniqueness.** The `ReferralLink.code` must NOT collide with any `User.referralCode` (a separate column on the `User` table โ€” also a referral identifier). The server retries up to 3 times with random UUID slices on collision; if all 3 retry, the request fails with `400 code_collision`. **Username drives the default code.** If the user's username is `alice123`, their referral link will be `bio.re/ref/alice123` โ€” username and referral code are linked at first creation, but subsequent username changes do NOT update the existing code. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofreferrallinkcoderesponsedto] ```json { "success": true, "data": { "code": "alice123", "link": "bio.re/ref/alice123" } } ``` | Field | Type | Notes | | ------ | ------ | --------------------------------------------------------------------------- | | `code` | string | The referral identifier โ€” pass to `POST /referral/click/:code` for tracking | | `link` | string | The full shareable URL โ€” `bio.re/ref/` | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `referral.link.code_collision` | Failed to generate a unique code after 3 retries (rare โ€” would require username-vs-other-user collision plus 2 UUID slice collisions in a row) | | `401` | (guard) | Missing / invalid bearer token | | `503` | `features.referral_disabled` | Admin kill switch `REFERRAL` is active | ## Side effects [#side-effects] 1. `referralLink.findUnique({ where: { userId } })`. **Existing row** โ†’ return `{ code, link }` directly. 2. **No row** โ€” load `User.username` for the candidate code; fallback to `randomUUID().slice(0, 8)` if null. 3. **Cross-table collision check** (up to 3 attempts): * `user.findUnique({ where: { referralCode: code } })` โ€” if hit, regenerate with `randomUUID().slice(0, 8)`. * 3rd failure โ†’ throw `code_collision`. 4. `referralLink.create({ id, userId, code })`. 5. Return `{ code, link: 'bio.re/ref/' }`. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/referral/link \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type ReferralLink = { code: string; link: string; }; async function getReferralLink(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/referral/link', { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Referral link fetch failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const referralKeys = { link: () => ['referral', 'link'] as const, }; export function useReferralLink() { return useQuery({ queryKey: referralKeys.link(), queryFn: async () => { const res = await fetch('/api/v1/referral/link'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Referral link fetch failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as ReferralLink; }, staleTime: Infinity, // code never changes after first creation gcTime: 60 * 60_000, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/referral/referral.controller.ts` | `42โ€“47` (`getLink`) | | DTO (response) | `apps/api-core/src/modules/referral/dto/referral-response.dto.ts` | `298โ€“304` (`ReferralLinkCodeResponseDto`) | | Service | `apps/api-core/src/modules/referral/referral.service.ts` | `23โ€“47` (`getOrCreateLink`) | | Kill switch | `apps/api-core/src/common/guards/kill-switch.guard.ts` | `RequireKillSwitch('REFERRAL')` (class-level) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `ReferralLink` (unique `userId`, unique `code`), `User.referralCode` (cross-table uniqueness check) | # Regenerate Backup Codes (/docs/auth/2fa-backup-codes-regenerate) `POST /api/v1/auth/2fa/backup-codes/regenerate` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **3 req / hour** Issues a fresh batch of backup codes (`auth.backup_code_count`, default 10) and atomically deletes the previous batch. **Requires a valid TOTP code** โ€” backup codes are explicitly rejected here, because the operation must prove the user still has access to the authenticator. The previous batch is deleted **before** the response is returned. Any unspent backup codes the user had on paper / in a vault are now dead. Show the new batch and prompt the user to replace stored copies. Why TOTP-only (no backup-code path here)? If a stolen backup code could itself rotate the backup codes, an attacker with one code could lock the legitimate user out. Requiring a live TOTP keeps regeneration tied to authenticator possession. ## Request [#request] ### Body โ€” `VerifyTotpDto` [#body--verifytotpdto] | Field | Type | Required | Validation | Notes | | ------ | ------ | -------- | -------------------------------- | -------------------------------------------------------------------------------------- | | `code` | string | โœ“ | exactly 6 chars (`Length(6, 6)`) | 6-digit TOTP code from the authenticator. **Backup codes are not accepted here.** | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofbackupcodesresponsedto] ```json { "success": true, "data": { "backupCodes": [ "ABCD-2345", "EFGH-6789", "JKLM-2345", "NPQR-6789", "STUV-2345", "WXYZ-6789", "23AB-CDEF", "45GH-JKLM", "67NP-QRST", "89UV-WXYZ" ] } } ``` | Field | Type | Notes | | ------------- | ---------- | ------------------------------------------------------------------------------------------------------------- | | `backupCodes` | `string[]` | New one-time codes in `XXXX-XXXX` format. Count = `auth.backup_code_count`. Server stores bcrypt hashes only. | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ----------------------- | --------------------------------------------------------- | | `400` | `auth.2fa.not_enabled` | `User.twoFactorEnabled = false` โ€” enable 2FA first | | `400` | `auth.2fa.invalid_code` | TOTP code doesn't match (within `auth.totp_window` drift) | | `401` | (guard) | Missing / invalid bearer token | | `429` | (throttle) | Rate limit exceeded (3 req/hour) | ## Side effects [#side-effects] 1. Lookup `User`; assert `twoFactorEnabled = true` AND `twoFactorSecret IS NOT NULL`. 2. Decrypt `User.twoFactorSecret` and `otplib.verify()` the submitted code with `epochTolerance = auth.totp_window`. 3. Generate `auth.backup_code_count` new codes (`XXXX-XXXX`, no `0/O/1/I`); bcrypt-hash each (`auth.salt_rounds`). 4. **Inside one transaction**: * `DELETE FROM BackupCode WHERE userId = :userId` (drop the entire previous batch). * Insert the new batch. 5. Audit log: `[2fa] Backup codes regenerated for user {userId}`. 6. **Sessions are NOT revoked** here โ€” the user keeps their session because the authenticator possession check just succeeded. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/2fa/backup-codes/regenerate \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"code": "123456"}' ``` ```ts type BackupCodes = { backupCodes: string[] }; async function regenerateBackupCodes(accessToken: string, code: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/2fa/backup-codes/regenerate', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ code }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Regenerate failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useRegenerateBackupCodes() { return useMutation({ mutationFn: async (code: string) => { const res = await fetch('/api/v1/auth/2fa/backup-codes/regenerate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Regenerate failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as BackupCodes; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | ------------------------------------- | | Controller | `apps/api-core/src/modules/auth/two-factor.controller.ts` | `76โ€“86` (`regenerateBackupCodes`) | | DTO (request) | `apps/api-core/src/modules/auth/dto/two-factor.dto.ts` | `4โ€“7` (`VerifyTotpDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `126โ€“129` (`BackupCodesResponseDto`) | | Service | `apps/api-core/src/modules/auth/two-factor.service.ts` | `179โ€“219` (`regenerateBackupCodes`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `BackupCode`, `User.twoFactorEnabled` | # Disable 2FA (/docs/auth/2fa-disable) `POST /api/v1/auth/2fa/disable` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **5 req / hour** Disables 2FA on the current account. **Requires the current account password** as a re-confirmation step (in addition to the bearer token). On success: clears `User.twoFactorSecret`, deletes every `BackupCode`, and revokes every `Session` for the user. Sessions are revoked, including the one calling this endpoint. The user must re-login afterwards (no longer with 2FA). Plan a redirect to the login screen on success. The password check exists because the bearer token alone shouldn't be enough to weaken account security. A stolen / leaked access token cannot disable 2FA without also knowing the password. ## Request [#request] ### Body โ€” `DisableTwoFactorDto` [#body--disabletwofactordto] | Field | Type | Required | Validation | Notes | | ---------- | ------ | -------- | -------------- | ----------------------------------------------------------------------------- | | `password` | string | โœ“ | `MinLength(8)` | The current account password โ€” checked against `User.passwordHash` via bcrypt | | Header | Required | Notes | | ------------------------------------- | -------- | ------------------------------------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` (which itself required 2FA) | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `400` | `auth.2fa.no_password` | `User.passwordHash` is null (account was created via OAuth-only โ€” set a password first via `POST /auth/change-password` flow) | | `400` | `auth.2fa.invalid_password` | Password mismatch | | `401` | (guard) | Missing / invalid bearer token | | `429` | (throttle) | Rate limit exceeded (5 req/hour) | ## Side effects [#side-effects] 1. Lookup `User.passwordHash`; if null โ†’ throw `no_password`. 2. `bcrypt.compare(submittedPassword, passwordHash)`; if false โ†’ throw `invalid_password`. 3. **Inside one transaction**: * `User.twoFactorEnabled = false`, `User.twoFactorSecret = null`. * `DELETE FROM BackupCode WHERE userId = :userId` (all of them). * `UPDATE Session SET revoked = true, revokedAt = now() WHERE userId = :userId AND revoked = false`. 4. Audit log: `[2fa] Disabled for user {userId}`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/2fa/disable \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"password": "current-account-password"}' ``` ```ts async function disableTwoFactor(accessToken: string, password: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/2fa/disable', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ password }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA disable failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useDisableTwoFactor() { const qc = useQueryClient(); return useMutation({ mutationFn: async (password: string) => { const res = await fetch('/api/v1/auth/2fa/disable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA disable failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { // Sessions revoked server-side โ€” drop cached identity, force re-login qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/two-factor.controller.ts` | `64โ€“74` (`disable`) | | DTO (request) | `apps/api-core/src/modules/auth/dto/two-factor.dto.ts` | `17โ€“22` (`DisableTwoFactorDto`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/auth/two-factor.service.ts` | `130โ€“161` (`disable`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User.twoFactorEnabled`, `User.twoFactorSecret`, `BackupCode`, `Session.revoked` | # Init 2FA Setup (Stable Shape) (/docs/auth/2fa-setup-init) `POST /api/v1/auth/2fa/setup-init` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **10 req / hour** **Alias** of `POST /auth/2fa/setup` with one cosmetic difference: the response shape includes `recoveryCodes: null` so the frontend can use a single response type across setup โ†’ verify without conditional fields. Behavior is identical to `POST /auth/2fa/setup` โ€” same secret generation, same encrypted persistence, same `already_enabled` guard. The only difference is the explicit `recoveryCodes: null` field. Recovery codes are still issued by `POST /auth/2fa/verify`, never here. ## Why two endpoints [#why-two-endpoints] The original `/setup` returns `{ secret, qrCodeDataUrl, otpauthUrl }`. After `/verify` succeeds, the response is `{ backupCodes: [...] }`. The shapes don't overlap. `/setup-init` returns `{ secret, qrCodeUrl, otpauthUrl, recoveryCodes: null }` so a single TS type covers both stages โ€” `recoveryCodes` flips from `null` (setup-init) to `string[]` (verify), nothing else changes. Use `/setup` if you want the canonical endpoint. Use `/setup-init` if you need the stable response shape across the two-step flow. ## Request [#request] No body. The current user is resolved from the bearer token. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseoftwofactorsetupinitresponsedto] ```json { "success": true, "data": { "secret": "JBSWY3DPEHPK3PXP", "qrCodeUrl": "data:image/png;base64,iVBORw0KGgo...", "otpauthUrl": "otpauth://totp/BIO.RE:creator@bio.re?secret=JBSWY3DPEHPK3PXP&issuer=BIO.RE", "recoveryCodes": null } } ``` | Field | Type | Notes | | --------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `secret` | string (base32) | TOTP secret for manual entry | | `qrCodeUrl` | string | `data:image/png;base64,โ€ฆ` โ€” note the field name is `qrCodeUrl`, not `qrCodeDataUrl` (the only naming drift from `/setup`) | | `otpauthUrl` | string | `otpauth://totp/...` deep-link | | `recoveryCodes` | `null` | **Always `null` here.** Recovery codes are issued by `POST /auth/2fa/verify` after the user proves TOTP works. Forward-compat field so the type stays stable. | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------- | ----------------------------------------- | | `400` | `auth.2fa.already_enabled` | `User.twoFactorEnabled` is already `true` | | `401` | (guard) | Missing / invalid bearer token | | `404` | `auth.2fa.user_not_found` | Token decoded but user row missing | | `429` | (throttle) | Rate limit exceeded (10 req/hour) | ## Side effects [#side-effects] Identical to `POST /auth/2fa/setup`: 1. Lookup `User`; assert `twoFactorEnabled = false`. 2. Generate fresh TOTP secret (`otplib.generateSecret()`). 3. Build `otpauth://` URL + render QR PNG data URL. 4. AES-256-GCM-encrypt the secret and persist to `User.twoFactorSecret`. 5. **No backup code generation** โ€” that's deferred to `/verify`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/2fa/setup-init \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type TwoFactorSetupInit = { secret: string; qrCodeUrl: string; otpauthUrl: string; recoveryCodes: string[] | null; // null on setup-init, string[] after verify }; async function initTwoFactorSetup(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/2fa/setup-init', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA setup-init failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useInitTwoFactorSetup() { return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/auth/2fa/setup-init', { method: 'POST' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA setup-init failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as TwoFactorSetupInit; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ------------------------------------------------------------ | ----------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/two-factor.controller.ts` | `30โ€“50` (`setupInit`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/two-factor-setup.dto.ts` | `15โ€“34` (`TwoFactorSetupInitResponseDto`) | | Service | `apps/api-core/src/modules/auth/two-factor.service.ts` | `31โ€“50` (`generateSetup`, shared) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User.twoFactorSecret`, `User.twoFactorEnabled` | # Generate 2FA Setup (/docs/auth/2fa-setup) `POST /api/v1/auth/2fa/setup` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **10 req / hour** Generates a TOTP secret server-side, AES-256-GCM-encrypts it onto `User.twoFactorSecret`, and returns the secret + QR code data URL + `otpauth://` URL. **Does not yet activate 2FA** โ€” the user must prove they can read TOTP from their authenticator via `POST /auth/2fa/verify` before backup codes are issued and 2FA is flipped on. The `secret` is also rendered into the QR code. Show it once and recommend the user store it via the authenticator. Don't log it. Don't echo it back over an unrelated channel. Backup codes are **not** returned here โ€” they are issued by `POST /auth/2fa/verify` after the user proves their TOTP works. This avoids a window where the user has backup codes for a 2FA setup they never completed. ## Request [#request] No body. The current user is resolved from the bearer token. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `201 Created` โ€” `ApiResponseOf` [#201-created--apiresponseoftwofactorsetupresponsedto] ```json { "success": true, "data": { "secret": "JBSWY3DPEHPK3PXP", "qrCodeDataUrl": "data:image/png;base64,iVBORw0KGgo...", "otpauthUrl": "otpauth://totp/BIO.RE:creator@bio.re?secret=JBSWY3DPEHPK3PXP&issuer=BIO.RE" } } ``` | Field | Type | Notes | | --------------- | --------------- | --------------------------------------------------------------------------- | | `secret` | string (base32) | TOTP secret for manual entry into the authenticator | | `qrCodeDataUrl` | string | `data:image/png;base64,โ€ฆ` โ€” render directly in `` | | `otpauthUrl` | string | `otpauth://totp/...` โ€” useful for native deep-links into authenticator apps | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------- | -------------------------------------------------------------------------------------- | | `400` | `auth.2fa.already_enabled` | `User.twoFactorEnabled` is already `true` โ€” disable first via `POST /auth/2fa/disable` | | `401` | (guard) | Missing / invalid bearer token | | `404` | `auth.2fa.user_not_found` | Token decoded but user row missing (deleted account) | | `429` | (throttle) | Rate limit exceeded (10 req/hour) | ## Side effects [#side-effects] 1. Lookup `User` by id; assert `twoFactorEnabled = false` (else throw `already_enabled`). 2. Generate a fresh TOTP secret via `otplib.generateSecret()` (Google Authenticator / Authy compatible). 3. Build `otpauth://` URL with `BIO.RE` issuer + user email as label. 4. Render QR code as PNG data URL via `qrcode.toDataURL()`. 5. **Encrypt the secret with AES-256-GCM** (`encryptPii`) and persist to `User.twoFactorSecret`. Until `/verify`, this secret exists but `twoFactorEnabled = false` โ€” login is unaffected. 6. Calling this endpoint twice replaces the secret โ€” only the most recent `/setup` matches the `/verify` step. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/2fa/setup \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type TwoFactorSetup = { secret: string; qrCodeDataUrl: string; otpauthUrl: string; }; async function startTwoFactorSetup(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/2fa/setup', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA setup failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useStartTwoFactorSetup() { return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/auth/2fa/setup', { method: 'POST' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA setup failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as TwoFactorSetup; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | ----------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/two-factor.controller.ts` | `20โ€“28` (`generateSetup`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `114โ€“123` (`TwoFactorSetupResponseDto`) | | Service | `apps/api-core/src/modules/auth/two-factor.service.ts` | `31โ€“50` (`generateSetup`) | | PII encryption | `apps/api-core/src/common/pii-encryption.ts` | `encryptPii()` (AES-256-GCM) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User.twoFactorSecret`, `User.twoFactorEnabled` | # Get 2FA Status (/docs/auth/2fa-status) `GET /api/v1/auth/2fa/status` โ€” ๐Ÿ”‘ **Bearer** ยท No throttle Returns the current `User.twoFactorEnabled` value. No mutations โ€” purely a read. The same flag is also returned by `GET /auth/me` as `twoFactorEnabled`. Use this dedicated endpoint when you only need that single bit and want to keep the `me` cache untouched (e.g., to drive a "Two-Factor Authentication" toggle on a settings screen). ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseoftwofactorstatusresponsedto] ```json { "success": true, "data": { "enabled": true } } ``` | Field | Type | Notes | | --------- | ------- | ------------------------------- | | `enabled` | boolean | Mirrors `User.twoFactorEnabled` | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | ------------------------------ | | `401` | (guard) | Missing / invalid bearer token | ## Side effects [#side-effects] 1. Lookup `User.twoFactorEnabled` (single field select). 2. Return `{ enabled }`. No mutations. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/auth/2fa/status \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function getTwoFactorStatus(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/2fa/status', { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Status check failed'), { code: json?.error?.code, }); } return json.data.enabled; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const twoFactorKeys = { status: () => ['auth', '2fa', 'status'] as const, }; export function useTwoFactorStatus() { return useQuery({ queryKey: twoFactorKeys.status(), queryFn: async () => { const res = await fetch('/api/v1/auth/2fa/status'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Status check failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data.enabled as boolean; }, staleTime: 60_000, // 1 min โ€” flips rarely outside of explicit setup/disable }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | ---------------------------------------- | | Controller | `apps/api-core/src/modules/auth/two-factor.controller.ts` | `88โ€“95` (`isEnabled`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `132โ€“135` (`TwoFactorStatusResponseDto`) | | Service | `apps/api-core/src/modules/auth/two-factor.service.ts` | `166โ€“172` (`isEnabled`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User.twoFactorEnabled` | # Verify TOTP & Activate 2FA (/docs/auth/2fa-verify) `POST /api/v1/auth/2fa/verify` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **5 req / hour** Verifies the 6-digit code against the secret stored by `POST /auth/2fa/setup` (or `/setup-init`). On success: flips `User.twoFactorEnabled = true`, generates `auth.backup_code_count` one-time backup codes, and **revokes all existing sessions** for the user (a re-login is required, this time with 2FA). **Backup codes are returned ONCE.** Display them and prompt the user to download / print / save to a password manager. The server only stores bcrypt hashes โ€” there is no way to fetch them again. To rotate, call `POST /auth/2fa/backup-codes/regenerate`. All sessions for this user are revoked on success โ€” including the one calling this endpoint. The next request with the old access token will succeed only until the JWT expires (`auth.access_token_ttl_seconds`); the refresh cookie is dead immediately. Plan to redirect to a 2FA-enabled login flow right after handling the response. ## Request [#request] ### Body โ€” `VerifyTotpDto` [#body--verifytotpdto] | Field | Type | Required | Validation | Notes | | ------ | ------ | -------- | -------------------------------- | ---------------------------------------- | | `code` | string | โœ“ | exactly 6 chars (`Length(6, 6)`) | 6-digit TOTP code from the authenticator | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofbackupcodesresponsedto] ```json { "success": true, "data": { "backupCodes": [ "ABCD-2345", "EFGH-6789", "JKLM-2345", "NPQR-6789", "STUV-2345", "WXYZ-6789", "23AB-CDEF", "45GH-JKLM", "67NP-QRST", "89UV-WXYZ" ] } } ``` | Field | Type | Notes | | ------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `backupCodes` | `string[]` | One-time codes in `XXXX-XXXX` format (alphabet skips `0/O/1/I` to avoid confusion). Count = `auth.backup_code_count` (admin-managed config; default 10). Server stores bcrypt hashes only โ€” these strings are never recoverable. | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------ | ------------------------------------------------------------------- | | `400` | `auth.2fa.setup_not_initiated` | No `User.twoFactorSecret` โ€” call `/setup` first | | `400` | `auth.2fa.already_enabled` | `User.twoFactorEnabled` is already `true` | | `400` | `auth.2fa.invalid_code` | TOTP code doesn't match (within `auth.totp_window` drift tolerance) | | `401` | (guard) | Missing / invalid bearer token | | `429` | (throttle) | Rate limit exceeded (5 req/hour) | ## Side effects [#side-effects] 1. Lookup `User`; assert `twoFactorSecret IS NOT NULL` AND `twoFactorEnabled = false`. 2. Decrypt `User.twoFactorSecret` (`decryptPii` โ€” AES-256-GCM; handles legacy plaintext too). 3. `otplib.verify()` against decrypted secret with `epochTolerance = auth.totp_window` (default 1 step = ยฑ30s). 4. On success, **inside one transaction**: * Generate `auth.backup_code_count` codes (`XXXX-XXXX` format, no `0/O/1/I`). * Bcrypt-hash each code (`auth.salt_rounds`) and insert into `BackupCode` table. * Set `User.twoFactorEnabled = true`. * Update all `Session` rows for this user โ†’ `revoked = true`, `revokedAt = now()`. 5. Audit log: `[2fa] Activated for user {userId}`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/2fa/verify \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"code": "123456"}' ``` ```ts type BackupCodes = { backupCodes: string[] }; async function verifyTwoFactor(accessToken: string, code: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/2fa/verify', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ code }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA verify failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useVerifyTwoFactor() { const qc = useQueryClient(); return useMutation({ mutationFn: async (code: string) => { const res = await fetch('/api/v1/auth/2fa/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA verify failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as BackupCodes; }, onSuccess: () => { // Sessions revoked server-side โ€” invalidate identity + force re-auth flow qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | -------------------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/two-factor.controller.ts` | `52โ€“62` (`verifyAndActivate`) | | DTO (request) | `apps/api-core/src/modules/auth/dto/two-factor.dto.ts` | `4โ€“7` (`VerifyTotpDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `126โ€“129` (`BackupCodesResponseDto`) | | Service | `apps/api-core/src/modules/auth/two-factor.service.ts` | `55โ€“101` (`verifyAndActivate`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User.twoFactorEnabled`, `BackupCode`, `Session.revoked` | # Get Challenge Metadata (/docs/auth/challenge) `GET /api/v1/auth/challenge/{id}` โ€” ๐ŸŒ **Public** ยท Rate limit: **60 req / hour** Returns the metadata for an active `Challenge` so the UI can render appropriate state: masked phone (last 4 digits), expiry, attempts remaining, and the next resend availability time. **Does NOT return the code** โ€” the code only travels via SMS. This endpoint is **public** because Challenge ownership is implicit in the unguessable UUIDv4 `id`. The `id` itself acts as a capability token โ€” if the client lost it (e.g., after a page refresh), they cannot re-acquire it without going through `/send-otp` again. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | ----- | ------------- | --------------- | --------------------------------------------------- | | `id` | string (UUID) | `ParseUUIDPipe` | The `challengeId` returned by `POST /auth/send-otp` | No body, no query. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofchallengemetadataresponsedto] ```json { "success": true, "data": { "challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "purpose": "verify-phone-fan", "phoneMask": "+90 โ€ขโ€ขโ€ข โ€ขโ€ขโ€ข โ€ขโ€ข12", "expiresAt": "2026-04-29T20:15:00.000Z", "attemptsRemaining": 4, "resendAvailableAt": "2026-04-29T20:01:30.000Z" } } ``` | Field | Type | Notes | | ------------------- | ------------------------- | ------------------------------------------------------------------------------- | | `challengeId` | string (UUID) | Echoed back | | `purpose` | `OtpPurpose` | One of: `verify-phone-fan` / `verify-phone-profile` / `2fa-setup` / `login-2fa` | | `phoneMask` | string \| null | Masked โ€” last 4 digits visible (e.g., `+90 โ€ขโ€ขโ€ข โ€ขโ€ขโ€ข โ€ขโ€ข12`) | | `expiresAt` | string (ISO 8601) | Challenge expiry | | `attemptsRemaining` | number | `auth.otp_max_attempts โˆ’ Challenge.attempts` | | `resendAvailableAt` | string (ISO 8601) \| null | Earliest time another `/resend-otp` will succeed (cooldown gate) | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------- | ------------------------------------------ | | `404` | `auth.challenge.not_found` | Challenge ID does not exist OR has expired | | `429` | (throttle) | Rate limit exceeded (60 req/hour) | ## Side effects [#side-effects] 1. Lookup `Challenge` by `id`. 2. Mask phone for display (`maskPhone()` helper โ€” last 4 digits visible). 3. Compute `resendAvailableAt` based on last dispatch + `auth.otp_resend_cooldown_seconds`. 4. No mutations โ€” pure read. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/auth/challenge/a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` ```ts type ChallengeMetadata = { challengeId: string; purpose: 'verify-phone-fan' | 'verify-phone-profile' | '2fa-setup' | 'login-2fa'; phoneMask: string | null; expiresAt: string; attemptsRemaining: number; resendAvailableAt: string | null; }; async function getChallenge(challengeId: string): Promise { const res = await fetch(`https://api.bio.re/api/v1/auth/challenge/${challengeId}`); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const challengeKeys = { detail: (id: string) => ['auth', 'challenge', id] as const, }; export function useChallenge(challengeId: string) { return useQuery({ queryKey: challengeKeys.detail(challengeId), queryFn: async () => { const res = await fetch(`/api/v1/auth/challenge/${challengeId}`); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data as ChallengeMetadata; }, refetchInterval: 1000, // Tick the countdown UI every second }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------- | ---------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `383โ€“401` (`getChallenge`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/challenge.dto.ts` | `27โ€“44` (`ChallengeMetadataResponseDto`) | | Service | `apps/api-core/src/modules/auth/challenge.service.ts` | `getChallenge()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Challenge` | # Change Password (/docs/auth/change-password) `POST /api/v1/auth/change-password` โ€” ๐Ÿ”‘ **User-auth** (Bearer JWT) ยท Rate limit: **3 req / hour** Authenticated password change. Verifies the current password before applying the new one. All existing sessions are revoked on success โ€” user is logged out everywhere except the device that performed the change. This endpoint requires a valid JWT (`Authorization: Bearer `). It overrides the controller-level `@Public()` decorator via `@SetMetadata(IS_PUBLIC_KEY, false)`. ## Request [#request] ### Headers [#headers] | Header | Value | Notes | | --------------- | ---------------------- | --------------------------------- | | `Authorization` | `Bearer ` | Required โ€” JWT from `/auth/login` | | `Content-Type` | `application/json` | Required | ### Body โ€” `ChangePasswordDto` [#body--changepassworddto] | Field | Type | Required | Validation | Notes | | ----------------- | ------ | -------- | ----------------------------------------------- | ------------------------------------------- | | `currentPassword` | string | โœ“ | min 1 char | bcrypt-compared against `User.passwordHash` | | `newPassword` | string | โœ“ | 8โ€“128 chars; must include upper + lower + digit | Must differ from current; bcrypt hashed | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------------------- | ---------------------------------------------------- | | `400` | (DTO validation) | New password fails policy (length, character class) | | `400` | `auth.change_password.same_as_current` | New password matches current password | | `401` | `auth.change_password.invalid_current` | `currentPassword` does not match `User.passwordHash` | | `401` | (no JWT or invalid) | Not authenticated | | `429` | (throttle) | Rate limit exceeded (3 req/hour) | ## Side effects [#side-effects] 1. Verify `currentPassword` against `User.passwordHash` (bcrypt compare). 2. Confirm new password is different from current (bcrypt compare again). 3. Hash new password (bcrypt). 4. Atomic transaction: update `User.passwordHash`, **revoke ALL `Session` records EXCEPT the current one** (the device performing the change stays logged in). 5. Send security alert email: "Your password was changed on `{date}` from `{device}`". 6. Audit log: `auth.change_password.success`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/change-password \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "currentPassword": "OldP@ss123", "newPassword": "NewSecureP@ss456" }' ``` ```ts async function changePassword(currentPassword: string, newPassword: string, accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, }, body: JSON.stringify({ currentPassword, newPassword }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Change failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useChangePassword() { return useMutation({ mutationFn: async (input: { currentPassword: string; newPassword: string }) => { const res = await fetch('/api/v1/auth/change-password', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getAccessToken()}`, }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Change failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { toast.success(t('auth.change_password.success')); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------- | --------------------------------------------------- | ------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `225โ€“243` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `123โ€“135` (`ChangePasswordDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `changePassword()` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User`, `Session` | # Forgot Password (/docs/auth/forgot-password) `POST /api/v1/auth/forgot-password` โ€” ๐ŸŒ **Public** ยท Rate limit: **5 req / hour** Initiates the password reset flow. If the email is registered + active, a reset email is queued (worker-service). Returns success even if the account is unknown โ€” anti-enumeration. **Privacy by default**: response is `200 OK` regardless of whether the email is registered. Client should display a generic "If the address is registered, a reset email has been sent." Do not infer account existence from this response. ## Request [#request] ### Body โ€” `ForgotPasswordDto` [#body--forgotpassworddto] | Field | Type | Required | Validation | Notes | | ------- | ----------------- | -------- | -------------------- | --------------------------- | | `email` | string (RFC 5322) | โœ“ | Trimmed + lowercased | Lookup against `User.email` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofmessageresponsedto] ```json { "success": true, "data": { "message": "Password reset email sent if account exists" } } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | --------------------------------------------------------------------- | | `429` | (throttle) | Rate limit exceeded (5 req/hour โ€” strict to deter enumeration + spam) | ## Side effects [#side-effects] 1. Lookup `User` by email; **always returns success** (anti-enumeration). 2. If user exists + active + email-verified: invalidate prior `PasswordReset` records, create new token, queue reset email (worker-service). 3. Audit log: `auth.forgot_password.requested` (always logged regardless of account existence). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/forgot-password \ -H 'Content-Type: application/json' \ -d '{"email": "creator@example.com"}' ``` ```ts async function forgotPassword(email: string): Promise<{ message: string }> { const res = await fetch('https://api.bio.re/api/v1/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Request failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useForgotPassword() { return useMutation({ mutationFn: async (email: string) => { const res = await fetch('/api/v1/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Request failed'), { code: json?.error?.code, }); } return json.data; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------- | --------------------------------------------------- | ------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `204โ€“212` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `108โ€“113` (`ForgotPasswordDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `forgotPassword()` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User`, `PasswordReset` | # Complete Login with 2FA (/docs/auth/login-2fa) `POST /api/v1/auth/login/2fa` โ€” ๐ŸŒ **Public** ยท Rate limit: **10 req / hour** ยท AUTH-P16 Completes the 2FA login flow. Call this AFTER `POST /auth/login` returned `requiresTwoFactor: true` with a `tempToken`. Returns full JWT pair (access + refresh cookie) on success. ## Request [#request] ### Body โ€” `LoginTwoFactorDto` [#body--logintwofactordto] | Field | Type | Required | Validation | Notes | | ----------- | ------------- | -------- | ------------- | ----------------------------------------------- | | `tempToken` | string (UUID) | โœ“ | `@IsUUID()` | Temp token from prior `/auth/login` response | | `code` | string | โœ“ | `@IsString()` | 6-digit TOTP code OR a backup code (8โ€“10 chars) | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofloginresponsedto] ```json { "success": true, "data": { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900 } } ``` Refresh token is set as `httpOnly` cookie scoped to `.bio.re` (not in body). ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ---------------------------- | ------------------------------------------------ | | `401` | `auth.2fa.invalid_code` | TOTP code wrong, or backup code already used | | `401` | `auth.2fa.challenge_expired` | `tempToken` expired (>5 min) or already consumed | | `429` | (throttle) | Rate limit exceeded (10 req/hour) | ## Side effects [#side-effects] 1. Look up `Challenge` by `tempToken`; verify not expired + not consumed. 2. Verify TOTP code against `User.twoFactorSecret` (decrypted with `ENCRYPTION_MASTER_KEY`) โ€” OR match a backup code from `BackupCode` (deletes on use). 3. Mark `Challenge.usedAt = now()` (single-use). 4. Issue access + refresh JWT pair; create `Session` record. 5. Audit log: `auth.2fa.login.success` or `auth.2fa.login.failure`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/login/2fa \ -H 'Content-Type: application/json' \ -c cookies.txt \ -d '{ "tempToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "code": "123456" }' ``` ```ts async function loginTwoFactor(tempToken: string, code: string): Promise<{ accessToken: string; expiresIn: number }> { const res = await fetch('https://api.bio.re/api/v1/auth/login/2fa', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tempToken, code }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useLoginTwoFactor() { return useMutation({ mutationFn: async (input: { tempToken: string; code: string }) => { const res = await fetch('/api/v1/auth/login/2fa', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? '2FA failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data; }, onSuccess: () => { router.push('/dashboard'); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ------------------------------------------------------ | ------------------------------------------------------------ | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `144โ€“164` | | DTO (request) | `apps/api-core/src/modules/auth/dto/two-factor.dto.ts` | `9โ€“15` (`LoginTwoFactorDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `17โ€“29` (`LoginResponseDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `verifyLoginTwoFactor()` | | Service (TOTP) | `apps/api-core/src/modules/auth/two-factor.service.ts` | TOTP verify | | Prisma models | `packages/prisma/prisma/schema.prisma` | `Challenge`, `BackupCode`, `User.twoFactorSecret`, `Session` | # Login with Email + Password (/docs/auth/login) `POST /api/v1/auth/login` โ€” ๐ŸŒ **Public** ยท Rate limit: **20 req / hour** ยท Captcha: **required** Authenticates with email + password. Two flows: * **Standard** โ†’ returns `accessToken` + `expiresIn` + sets `biore_refresh` httpOnly cookie. * **2FA enabled** โ†’ returns `requiresTwoFactor: true` + `tempToken`. Client must call `POST /auth/login/2fa` next. The refresh token is set as an `httpOnly`, `secure`, `SameSite=Strict` cookie scoped to `.bio.re`. It is **not** in the response body โ€” clients should not read it. ## Request [#request] ### Body โ€” `LoginDto` [#body--logindto] | Field | Type | Required | Validation | Notes | | ---------------- | ----------------- | -------- | ------------------------------------------------------------------- | ------------------------------------------- | | `email` | string (RFC 5322) | โœ“ | Trimmed + lowercased | Lookup against `User.email` | | `password` | string | โœ“ | `IsString()` | bcrypt-compared against `User.passwordHash` | | `captchaToken` | string | โ€” | Validated by `CaptchaGuard` (admin-managed provider) | Required if captcha enabled | | `turnstileToken` | string | โ€” | **Deprecated** alias for `captchaToken` (1 sprint backwards-compat) | โ€” | ## Response [#response] ### `200 OK` โ€” standard flow (`ApiResponseOf`) [#200-ok--standard-flow-apiresponseofloginresponsedto] ```json { "success": true, "data": { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900 } } ``` ### `200 OK` โ€” 2FA flow (LoginResponseDto with `requiresTwoFactor`) [#200-ok--2fa-flow-loginresponsedto-with-requirestwofactor] ```json { "success": true, "data": { "requiresTwoFactor": true, "tempToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------------- | ------------------------------------------------------------- | | `400` | (DTO validation) | Invalid email or missing password | | `401` | `auth.login.invalid_credentials` | Email not found OR password mismatch (deliberately ambiguous) | | `401` | `auth.login.account_locked` | `User.lockedUntil` in the future (too many failed attempts) | | `401` | `auth.login.account_suspended` | `User.status = SUSPENDED` | | `401` | `auth.login.account_deactivated` | `User.status = DEACTIVATED` (use reactivate flow) | | `403` | `auth.login.email_not_verified` | Account exists but `User.emailVerified = false` | | `429` | (throttle) | Rate limit exceeded (20 req/hour) | ## Side effects [#side-effects] 1. Lookup `User` by email. 2. bcrypt compare against `User.passwordHash`. 3. On success: create `Session` record, generate access + refresh JWTs, set httpOnly cookie. 4. On failure: increment `User.loginAttempts`; lock account (`User.lockedUntil`) after threshold (config `auth.lockout_threshold`). 5. If `User.twoFactorEnabled`: skip token issuance, return `tempToken` from `Challenge` table. 6. Track device via `device-tracking.service` โ€” new device โ†’ security alert email. 7. Audit log: `auth.login.success` or `auth.login.failure`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/login \ -H 'Content-Type: application/json' \ -c cookies.txt \ -d '{ "email": "creator@example.com", "password": "SecureP@ss123", "captchaToken": "" }' ``` ```ts type LoginResponse = | { accessToken: string; expiresIn: number } | { requiresTwoFactor: true; tempToken: string }; async function login(email: string, password: string, captchaToken?: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/login', { method: 'POST', credentials: 'include', // accept refresh cookie headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, captchaToken }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Login failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useLogin() { return useMutation({ mutationFn: async (input: { email: string; password: string; captchaToken?: string }) => { const res = await fetch('/api/v1/auth/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Login failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as LoginResponse; }, onSuccess: (data) => { if ('requiresTwoFactor' in data) { router.push(`/auth/2fa?tempToken=${data.tempToken}`); } else { // Store accessToken in memory; refresh cookie is httpOnly router.push('/dashboard'); } }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------- | ---------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `119โ€“142` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `74โ€“90` (`LoginDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `17โ€“29` (`LoginResponseDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `login()`, `device-tracking.service.ts` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User`, `Session`, `Challenge`, `Device` | # Logout (/docs/auth/logout) `POST /api/v1/auth/logout` โ€” ๐ŸŒ **Public** ยท Rate limit: **60 req / hour** Revokes the user's refresh token (from `biore_refresh` httpOnly cookie or `body.refreshToken`) and clears the cookie. Always returns success even if no token is present (idempotent). This endpoint marks the `Session` row revoked. The user's access token (JWT) remains valid until expiry (\~15 minutes) โ€” to truly kill access immediately, also invalidate it client-side and rely on the short access TTL. ## Request [#request] ### Cookies [#cookies] | Cookie | Notes | | --------------- | --------------------------------------------------------- | | `biore_refresh` | httpOnly cookie, automatically sent. Cleared in response. | ### Body โ€” `LogoutDto` (cookie fallback only) [#body--logoutdto-cookie-fallback-only] | Field | Type | Required | Validation | Notes | | -------------- | ------ | -------- | --------------------------- | -------------------------------------------------- | | `refreshToken` | string | โ€” | `@IsOptional() @IsString()` | Used only when no cookie (mobile / non-web client) | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofmessageresponsedto] ```json { "success": true, "data": { "message": "Logged out successfully" } } ``` The `biore_refresh` cookie is set with `Max-Age=0` (cleared). ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | --------------------------------- | | `429` | (throttle) | Rate limit exceeded (60 req/hour) | ## Side effects [#side-effects] 1. Mark `Session.revokedAt = now()` for the matching refresh token (if present). 2. Clear `biore_refresh` cookie via `Set-Cookie` with `Max-Age=0`. 3. Audit log: `auth.logout.success`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/logout \ -b cookies.txt -c cookies.txt ``` ```ts async function logout(): Promise { await fetch('https://api.bio.re/api/v1/auth/logout', { method: 'POST', credentials: 'include', }); } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useLogout() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include', }); }, onSettled: () => { qc.clear(); router.push('/login'); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------- | --------------------------------------------------- | ----------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `188โ€“202` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `103โ€“106` (`LogoutDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `logout()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Session.revokedAt` | # Current User Identity (/docs/auth/me) `GET /api/v1/auth/me` โ€” ๐Ÿ”‘ **User-auth** (Bearer JWT) ยท Rate limit: **60 req / hour** Returns the current authenticated user's identity. Lightweight โ€” only canonical user fields, no role or permission data (use `/users/profile` for richer profile). This is the canonical "who am I" endpoint โ€” frontends typically call it on app boot to hydrate the auth state and decide rendering (creator vs fan, verified vs unverified, status). ## Request [#request] ### Headers [#headers] | Header | Value | Notes | | --------------- | ---------------------- | -------- | | `Authorization` | `Bearer ` | Required | No body, no query params. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofuseridentityresponsedto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "email": "creator@bio.re", "username": "creator", "displayName": "Awesome Creator", "avatarUrl": "https://cdn.bio.re/avatars/abc.jpg", "status": "ACTIVE", "emailVerified": true } } ``` ### `UserIdentityResponseDto` fields [#useridentityresponsedto-fields] | Field | Type | Notes | | --------------- | -------------- | ----------------------------------------------------------------- | | `id` | string (UUID) | `User.id` | | `email` | string | `User.email` (lowercase) | | `username` | string \| null | `User.username` (nullable โ€” fan accounts may not have one) | | `displayName` | string \| null | `User.displayName` | | `avatarUrl` | string \| null | `User.avatarUrl` | | `status` | enum | One of: `ACTIVE`, `SUSPENDED`, `BANNED`, `DELETED`, `DEACTIVATED` | | `emailVerified` | boolean | `User.emailVerified` | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------- | ---------------------------------- | | `401` | (no JWT or invalid) | Not authenticated or token expired | | `429` | (throttle) | Rate limit exceeded (60 req/hour) | ## Side effects [#side-effects] 1. Lookup `User` by `id` (decoded from JWT). 2. Project to `UserIdentityResponseDto` (no relations loaded). 3. No mutations. ## Code samples [#code-samples] ```bash curl -X GET https://api.bio.re/api/v1/auth/me \ -H 'Authorization: Bearer ' ``` ```ts type Identity = { id: string; email: string; username: string | null; displayName: string | null; avatarUrl: string | null; status: 'ACTIVE' | 'SUSPENDED' | 'BANNED' | 'DELETED' | 'DEACTIVATED'; emailVerified: boolean; }; async function getMe(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/me', { headers: { 'Authorization': `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const meKeys = { identity: ['auth', 'me'] as const, }; export function useMe() { return useQuery({ queryKey: meKeys.identity, queryFn: async () => { const res = await fetch('/api/v1/auth/me', { headers: { 'Authorization': `Bearer ${getAccessToken()}` }, }); const json = await res.json(); if (!res.ok || !json.success) { if (res.status === 401) { // Trigger refresh flow or redirect to login throw Object.assign(new Error('Unauthenticated'), { code: 'auth.unauthenticated' }); } throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data as Identity; }, staleTime: 5 * 60 * 1000, // 5 min โ€” identity rarely changes }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------- | ---------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `299โ€“313` | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `49โ€“82` (`UserIdentityResponseDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `getIdentity(userId)` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User` (status, emailVerified, username) | # Link OAuth Provider (/docs/auth/oauth-link) `POST /api/v1/auth/oauth/link` โ€” ๐Ÿ”‘ **User-auth** (Bearer JWT) ยท Rate limit: **20 req / hour** Links an OAuth identity to the current user. Useful when a user registered with email + password and later wants to add Google/Apple/X login as an alternative sign-in. The provider account is **unique per platform**: an OAuth identity that's already linked to another user returns `409 conflict`. Server-side verification of the token (same `OAuthVerifierService` as `/auth/oauth/login`) prevents claim forgery. ## Request [#request] ### Headers [#headers] | Header | Value | Notes | | --------------- | ---------------------- | -------- | | `Authorization` | `Bearer ` | Required | | `Content-Type` | `application/json` | Required | ### Body โ€” `OAuthLinkDto` [#body--oauthlinkdto] Same shape as `OAuthLoginDto` minus `referralCode`: | Field | Type | Required | Notes | | -------------- | ---------------------------- | -------------- | ----------------------- | | `provider` | `'google' \| 'apple' \| 'x'` | โœ“ | `IsIn(OAUTH_PROVIDERS)` | | `idToken` | string | conditional | Google / Apple flow | | `code` | string | conditional | Code flow | | `codeVerifier` | string | required for X | PKCE | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofmessageresponsedto] ```json { "success": true, "data": { "message": "Provider linked successfully" } } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | --------------------------------- | --------------------------------------------------------------- | | `400` | `auth.oauth.already_linked` | This user already has the provider linked | | `400` | `auth.oauth.provider_disabled` | Admin disabled this platform | | `400` | (DTO validation) | Missing both `idToken` and `code`; missing `codeVerifier` for X | | `401` | (no JWT or invalid) | Not authenticated OR token verification failed | | `409` | `auth.oauth.linked_to_other_user` | This OAuth identity is already linked to a different user | | `429` | (throttle) | Rate limit exceeded (20 req/hour) | ## Side effects [#side-effects] 1. Verify token via `OAuthVerifierService.verify()`. 2. Check existing `SocialAccount` for `(provider, providerUserId)`: * If linked to **current user** โ†’ return `400 already_linked`. * If linked to **another user** โ†’ return `409 linked_to_other_user`. 3. Insert `SocialAccount` row tying current user to provider profile. 4. Audit log: `auth.oauth.link.success`. 5. Send notification email: "`{Provider}` account linked to your BIO.RE login on `{date}`". ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/oauth/link \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "provider": "google", "idToken": "" }' ``` ```ts type OAuthLinkInput = { provider: 'google' | 'apple' | 'x'; idToken?: string; code?: string; codeVerifier?: string; }; async function linkOAuth(input: OAuthLinkInput, accessToken: string): Promise<{ message: string }> { const res = await fetch('https://api.bio.re/api/v1/auth/oauth/link', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Link failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; import { meKeys } from './use-me'; export function useOAuthLink() { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: OAuthLinkInput) => { const res = await fetch('/api/v1/auth/oauth/link', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getAccessToken()}`, }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Link failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data; }, onSuccess: () => { qc.invalidateQueries({ queryKey: meKeys.identity }); toast.success(t('auth.oauth.linked')); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ---------------- | ---------------------------------------------------------- | ------------------------ | | Controller | `apps/api-core/src/modules/auth/oauth.controller.ts` | `67โ€“88` (`link`) | | DTO (request) | `apps/api-core/src/modules/auth/dto/oauth.dto.ts` | `35โ€“57` (`OAuthLinkDto`) | | Service (verify) | `apps/api-core/src/modules/auth/oauth-verifier.service.ts` | `verify()` | | Service (link) | `apps/api-core/src/modules/auth/oauth.service.ts` | `linkProvider()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `SocialAccount`, `User` | # Login or Register via OAuth (/docs/auth/oauth-login) `POST /api/v1/auth/oauth/login` โ€” ๐ŸŒ **Public** ยท Rate limit: **10 req / hour** ยท Captcha: **NOT required** (provider handles) Single endpoint for OAuth flows: if the provider's user is unknown, a fresh BIO.RE account is auto-created from the verified profile. If known, the existing account is logged in. Returns `accessToken` + sets `httpOnly` refresh cookie. Server-side verification is **mandatory**. The frontend obtains an `idToken` (Google/Apple) or `code` (any provider) from the SDK, sends it here; the backend re-verifies against the provider's public keys via `OAuthVerifierService` โ€” never trusts the frontend's claim blindly. **Three login providers**: `google`, `apple`, `x`. Despite 15 OAuth platforms in admin (`OAuthProvider` table), only these three are accepted for **login**. The other 12 (discord, github, instagram, linkedin, pinterest, reddit, spotify, threads, tiktok, twitch, youtube, facebook) are **verify-only** โ€” used by `creator/social/connect` to attach social accounts, not to log in. ## Request [#request] ### Body โ€” `OAuthLoginDto` [#body--oauthlogindto] | Field | Type | Required | Validation | Notes | | -------------- | ---------------------------- | -------------- | ----------------------- | --------------------------------------------------------------- | | `provider` | `'google' \| 'apple' \| 'x'` | โœ“ | `IsIn(OAUTH_PROVIDERS)` | Active login provider | | `idToken` | string | conditional | max 5000 chars | Required for Google + Apple flows (alternative to `code`) | | `code` | string | conditional | max 2000 chars | Authorization code from any provider (alternative to `idToken`) | | `codeVerifier` | string | required for X | max 256 chars | PKCE code verifier โ€” **mandatory for X** flow | | `referralCode` | string | โ€” | โ€” | Attribution if a brand-new account is auto-created | At least one of `idToken` or `code` MUST be present. PKCE (`codeVerifier`) is X-specific. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofoauthloginresponsedto] ```json { "success": true, "data": { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900, "isNewUser": false } } ``` | Field | Type | Notes | | ------------- | ------- | ----------------------------------------------------------------------------------- | | `accessToken` | string | Short-lived JWT (\~15 min) | | `expiresIn` | number | Seconds to access token expiry | | `isNewUser` | boolean | `true` if this call auto-created a fresh BIO.RE account; client may show onboarding | Refresh token is set as `httpOnly` cookie scoped to `.bio.re`. ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `auth.oauth.provider_disabled` | `provider` not in active login set OR admin disabled the platform | | `400` | (DTO validation) | Missing both `idToken` and `code`; missing `codeVerifier` for X | | `401` | `auth.oauth.token_invalid` | Provider rejected the token (signature, audience, expiry) | | `409` | `auth.oauth.email_exists` | Account already exists under that email; response includes `hasPassword` + `hasOAuth` flags so client can prompt linking via `POST /auth/oauth/link` | | `429` | (throttle) | Rate limit exceeded (10 req/hour) | ## Side effects [#side-effects] 1. **OAuthVerifierService.verify(provider, idToken, code, codeVerifier)** โ€” exchanges code for tokens (when `code` flow) and verifies idToken against provider's JWK set. 2. Lookup existing `SocialAccount` by `(provider, providerUserId)`. If found โ†’ load `User`, log in. 3. If not found, lookup `User` by verified `email` from provider profile. If found โ†’ conflict (`email_exists`) โ€” client must call `/auth/oauth/link`. 4. If neither match โ†’ auto-register: insert `User` (verified email, no password), `SocialAccount`, optional `referredBy` resolution, `ConsentRecord` (terms + privacy implicitly accepted via OAuth flow per platform policy). 5. Issue access + refresh JWT pair, set httpOnly cookie. 6. Audit log: `auth.oauth.login.success` or `.register.success`. ## Code samples [#code-samples] ```bash # Google flow (idToken from Google Identity Services) curl -X POST https://api.bio.re/api/v1/auth/oauth/login \ -H 'Content-Type: application/json' \ -c cookies.txt \ -d '{ "provider": "google", "idToken": "" }' # X flow (code + PKCE verifier) curl -X POST https://api.bio.re/api/v1/auth/oauth/login \ -H 'Content-Type: application/json' \ -c cookies.txt \ -d '{ "provider": "x", "code": "", "codeVerifier": "" }' ``` ```ts type OAuthLoginInput = { provider: 'google' | 'apple' | 'x'; idToken?: string; code?: string; codeVerifier?: string; referralCode?: string; }; type OAuthLoginResponse = { accessToken: string; expiresIn: number; isNewUser: boolean; }; async function oauthLogin(input: OAuthLoginInput): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/oauth/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'OAuth failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useOAuthLogin() { return useMutation({ mutationFn: async (input: OAuthLoginInput) => { const res = await fetch('/api/v1/auth/oauth/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'OAuth failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as OAuthLoginResponse; }, onSuccess: (data) => { router.push(data.isNewUser ? '/onboarding' : '/dashboard'); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------------------ | ---------------------------------------------------------- | --------------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/oauth.controller.ts` | `30โ€“63` (`login`) | | DTO (request) | `apps/api-core/src/modules/auth/dto/oauth.dto.ts` | `6โ€“33` (`OAuthLoginDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `36โ€“45` (`OAuthLoginResponseDto`) | | Service (verify) | `apps/api-core/src/modules/auth/oauth-verifier.service.ts` | `verify()` | | Service (login/register) | `apps/api-core/src/modules/auth/oauth.service.ts` | `loginOrRegister()` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User`, `SocialAccount`, `Session`, `ConsentRecord` | # List OAuth Login Providers (/docs/auth/oauth-providers) `GET /api/v1/auth/oauth/providers` โ€” ๐ŸŒ **Public** ยท Rate limit: **60 req / hour** ๐Ÿ› ๏ธ **Admin-managed**: this list is sourced from the `OAuthProvider` table โ€” providers can be enabled/disabled by admin without redeploy. **Never hardcode** `['google', 'x', 'apple']` in the frontend; always read from this endpoint and render only what comes back. Returns the list of OAuth platforms currently enabled for **login** (subset of all 15 platforms; the rest are verify-only for social account linking). Each entry has the public client ID needed to bootstrap the provider's frontend SDK. ## Request [#request] No headers, no body, no params. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofoauthprovidersresponsedto] ```json { "success": true, "data": { "providers": [ { "platform": "google", "clientId": "123456789-abc.apps.googleusercontent.com", "redirectUri": "https://bio.re/auth/callback/google" }, { "platform": "apple", "clientId": "com.bio.re.signin", "redirectUri": "https://bio.re/auth/callback/apple" }, { "platform": "x", "clientId": "abcdef123456", "redirectUri": "https://bio.re/auth/callback/x" } ] } } ``` ### `OAuthProviderDto` fields [#oauthproviderdto-fields] | Field | Type | Notes | | ------------- | ---------------------------- | ------------------------------------------------------------------------------------- | | `platform` | `'google' \| 'apple' \| 'x'` | OAuth login provider key โ€” passes back into `POST /auth/oauth/login` `provider` field | | `clientId` | string | Public client ID for the provider's frontend SDK | | `redirectUri` | string \| null | Configured redirect URI; null if not set in admin | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | --------------------------------- | | `429` | (throttle) | Rate limit exceeded (60 req/hour) | ## Side effects [#side-effects] 1. Read all `OAuthProvider` rows where `enabled = true` AND `loginEnabled = true`. 2. Project to `{ platform, clientId, redirectUri }` (never expose secrets). 3. Cached in-memory by `OAuthVerifierService` for \~5 minutes (admin updates invalidate the cache via internal pub/sub). 4. No mutations. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/auth/oauth/providers ``` ```ts type OAuthProvider = { platform: 'google' | 'apple' | 'x'; clientId: string; redirectUri: string | null; }; async function getOAuthProviders(): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/oauth/providers'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data.providers; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const oauthKeys = { providers: ['auth', 'oauth', 'providers'] as const, }; export function useOAuthProviders() { return useQuery({ queryKey: oauthKeys.providers, queryFn: async () => { const res = await fetch('/api/v1/auth/oauth/providers'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data.providers as OAuthProvider[]; }, staleTime: 5 * 60 * 1000, // mirrors backend cache }); } ``` Render dynamically: ```tsx const { data: providers = [] } = useOAuthProviders(); return ( <> {providers.map((p) => ( ))} ); ``` Never `` `` `` hardcoded โ€” admin can disable a provider at any time. ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/oauth.controller.ts` | `114โ€“123` (`providers`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `138โ€“152` (`OAuthProviderDto`, `OAuthProvidersResponseDto`) | | Service | `apps/api-core/src/modules/auth/oauth-verifier.service.ts` | `getPublicClientIds()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `OAuthProvider` (15 platforms โ€” `loginEnabled` filters to login subset) | # Unlink OAuth Provider (/docs/auth/oauth-unlink) `DELETE /api/v1/auth/oauth/unlink/{provider}` โ€” ๐Ÿ”‘ **User-auth** (Bearer JWT) ยท Rate limit: **20 req / hour** Detaches an OAuth identity from the current user. Refuses to unlink if it's the user's **only** sign-in method (no password + no other OAuth) โ€” protects against accidental lockout. **Anti-lockout guard**: if the user has no `User.passwordHash` AND only this provider is linked, the request returns `400 only_auth_method`. Set a password first via the password reset flow if you want to unlink the only OAuth login. ## Request [#request] ### Headers [#headers] | Header | Value | Notes | | --------------- | ---------------------- | -------- | | `Authorization` | `Bearer ` | Required | ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | ---------- | ---------------------------- | -------------------------------------------- | ---------------------------------------------------------------------- | | `provider` | `'google' \| 'apple' \| 'x'` | Allowlisted in controller (`validProviders`) | Hardcoded check โ€” non-login providers (discord, github, etc.) rejected | No body, no query. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofmessageresponsedto] ```json { "success": true, "data": { "message": "Provider unlinked successfully" } } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------ | ------------------------------------------------------------- | | `400` | `auth.oauth.provider_disabled` | `provider` not in `['google', 'apple', 'x']` | | `400` | `auth.oauth.not_linked` | Current user has no `SocialAccount` row for this provider | | `400` | `auth.oauth.only_auth_method` | Anti-lockout โ€” no password + this is the last linked provider | | `401` | (no JWT or invalid) | Not authenticated | | `429` | (throttle) | Rate limit exceeded (20 req/hour) | ## Side effects [#side-effects] 1. Validate `provider` against allowlist (`['google', 'apple', 'x']`). 2. Look up `SocialAccount` row for `(userId, provider)`; verify exists. 3. Anti-lockout check: count user's `SocialAccount` rows + check `User.passwordHash`. If unlinking would leave **zero** sign-in methods โ†’ reject `400 only_auth_method`. 4. Delete `SocialAccount` row. 5. Audit log: `auth.oauth.unlink.success`. 6. Send notification email: "`{Provider}` unlinked from your BIO.RE login on `{date}`". ## Code samples [#code-samples] ```bash curl -X DELETE https://api.bio.re/api/v1/auth/oauth/unlink/google \ -H 'Authorization: Bearer ' ``` ```ts async function unlinkOAuth(provider: 'google' | 'apple' | 'x', accessToken: string): Promise<{ message: string }> { const res = await fetch(`https://api.bio.re/api/v1/auth/oauth/unlink/${provider}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Unlink failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; import { meKeys } from './use-me'; export function useOAuthUnlink() { const qc = useQueryClient(); return useMutation({ mutationFn: async (provider: 'google' | 'apple' | 'x') => { const res = await fetch(`/api/v1/auth/oauth/unlink/${provider}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${getAccessToken()}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Unlink failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data; }, onSuccess: () => { qc.invalidateQueries({ queryKey: meKeys.identity }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------ | ---------------------------------------------------- | ------------------------------------ | | Controller | `apps/api-core/src/modules/auth/oauth.controller.ts` | `92โ€“110` (`unlink`) | | Service | `apps/api-core/src/modules/auth/oauth.service.ts` | `unlinkProvider()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `SocialAccount`, `User.passwordHash` | # Validate Reactivation Token (/docs/auth/reactivate-validate) `GET /api/v1/auth/reactivate/validate?token=...` โ€” ๐ŸŒ **Public** ยท Rate limit: **30 req / hour** Pre-flight check: validates a reactivation token without committing reactivation. Returns the account status (`paused` / `pending-deletion` / `expired` / `deleted`) so the landing page can render appropriate UI before the user clicks the "Reactivate" button. The actual reactivation happens via `POST /users/reactivate` (separate endpoint, in the `user` module). This endpoint is read-only โ€” it lets the UI show "Welcome back, your account is paused โ€” click below to reactivate" vs "This account was deleted and cannot be recovered". ## Request [#request] ### Query parameters [#query-parameters] | Param | Type | Required | Notes | | ------- | ------ | -------- | --------------------------------------------- | | `token` | string | โœ“ | Opaque reactivation token from the email link | No body, no path params, no headers. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofreactivatevalidateresponsedto] ```json { "success": true, "data": { "valid": true, "status": "paused", "userMaskEmail": "u***@b***.re", "deletionDate": null } } ``` | Field | Type | Notes | | --------------- | ------------------------- | --------------------------------------------------------------------------------------------------- | | `valid` | boolean | `false` if token is malformed, unknown, used, or expired | | `status` | enum | One of: `paused` / `pending-deletion` / `expired` / `deleted` | | `userMaskEmail` | string \| null | Masked email (single-letter prefix per `maskEmail` helper) of the account associated with the token | | `deletionDate` | string (ISO 8601) \| null | Hard-deletion schedule โ€” present **only** when `status = pending-deletion` | ### Status semantics [#status-semantics] | `status` | Meaning | UI guidance | | ------------------ | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `paused` | Account is `DEACTIVATED`, no pending deletion โ†’ reactivate eligible | "Reactivate your account" CTA | | `pending-deletion` | Account is `DEACTIVATED` + `GDPRRequest DELETION` pending in grace window โ†’ reactivate eligible | "Cancel deletion + reactivate (deadline `{deletionDate}`)" CTA | | `expired` | Token signature valid but `expiresAt` past โ†’ user must re-request link | "This link expired โ€” request a new one" | | `deleted` | Account purged (`UserStatus.DELETED`) โ†’ cannot reactivate | "This account has been permanently deleted" โ€” terminal | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | --------------------------------- | | `429` | (throttle) | Rate limit exceeded (30 req/hour) | The endpoint always returns 200 โ€” invalid / unknown / expired tokens come back with `valid: false` (not 4xx) so the landing page can render a coherent expired-link UI. ## Side effects [#side-effects] 1. Decode + verify token signature. 2. Look up associated `User` + `GDPRRequest` (if any). 3. Compute status from `User.status` + `GDPRRequest.status` + `expiresAt`. 4. Mask email for display. 5. **No mutations** โ€” purely a read endpoint. The reactivation itself is a separate POST. ## Code samples [#code-samples] ```bash curl 'https://api.bio.re/api/v1/auth/reactivate/validate?token=abc123-from-email-link' ``` ```ts type ReactivateStatus = 'paused' | 'pending-deletion' | 'expired' | 'deleted'; type ReactivateValidate = { valid: boolean; status: ReactivateStatus; userMaskEmail: string | null; deletionDate: string | null; }; async function validateReactivate(token: string): Promise { const url = new URL('https://api.bio.re/api/v1/auth/reactivate/validate'); url.searchParams.set('token', token); const res = await fetch(url); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const reactivateKeys = { validate: (token: string) => ['auth', 'reactivate', 'validate', token] as const, }; export function useReactivateValidate(token: string) { return useQuery({ queryKey: reactivateKeys.validate(token), queryFn: async () => { const url = new URL('/api/v1/auth/reactivate/validate', window.location.origin); url.searchParams.set('token', token); const res = await fetch(url); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data as ReactivateValidate; }, enabled: Boolean(token), retry: false, // Token state is deterministic โ€” no point retrying }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `407โ€“423` (`reactivateValidate`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/reactivate-validate.dto.ts` | `34โ€“45` (`ReactivateValidateResponseDto`), `21โ€“24` (`REACTIVATE_STATUSES`) | | Service | `apps/api-core/src/modules/auth/challenge.service.ts` | `validateReactivationToken()` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User.status`, `GDPRRequest` | # Refresh Access Token (/docs/auth/refresh) `POST /api/v1/auth/refresh` โ€” ๐ŸŒ **Public** ยท Rate limit: **60 req / hour** Rotates the refresh token. Reads from the `biore_refresh` httpOnly cookie by default, or from `body.refreshToken` as fallback. Issues a new access token + new refresh token (cookie). Detects refresh token reuse โ†’ revokes ALL sessions for the user (security alert). **Token reuse detection**: if a refresh token is presented after it's been rotated, the entire user session family is revoked and a security alert email is sent. Clients must always discard the old refresh token immediately upon receiving a new one. ## Request [#request] ### Cookies [#cookies] | Cookie | Notes | | --------------- | ---------------------------------------------------------------------------------------------- | | `biore_refresh` | httpOnly, Secure, SameSite=Strict, scoped to `.bio.re`. Auto-sent by browsers; preferred path. | ### Body โ€” `RefreshDto` (cookie fallback only) [#body--refreshdto-cookie-fallback-only] | Field | Type | Required | Validation | Notes | | -------------- | ------ | -------- | --------------------------- | ----------------------------------------------------------------- | | `refreshToken` | string | โ€” | `@IsOptional() @IsString()` | Used only when cookie is absent (e.g., mobile app non-web client) | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofloginresponsedto] ```json { "success": true, "data": { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900 } } ``` A new `biore_refresh` cookie is set. The old refresh token is revoked (Session record updated). ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ----------------------------------- | --------------------------------------------------------------------------- | | `401` | `auth.refresh.invalid_token` | Refresh token not found, expired, or already rotated | | `401` | `auth.refresh.token_reuse_detected` | **Security alert** โ€” token reused after rotation; all user sessions revoked | | `401` | `auth.refresh.account_suspended` | `User.status = SUSPENDED` between issuance and refresh | | `429` | (throttle) | Rate limit exceeded (60 req/hour) | ## Side effects [#side-effects] 1. Verify refresh token signature + lookup `Session` record. 2. **Reuse check**: if `Session.rotatedAt != null`, all sessions for this `User` are revoked + admin alert + email to user (security incident). 3. On valid refresh: mark old `Session.rotatedAt = now()`, create new Session with new refresh token, issue new access token. 4. Update httpOnly cookie with new refresh token. 5. Audit log: `auth.refresh.success` or `auth.refresh.token_reuse_detected`. ## Code samples [#code-samples] ```bash # Cookie-based (preferred): curl -X POST https://api.bio.re/api/v1/auth/refresh \ -b cookies.txt -c cookies.txt # Body-based (mobile / non-cookie clients): curl -X POST https://api.bio.re/api/v1/auth/refresh \ -H 'Content-Type: application/json' \ -d '{"refreshToken": "..."}' ``` ```ts async function refresh(): Promise<{ accessToken: string; expiresIn: number }> { const res = await fetch('https://api.bio.re/api/v1/auth/refresh', { method: 'POST', credentials: 'include', // sends + receives biore_refresh cookie }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Refresh failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useRefresh() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/auth/refresh', { method: 'POST', credentials: 'include', }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Refresh failed'), { code: json?.error?.code, }); } return json.data; }, onError: (err: { code?: string }) => { if (err.code === 'auth.refresh.token_reuse_detected') { // Security incident โ€” force re-login qc.clear(); router.push('/login?reason=security_alert'); } }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------- | --------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `166โ€“186` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `98โ€“101` (`RefreshDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `17โ€“29` (`LoginResponseDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `refresh()`, reuse detection logic | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Session.rotatedAt` (token rotation tracking) | # Register New Account (/docs/auth/register) `POST /api/v1/auth/register` โ€” ๐ŸŒ **Public** ยท Rate limit: **10 req / hour** ยท Captcha: **required** Creates a new user account. Sends a verification email. Returns the new `userId` so the client can navigate to the email verification flow. Username is optional at registration; users can claim a username later. This endpoint enforces the platform-wide kill switch `platform.registration_enabled` (admin-managed). When the switch is off, registration returns `403 Forbidden` with i18nKey `auth.register.closed`. ## Request [#request] ### Headers [#headers] | Header | Value | Notes | | ------------------ | ------------------ | -------------------------------------------- | | `Content-Type` | `application/json` | Required | | `User-Agent` | (auto) | Captured into `User.registrationDevice` | | `Accept-Language` | (auto) | Used as fallback when `body.locale` is empty | | `cf-connecting-ip` | (auto, Cloudflare) | Source for GeoIP `User.registrationCountry` | ### Body โ€” `RegisterDto` [#body--registerdto] | Field | Type | Required | Validation | Notes | | ------------------------------------------------------------ | -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `email` | string (RFC 5322) | โœ“ | Trimmed + lowercased; disposable domain blocked; MX record checked | Persisted to `User.email` (unique) | | `password` | string | โœ“ | 8โ€“128 chars; must include upper + lower + digit | bcrypt hashed (salt rounds from config `auth.salt_rounds`, min 10) | | `acceptedTerms` | boolean | โœ“ | Must equal `true` | GDPR consent record written | | `acceptedPrivacy` | boolean | โœ“ | Must equal `true` | GDPR consent record written | | `username` | string | โ€” | 1โ€“100 chars, `^[a-z0-9._-]+$` | Unique across User + ReservedUsername | | `displayName` | string | โ€” | max 100 chars | UI display (separate from username) | | `intent` | `'creator' \| 'fan'` | โ€” | max 20 chars | Sets `User.intent`; null = undecided | | `captchaToken` | string | โ€” | Validated by `CaptchaGuard` against the **active captcha provider** (admin-managed via `external.captcha.active_provider`) | Required when captcha is enabled | | `referralCode` | string | โ€” | Resolves against `User.referralCode` first, then `ReferralLink.code` | Sets `User.referredBy` | | `locale` | string | โ€” | Must be in `platform.supported_locales` | Falls back to `Accept-Language` then platform default | | `utmSource` `utmMedium` `utmCampaign` `utmTerm` `utmContent` | string ร— 5 | โ€” | max 100 chars each | Permanent attribution | | `firstReferrerUrl` | string | โ€” | max 2048 chars | Permanent attribution | | `firstLandingPage` | string | โ€” | max 2048 chars | Permanent attribution | Field `turnstileToken` is **deprecated** โ€” accepted as a backwards-compat alias for `captchaToken` for one sprint. Do not use in new code; the active provider is admin-selected via `external.captcha.active_provider`. ## Response [#response] ### `201 Created` โ€” success [#201-created--success] ```json { "success": true, "data": { "userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "message": "Registration successful. Please check your email to verify your account." } } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | | `400` | `auth.register.invalid_email` | Disposable domain, invalid MX, or otherwise rejected by `EmailValidatorService` | | `400` | (DTO validation array) | `class-validator` failure (password policy, terms not accepted, etc.) | | `403` | `auth.register.closed` | Platform kill switch `platform.registration_enabled = false` | | `409` | `auth.register.email_exists` | Email already registered | | `409` | `auth.register.account_previously_deleted` | Email matches a GDPR-deleted tombstone (SHA-256 hash) | | `409` | `auth.register.username_unavailable` | Username taken in `User` or `ReservedUsername` | | `409` | `auth.register.referral_code_collision` | Generated referral code collided 3 times against `ReferralLink.code` (rare; client can retry) | | `429` | (throttle) | Rate limit exceeded (10 req/hour) | All non-validation errors follow the platform error envelope: ```json { "success": false, "error": { "code": "auth.register.email_exists", "message": "Email already registered", "i18nKey": "auth.register.email_exists", "correlationId": "..." } } ``` ## Side effects [#side-effects] 1. **Kill-switch check** โ€” `platform.registration_enabled` (ConfigService). 2. **Email validation** โ€” disposable domain + MX record check (`EmailValidatorService`). 3. **Email uniqueness** โ€” Prisma `User.email` unique. 4. **GDPR tombstone check** โ€” SHA-256 of email matched against `email = 'deleted_@deleted.bio.re'` and `status = DELETED`. 5. **Username uniqueness** โ€” both `User.username` and `ReservedUsername.username`. 6. **Referral resolution** โ€” `User.referralCode` first, fallback to `ReferralLink.code`. 7. **Password hashing** โ€” bcrypt, configurable salt rounds (min 10). 8. **Atomic transaction** โ€” `User` insert + `EmailVerification` insert + `ConsentRecord` (terms, privacy) insert. 9. **Cross-table referral code uniqueness** โ€” generated code retried up to 3 times against `ReferralLink.code`. 10. **GeoIP lookup** โ€” non-blocking; populates `User.registrationCountry`. 11. **Welcome / verification email** โ€” queued to worker-service, dispatched via the **active email provider** (admin-managed via `external.email.active_provider`; failover handled server-side). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/register \ -H 'Content-Type: application/json' \ -H 'Accept-Language: en' \ -d '{ "email": "creator@example.com", "username": "creator", "password": "SecureP@ss123", "acceptedTerms": true, "acceptedPrivacy": true, "displayName": "Awesome Creator", "intent": "creator", "captchaToken": "", "locale": "en" }' ``` ```ts type RegisterRequest = { email: string; password: string; acceptedTerms: true; acceptedPrivacy: true; username?: string; displayName?: string; intent?: 'creator' | 'fan'; captchaToken?: string; referralCode?: string; locale?: string; }; type RegisterResponse = { userId: string; message: string }; async function register(input: RegisterRequest): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { const code = json?.error?.code ?? `http_${res.status}`; throw Object.assign(new Error(json?.error?.message ?? 'Register failed'), { code }); } return json.data as RegisterResponse; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useRegister() { return useMutation({ mutationFn: async (input: RegisterRequest) => { const res = await fetch('/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Register failed'), { code: json?.error?.code ?? `http_${res.status}`, i18nKey: json?.error?.i18nKey, }); } return json.data as RegisterResponse; }, onSuccess: ({ userId }) => { // Navigate to email verification page router.push(`/auth/verify-email?userId=${userId}`); }, }); } ``` ## Try it [#try-it] **Staging only.** Production API requires admin-managed captcha tokens and is rate-limited. Set `NEXT_PUBLIC_OPENAPI_PROXY_URL` env var to a staging proxy if you want browser-side requests; otherwise use the playground for request shape inspection only. ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------- | --------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `60โ€“96` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `5โ€“65` (`RegisterDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `6โ€“12` (`RegisterResponseDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `126` (`register()`, \~120 lines) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `24` (`User`) | # Resend Phone OTP (/docs/auth/resend-otp) `POST /api/v1/auth/resend-otp` โ€” ๐ŸŒ **Public** ยท Rate limit: **10 req / hour** Generates a fresh code for an existing `Challenge`, resets `attempts = 0`, increments `resendCount`, and dispatches a new SMS via the **active SMS provider** (admin-managed). Two server-side caps: * `auth.otp_max_resends` โ€” per-challenge resend ceiling. * `auth.otp_resend_cooldown_seconds` โ€” minimum seconds between resends. ## Request [#request] ### Body โ€” `ResendOtpDto` [#body--resendotpdto] | Field | Type | Required | Validation | Notes | | ------------- | ------------- | -------- | ---------- | --------------------------------- | | `challengeId` | string (UUID) | โœ“ | `IsUUID()` | Returned by `POST /auth/send-otp` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofchallengedispatchresponsedto] ```json { "success": true, "data": { "challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "expiresAt": "2026-04-29T20:25:00.000Z", "attemptsRemaining": 5, "resendCount": 1 } } ``` Same shape as `/send-otp` but with `resendCount` populated. | Field | Type | Notes | | ------------------- | ----------------- | -------------------------------------------- | | `challengeId` | string (UUID) | Same id โ€” Challenge row reused, code rotated | | `expiresAt` | string (ISO 8601) | New TTL window from now | | `attemptsRemaining` | number | Reset to `auth.otp_max_attempts` | | `resendCount` | number | Total dispatches so far for this Challenge | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ----------------------------- | ------------------------------------------------------- | | `400` | `auth.otp.resend.cap_reached` | `Challenge.resendCount >= auth.otp_max_resends` | | `400` | `auth.otp.resend.cooldown` | Last dispatch within `auth.otp_resend_cooldown_seconds` | | `404` | `auth.otp.resend.not_found` | `challengeId` does not exist | | `429` | (throttle) | Rate limit exceeded (10 req/hour) | ## Side effects [#side-effects] 1. Lookup `Challenge` by `challengeId`; verify exists, not used, not expired. 2. Cap check: `Challenge.resendCount < auth.otp_max_resends` AND last dispatch โ‰ฅ `auth.otp_resend_cooldown_seconds` ago. 3. Generate fresh 6-digit code; bcrypt-hash; update `Challenge.codeHash`. 4. Reset `Challenge.attempts = 0`; increment `Challenge.resendCount`; refresh `expiresAt`. 5. Dispatch SMS via the **active SMS provider** (admin-managed via `external.sms.active_provider`). 6. Audit log: `auth.otp.resend.success` (with masked phone). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/resend-otp \ -H 'Content-Type: application/json' \ -d '{"challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}' ``` ```ts async function resendOtp(challengeId: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/resend-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challengeId }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Resend OTP failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useResendOtp() { return useMutation({ mutationFn: async (challengeId: string) => { const res = await fetch('/api/v1/auth/resend-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challengeId }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Resend OTP failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as ChallengeDispatch; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ------------------------------------------------------ | ------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `359โ€“381` (`resendOtp`) | | DTO (request) | `apps/api-core/src/modules/auth/dto/resend-otp.dto.ts` | `4โ€“8` (`ResendOtpDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/challenge.dto.ts` | `7โ€“22` (`ChallengeDispatchResponseDto`) | | Service | `apps/api-core/src/modules/auth/challenge.service.ts` | `resendOtp()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Challenge.resendCount`, `Challenge.codeHash`, `Challenge.attempts` | # Resend Verification Email (/docs/auth/resend-verification) `POST /api/v1/auth/resend-verification` โ€” ๐ŸŒ **Public** ยท Rate limit: **5 req / hour** ยท Captcha: **required** Re-dispatches the verification email if an unverified account exists for the given address. Always returns success (does NOT leak whether the email is registered) โ€” privacy-by-default. This endpoint always returns 200 even if the email is unknown โ€” to prevent account enumeration. Client should display a generic "If the address is registered, a verification email has been sent." message. ## Request [#request] ### Body โ€” `ResendVerificationDto` [#body--resendverificationdto] | Field | Type | Required | Validation | Notes | | ------- | ----------------- | -------- | -------------------- | --------------------------- | | `email` | string (RFC 5322) | โœ“ | Trimmed + lowercased | Lookup against `User.email` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofmessageresponsedto] ```json { "success": true, "data": { "message": "Verification email sent (if account exists)" } } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | ------------------------------------------------------- | | `429` | (throttle) | Rate limit exceeded (5 req/hour โ€” strict to deter spam) | ## Side effects [#side-effects] 1. Lookup `User` by email; **always returns success** even if not found (anti-enumeration). 2. If user exists + not verified: invalidate prior `EmailVerification` records, create new token, queue email job to `worker-service` โ€” dispatched via the **active email provider** (admin-managed via `external.email.active_provider`; failover handled server-side). 3. Audit log: `auth.resend_verification.requested` (success regardless of account existence). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/resend-verification \ -H 'Content-Type: application/json' \ -d '{"email": "creator@example.com"}' ``` ```ts async function resendVerification(email: string): Promise<{ message: string }> { const res = await fetch('https://api.bio.re/api/v1/auth/resend-verification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Resend failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useResendVerification() { return useMutation({ mutationFn: async (email: string) => { const res = await fetch('/api/v1/auth/resend-verification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Resend failed'), { code: json?.error?.code, }); } return json.data; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------- | --------------------------------------------------- | --------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `108โ€“117` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `67โ€“72` (`ResendVerificationDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `resendVerification()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User`, `EmailVerification` | # Reset Password (/docs/auth/reset-password) `POST /api/v1/auth/reset-password` โ€” ๐ŸŒ **Public** ยท Rate limit: **3 req / hour** Completes the password reset flow. Validates the reset token from the email link, updates `User.passwordHash`, and revokes all existing sessions for the user (forces re-login). ## Request [#request] ### Body โ€” `ResetPasswordDto` [#body--resetpassworddto] | Field | Type | Required | Validation | Notes | | ------------- | ------ | -------- | ----------------------------------------------- | --------------------------------------------------- | | `token` | string | โœ“ | `@IsString()` | Reset token from email link (`PasswordReset.token`) | | `newPassword` | string | โœ“ | 8โ€“128 chars; must include upper + lower + digit | bcrypt hashed (salt rounds from `auth.salt_rounds`) | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ----------------------------------- | ---------------------------------------------------------------------- | | `400` | `auth.reset_password.invalid_token` | Token not found, expired, or already consumed | | `400` | (DTO validation) | Password policy violation (length, character class) | | `429` | (throttle) | Rate limit exceeded (3 req/hour โ€” extra strict for password mutations) | ## Side effects [#side-effects] 1. Lookup `PasswordReset` by token; verify not expired + not used. 2. bcrypt hash new password (salt rounds from config). 3. Atomic transaction: update `User.passwordHash`, mark `PasswordReset.usedAt = now()`, **revoke ALL active `Session` records** (force re-login on every device). 4. Reset `User.loginAttempts = 0` and `User.lockedUntil = null` (account unlocked). 5. Send security alert email: "Your password was reset on `{date}`". 6. Audit log: `auth.reset_password.success`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/reset-password \ -H 'Content-Type: application/json' \ -d '{ "token": "abc123-reset-token-from-email", "newPassword": "NewSecureP@ss456" }' ``` ```ts async function resetPassword(token: string, newPassword: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, newPassword }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Reset failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useResetPassword() { return useMutation({ mutationFn: async (input: { token: string; newPassword: string }) => { const res = await fetch('/api/v1/auth/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Reset failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { router.push('/login?reason=password_reset'); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------- | --------------------------------------------------- | ---------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `214โ€“223` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `115โ€“121` (`ResetPasswordDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `resetPassword()` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User`, `PasswordReset`, `Session` | # Send Phone OTP (/docs/auth/send-otp) `POST /api/v1/auth/send-otp` โ€” ๐ŸŒ **Public** ยท Rate limit: **3 req / 10 min** Creates a `Challenge` record and dispatches a 6-digit code via SMS to the given phone number. Returns the `challengeId` that the client passes to `/verify-otp`, `/resend-otp`, and `/challenge/{id}`. The SMS is sent via the **active SMS provider** (admin-managed via `external.sms.active_provider`; failover handled server-side). Vendor identity stays in admin โ€” this endpoint guarantees delivery via whichever provider is active. ## Purposes (`OtpPurpose` enum) [#purposes-otppurpose-enum] | Purpose | Auth required | Notes | | ---------------------- | ------------- | ------------------------------------------------ | | `verify-phone-fan` | โŒ Public | Signup flow โ€” account not yet created | | `verify-phone-profile` | ๐Ÿ”‘ Bearer | Account settings โ†’ add or change phone | | `2fa-setup` | ๐Ÿ”‘ Bearer | Enabling 2FA via SMS (paired with TOTP setup) | | `login-2fa` | โŒ Public | Uses `tempToken` from `/auth/login`, not session | ## Request [#request] ### Body โ€” `SendOtpDto` [#body--sendotpdto] | Field | Type | Required | Validation | Notes | | --------- | -------------- | -------- | --------------------------------------- | -------------------------------------------- | | `phone` | string (E.164) | โœ“ | max 20 chars; regex `^\+[1-9]\d{7,14}$` | E.g., `+15551234567` โ€” plus prefix mandatory | | `purpose` | `OtpPurpose` | โœ“ | `IsEnum(OTP_PURPOSES)` | One of the four purposes above | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofchallengedispatchresponsedto] ```json { "success": true, "data": { "challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "expiresAt": "2026-04-29T20:15:00.000Z", "attemptsRemaining": 5 } } ``` | Field | Type | Notes | | ------------------- | ----------------- | -------------------------------------------------------- | | `challengeId` | string (UUID) | Pass to `/verify-otp`, `/resend-otp`, `/challenge/{id}` | | `expiresAt` | string (ISO 8601) | TTL = `auth.otp_ttl_minutes` (admin-managed config) | | `attemptsRemaining` | number | Initial = `auth.otp_max_attempts` (admin-managed config) | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------- | ------------------------------------------------------------------ | | `400` | `auth.otp.send.rate_limit` | Per-phone rate limit exhausted (`auth.otp_per_phone_max_per_hour`) | | `400` | (DTO validation) | Phone not E.164, or invalid purpose enum | | `429` | (throttle) | Endpoint rate limit exceeded (3 req / 10 min) | ## Side effects [#side-effects] 1. **Per-phone rate-limit check** โ€” config-driven (`auth.otp_per_phone_max_per_hour`). 2. Generate 6-digit code; bcrypt-hash it (`Challenge.codeHash`). 3. Insert `Challenge` row with `purpose`, `phone`, `codeHash`, `expiresAt`, `attempts = 0`, `resendCount = 0`. 4. Dispatch SMS to phone via the **active SMS provider** (admin-managed via `external.sms.active_provider`). Failover is handled server-side. 5. Audit log: `auth.otp.sent` (with masked phone โ€” last 4 digits only). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/send-otp \ -H 'Content-Type: application/json' \ -d '{ "phone": "+15551234567", "purpose": "verify-phone-fan" }' ``` ```ts type OtpPurpose = 'verify-phone-fan' | 'verify-phone-profile' | '2fa-setup' | 'login-2fa'; type ChallengeDispatch = { challengeId: string; expiresAt: string; attemptsRemaining: number; resendCount?: number; }; async function sendOtp(phone: string, purpose: OtpPurpose): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/send-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, purpose }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Send OTP failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useSendOtp() { return useMutation({ mutationFn: async (input: { phone: string; purpose: OtpPurpose }) => { const res = await fetch('/api/v1/auth/send-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Send OTP failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as ChallengeDispatch; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------- | ------------------------------------------------ | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `315โ€“339` (`sendOtp`) | | DTO (request) | `apps/api-core/src/modules/auth/dto/send-otp.dto.ts` | `15โ€“25` (`SendOtpDto`), `11โ€“12` (`OTP_PURPOSES`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/challenge.dto.ts` | `7โ€“22` (`ChallengeDispatchResponseDto`) | | Service | `apps/api-core/src/modules/auth/challenge.service.ts` | `sendOtp()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Challenge` | # Revoke Single Session (/docs/auth/session-revoke) `DELETE /api/v1/auth/sessions/{id}` โ€” ๐Ÿ”‘ **User-auth** (Bearer JWT) ยท Rate limit: **20 req / hour** Revokes a specific session. The user's other devices using that session lose access immediately (their refresh tokens are killed; access tokens expire within 15 min). Cannot revoke the **current** session โ€” that requires `/auth/logout`. Attempting to revoke the session that owns the requesting refresh token returns `400 cannot_revoke_current_session`. Use `/auth/logout` instead โ€” semantic separation. ## Request [#request] ### Headers [#headers] | Header | Value | Notes | | --------------------------- | ---------------------- | -------------------------------------- | | `Authorization` | `Bearer ` | Required | | `Cookie: biore_refresh=...` | (auto) | Used to detect "current session" guard | ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | ----- | ------------- | --------------- | ------------------------------------ | | `id` | string (UUID) | `ParseUUIDPipe` | Session ID from `GET /auth/sessions` | No body, no query. ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------------- | -------------------------------------------------------------------------------- | | `400` | `auth.sessions.cannot_revoke_current` | The session ID matches the requesting refresh token โ€” use `/auth/logout` instead | | `400` | (UUID validation) | `id` is not a valid UUID | | `401` | (no JWT or invalid) | Not authenticated | | `404` | `auth.sessions.not_found` | Session does not exist OR does not belong to current user | | `429` | (throttle) | Rate limit exceeded (20 req/hour) | ## Side effects [#side-effects] 1. Look up `Session` by `id`; verify `Session.userId` matches the authenticated user. 2. Verify session ID is NOT the current refresh token's session (anti-foot-shoot). 3. Mark `Session.revokedAt = now()`. 4. Audit log: `auth.sessions.revoke.success`. ## Code samples [#code-samples] ```bash curl -X DELETE https://api.bio.re/api/v1/auth/sessions/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H 'Authorization: Bearer ' \ -b cookies.txt ``` ```ts async function revokeSession(sessionId: string, accessToken: string): Promise { const res = await fetch(`https://api.bio.re/api/v1/auth/sessions/${sessionId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${accessToken}` }, credentials: 'include', }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Revoke failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; import { sessionKeys } from './use-sessions'; export function useRevokeSession() { const qc = useQueryClient(); return useMutation({ mutationFn: async (sessionId: string) => { const res = await fetch(`/api/v1/auth/sessions/${sessionId}`, { method: 'DELETE', credentials: 'include', headers: { 'Authorization': `Bearer ${getAccessToken()}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Revoke failed'), { code: json?.error?.code, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: sessionKeys.all }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------ | --------------------------------------------------- | --------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `260โ€“280` (`revokeSession`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `revokeSession()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Session.revokedAt` | # Revoke All Other Sessions (/docs/auth/sessions-revoke-all) `POST /api/v1/auth/sessions/revoke-all` โ€” ๐Ÿ”‘ **User-auth** (Bearer JWT) ยท Rate limit: **5 req / hour** Revokes all sessions for the authenticated user **EXCEPT the current one**. Pair with `GET /auth/sessions` to power "Sign out all other devices" buttons in account settings. The current session (the one matching the requesting `biore_refresh` cookie) stays alive. Other devices' refresh tokens become invalid immediately; their access tokens expire within \~15 min. ## Request [#request] ### Headers [#headers] | Header | Value | Notes | | --------------------------- | ---------------------- | ------------------------------------------------- | | `Authorization` | `Bearer ` | Required | | `Cookie: biore_refresh=...` | (auto) | Used to identify the current session (kept alive) | No body, no path/query params. ## Response [#response] ### `200 OK` โ€” empty body [#200-ok--empty-body] ```json { "success": true } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------- | ----------------------------------------------------------------------- | | `401` | (no JWT or invalid) | Not authenticated | | `429` | (throttle) | Rate limit exceeded (5 req/hour โ€” strict; this is a destructive action) | ## Side effects [#side-effects] 1. Hash the current refresh token (SHA-256) โ†’ match against `Session.refreshTokenHash` to identify the current session. 2. Update all `Session` rows where `userId = current user` AND `id != current session id` AND `revokedAt IS NULL`: set `revokedAt = now()`. 3. Audit log: `auth.sessions.revoke_all.success` (with count of sessions revoked). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/sessions/revoke-all \ -H 'Authorization: Bearer ' \ -b cookies.txt ``` ```ts async function revokeAllSessions(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/sessions/revoke-all', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}` }, credentials: 'include', }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Revoke-all failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; import { sessionKeys } from './use-sessions'; export function useRevokeAllSessions() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/auth/sessions/revoke-all', { method: 'POST', credentials: 'include', headers: { 'Authorization': `Bearer ${getAccessToken()}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Revoke-all failed'), { code: json?.error?.code, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: sessionKeys.all }); toast.success(t('auth.sessions.revoke_all.success')); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------ | --------------------------------------------------- | ----------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `282โ€“298` (`revokeAllSessions`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `revokeAllSessions()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Session.refreshTokenHash`, `Session.revokedAt` | # List Active Sessions (/docs/auth/sessions) `GET /api/v1/auth/sessions` โ€” ๐Ÿ”‘ **User-auth** (Bearer JWT) ยท Rate limit: **30 req / hour** Returns the current user's active session list. Each entry includes the device (parsed User-Agent), masked IP, last-active timestamp, and an `isCurrent` flag indicating which session matches the requesting refresh token. Use this endpoint to power "Active sessions" UI in account settings. Combine with `DELETE /auth/sessions/{id}` (revoke one) and `POST /auth/sessions/revoke-all` (revoke all except current). ## Request [#request] ### Headers [#headers] | Header | Value | Notes | | --------------------------- | ---------------------- | ---------------------------------- | | `Authorization` | `Bearer ` | Required | | `Cookie: biore_refresh=...` | (auto) | Used to determine `isCurrent` flag | No body, no query params. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofsessionlistresponsedto] ```json { "success": true, "data": { "sessions": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "device": "Chrome on macOS", "ipMasked": "192.168.1.***", "location": null, "isCurrent": true, "createdAt": "2026-04-20T10:30:00.000Z", "lastActiveAt": "2026-04-29T18:45:23.000Z" }, { "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", "device": "Safari on iOS", "ipMasked": "10.0.0.***", "location": null, "isCurrent": false, "createdAt": "2026-04-15T08:00:00.000Z", "lastActiveAt": "2026-04-28T12:15:00.000Z" } ] } } ``` ### `SessionItemDto` fields [#sessionitemdto-fields] | Field | Type | Notes | | -------------- | ----------------- | ---------------------------------------------------------------- | | `id` | string (UUID) | Session ID โ€” pass to `DELETE /auth/sessions/{id}` to revoke | | `device` | string \| null | Parsed from `Session.userAgent` (e.g., "Chrome on macOS") | | `ipMasked` | string \| null | IP with last octet masked (`192.168.1.***`) | | `location` | string \| null | Reserved for future GeoIP integration (currently `null`) | | `isCurrent` | boolean | `true` for the session that matches the requesting refresh token | | `createdAt` | string (ISO 8601) | When the session was created (login time) | | `lastActiveAt` | string (ISO 8601) | Last refresh or API call timestamp | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------- | --------------------------------- | | `401` | (no JWT or invalid) | Not authenticated | | `429` | (throttle) | Rate limit exceeded (30 req/hour) | ## Side effects [#side-effects] 1. Read all `Session` rows for `userId` where `revokedAt = null` and `expiresAt > now()`. 2. Hash the requesting refresh token from cookie โ†’ match against `Session.refreshTokenHash` to determine `isCurrent`. 3. Mask IPs (last octet) before serialization. 4. No mutations. ## Code samples [#code-samples] ```bash curl -X GET https://api.bio.re/api/v1/auth/sessions \ -H 'Authorization: Bearer ' \ -b cookies.txt ``` ```ts type Session = { id: string; device: string | null; ipMasked: string | null; location: string | null; isCurrent: boolean; createdAt: string; lastActiveAt: string; }; async function getSessions(accessToken: string): Promise<{ sessions: Session[] }> { const res = await fetch('https://api.bio.re/api/v1/auth/sessions', { headers: { 'Authorization': `Bearer ${accessToken}` }, credentials: 'include', }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const sessionKeys = { all: ['auth', 'sessions'] as const, }; export function useSessions() { return useQuery({ queryKey: sessionKeys.all, queryFn: async () => { const res = await fetch('/api/v1/auth/sessions', { credentials: 'include', headers: { 'Authorization': `Bearer ${getAccessToken()}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed'), { code: json?.error?.code, }); } return json.data as { sessions: Session[] }; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------- | ----------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `244โ€“278` (`getSessions`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/response.dto.ts` | `85โ€“106` (`SessionItemDto`), `108โ€“111` (`SessionListResponseDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `getSessions()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Session` | # Verify Email (/docs/auth/verify-email) `POST /api/v1/auth/verify-email` โ€” ๐ŸŒ **Public** ยท Rate limit: **20 req / hour** Verifies the user's email using the UUID token sent during registration. On success the `User.emailVerified` flag flips to `true`. Idempotent โ€” re-verifying an already-verified token returns the same success response. ## Request [#request] ### Body โ€” `VerifyEmailDto` [#body--verifyemaildto] | Field | Type | Required | Validation | Notes | | ------- | ---------------- | -------- | ----------- | ------------------------------------------------------------------- | | `token` | string (UUID v4) | โœ“ | `@IsUUID()` | Token sent via `EmailVerification.token` from the registration flow | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | --------------------------------- | --------------------------------------------- | | `400` | `auth.verify_email.invalid_token` | Token not found, expired, or already consumed | | `400` | (DTO validation) | Token is not a valid UUID | | `429` | (throttle) | Rate limit exceeded (20 req/hour) | ## Side effects [#side-effects] 1. Look up `EmailVerification` by `token` (Prisma). 2. If valid + unexpired: set `User.emailVerified = true`, `EmailVerification.usedAt = now()` in atomic transaction. 3. Audit log entry (`auth.verify_email.success`). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/verify-email \ -H 'Content-Type: application/json' \ -d '{"token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}' ``` ```ts async function verifyEmail(token: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/verify-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Verification failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useVerifyEmail() { return useMutation({ mutationFn: async (token: string) => { const res = await fetch('/api/v1/auth/verify-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Verification failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------- | --------------------------------------------------- | ----------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `98โ€“106` | | DTO (request) | `apps/api-core/src/modules/auth/dto/index.ts` | `92โ€“96` (`VerifyEmailDto`) | | Service | `apps/api-core/src/modules/auth/auth.service.ts` | `verifyEmail()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `EmailVerification`, `User.emailVerified` | # Verify Phone OTP (/docs/auth/verify-otp) `POST /api/v1/auth/verify-otp` โ€” ๐ŸŒ **Public** ยท Rate limit: **20 req / hour** Verifies the 6-digit code against `Challenge.codeHash` (bcrypt). On success, the `Challenge` is marked as used (`Challenge.usedAt = now()`). The calling flow consumes the verified Challenge to gate its own state change (e.g., flip `User.phoneVerified = true`). The response shape includes optional `accessToken` + `expiresIn` fields **reserved for a future SMS-OTP login bridge**. They are currently always absent โ€” for the login 2FA path, continue using `POST /auth/login/2fa` with TOTP / backup codes. ## Request [#request] ### Body โ€” `VerifyOtpDto` [#body--verifyotpdto] | Field | Type | Required | Validation | Notes | | ------------- | ------------- | -------- | -------------------------------- | --------------------------------- | | `challengeId` | string (UUID) | โœ“ | `IsUUID()` | Returned by `POST /auth/send-otp` | | `code` | string | โœ“ | exactly 6 chars; regex `^\d{6}$` | Numeric OTP from SMS | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofverifyotpresponsedto] ```json { "success": true, "data": { "success": true } } ``` | Field | Type | Notes | | ------------- | ------------------- | -------------------------------------------------------------------------------------------- | | `success` | boolean | Always `true` on 200 | | `accessToken` | string \| undefined | **Reserved** โ€” currently always undefined. Will carry a JWT once SMS-OTP login bridge ships. | | `expiresIn` | number \| undefined | **Reserved** โ€” mirrors `/auth/login.expiresIn` semantics when present | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------------ | --------------------------------------------- | | `401` | `auth.otp.verify.invalid` | Code mismatch โ€” `Challenge.attempts++` | | `401` | `auth.otp.verify.expired` | `Challenge.expiresAt` is past | | `401` | `auth.otp.verify.attempts_exhausted` | `Challenge.attempts >= auth.otp_max_attempts` | | `401` | `auth.otp.verify.already_used` | `Challenge.usedAt != null` (single-use) | | `429` | (throttle) | Rate limit exceeded (20 req/hour) | ## Side effects [#side-effects] 1. Lookup `Challenge` by `challengeId`; verify `expiresAt > now()` AND `usedAt = null` AND `attempts < auth.otp_max_attempts`. 2. bcrypt-compare submitted `code` against `Challenge.codeHash`. 3. On success: set `Challenge.usedAt = now()` (single-use lock). 4. On failure: increment `Challenge.attempts`. If cap reached, all further attempts return `attempts_exhausted`. 5. Audit log: `auth.otp.verify.success` or `auth.otp.verify.failure`. 6. **Calling flow consumes the verified Challenge** to make its state change (e.g., `verify-phone-fan` โ†’ set `User.phoneVerified = true` in the next call). This endpoint does NOT make user-facing mutations beyond the Challenge row. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/auth/verify-otp \ -H 'Content-Type: application/json' \ -d '{ "challengeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "code": "123456" }' ``` ```ts type VerifyOtpResponse = { success: boolean; accessToken?: string; expiresIn?: number; }; async function verifyOtp(challengeId: string, code: string): Promise { const res = await fetch('https://api.bio.re/api/v1/auth/verify-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challengeId, code }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Verify OTP failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useVerifyOtp() { return useMutation({ mutationFn: async (input: { challengeId: string; code: string }) => { const res = await fetch('/api/v1/auth/verify-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Verify OTP failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as VerifyOtpResponse; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ------------------------------------------------------ | -------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/auth/auth.controller.ts` | `341โ€“357` (`verifyOtp`) | | DTO (request) | `apps/api-core/src/modules/auth/dto/verify-otp.dto.ts` | `4โ€“14` (`VerifyOtpDto`) | | DTO (response) | `apps/api-core/src/modules/auth/dto/challenge.dto.ts` | `60โ€“80` (`VerifyOtpResponseDto`) | | Service | `apps/api-core/src/modules/auth/challenge.service.ts` | `verifyOtp()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `Challenge.codeHash`, `Challenge.attempts`, `Challenge.usedAt` | # Submit Appeal (/docs/user/appeals) `POST /api/v1/users/appeals` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **3 req / day** Submits an appeal against a moderation action. Calls into the **trust-safety** module (not the user service) to create an `Appeal` row with `status = PENDING`. The endpoint is decorated `@BypassBanCheck()` so banned / suspended users can reach it โ€” that's the whole reason it exists on the user-facing path. **`@BypassBanCheck` is a deliberate exception.** Most user-facing endpoints reject banned/suspended accounts via `BanCheckGuard`. This endpoint is whitelisted because the platform must give blocked users a way to appeal โ€” refusing that would be procedurally unfair. **Only one pending appeal at a time.** If an `Appeal` already exists with `status IN (PENDING, UNDER_REVIEW)`, the call is rejected with `400 error.trust_safety.appeal_already_pending`. Wait for moderation to resolve the open one (or hit the 3/day limit) before filing another. ## Request [#request] ### Body โ€” `SubmitAppealDto` [#body--submitappealdto] | Field | Type | Required | Validation | Notes | | ---------- | ------------- | -------- | -------------------------------------------------------------- | -------------------------------------------------------------- | | `type` | string | โœ“ | `IsIn(['ban','fraud_flag','content_removal','report_action'])` | Which moderation decision is being contested | | `reason` | string | โœ“ | `MaxLength(5000)` | Free-form explanation โ€” surface to the moderator review queue | | `banId` | string (UUID) | optional | `IsUUID()` | The `Ban` row being appealed (recommended when `type = 'ban'`) | | `evidence` | string | optional | `MaxLength(5000)` | Supporting evidence / additional context | | Header | Required | Notes | | ------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login`. The endpoint accepts the JWT even if the account is `BANNED` / `SUSPENDED`. | ## Response [#response] ### `201 Created` โ€” `ApiResponseOf` [#201-created--apiresponseofappealsubmitteddto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "PENDING", "createdAt": "2026-04-29T20:00:00.000Z" } } ``` | Field | Type | Notes | | ----------- | ----------------- | ------------------------------------------------------------------------------------------------------- | | `id` | string (UUID) | `Appeal.id` | | `status` | enum | One of `PENDING` / `REVIEWING` / `RESOLVED` / `DISMISSED`. Always `PENDING` immediately after creation. | | `createdAt` | string (ISO 8601) | Server-side timestamp | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------------------- | --------------------------------------------------------------------------------- | | `400` | (DTO validation) | Invalid `type` enum, fields exceeding 5000 chars, malformed `banId` UUID | | `400` | `error.trust_safety.appeal_already_pending` | An `Appeal` with `status IN (PENDING, UNDER_REVIEW)` already exists for this user | | `401` | (guard) | Missing / invalid bearer token | | `429` | (throttle) | Rate limit exceeded (3 req/day) | ## Side effects [#side-effects] 1. **No ban-check** โ€” `@BypassBanCheck` skips the gate that normally blocks banned/suspended users. 2. Look for existing `Appeal { userId, status IN (PENDING, UNDER_REVIEW) }`. If found โ†’ `appeal_already_pending`. 3. `prisma.appeal.create({ data: { id: randomUUID(), userId, type, reason, banId: banId ?? null, evidence: evidence ?? null } })`. 4. Surfaces in the moderation review queue โ€” operator response is out-of-band (notification when status flips). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/users/appeals \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "type": "ban", "reason": "I believe my ban was issued in error.", "banId": "b1a2n3i4-d5f6-7890-abcd-ef1234567890", "evidence": "Logs from the disputed session." }' ``` ```ts type AppealType = 'ban' | 'fraud_flag' | 'content_removal' | 'report_action'; type SubmitAppealInput = { type: AppealType; reason: string; banId?: string; evidence?: string; }; type AppealSubmitted = { id: string; status: 'PENDING' | 'REVIEWING' | 'RESOLVED' | 'DISMISSED'; createdAt: string; }; async function submitAppeal(accessToken: string, input: SubmitAppealInput): Promise { const res = await fetch('https://api.bio.re/api/v1/users/appeals', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Appeal submission failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useSubmitAppeal() { return useMutation({ mutationFn: async (input: SubmitAppealInput) => { const res = await fetch('/api/v1/users/appeals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Appeal submission failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as AppealSubmitted; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ------------------------------------------------------------------- | ------------------------------------------ | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `273โ€“282` (`submitAppeal`) | | DTO (request) | `apps/api-core/src/modules/trust-safety/dto/submit-appeal.dto.ts` | `4โ€“17` (`SubmitAppealDto`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `274โ€“283` (`AppealSubmittedDto`) | | Service | `apps/api-core/src/modules/trust-safety/trust-safety.service.ts` | `1282โ€“1303` (`submitAppeal`) | | Decorator | `apps/api-core/src/common/decorators/bypass-ban-check.decorator.ts` | `BypassBanCheck()` (skips `BanCheckGuard`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `Appeal`, `AppealStatus`, `Ban` | # Get Attribution (/docs/user/attribution) `GET /api/v1/users/attribution` โ€” ๐Ÿ”‘ **Bearer** Returns the attribution snapshot captured **at registration time**. All fields are read-only after signup โ€” there is no patch endpoint. Useful for analytics views, support tooling, and creator-attribution flows. The same fields are also embedded in `GET /users/profile`. This endpoint exists for consumers who want only the attribution slice without paying the cost of fetching the full profile (and the social-accounts join). ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofuserattributiondto] ```json { "success": true, "data": { "referredBy": "c1d2e3f4-a5b6-7890-1234-56789abcdef0", "referralCode": "REF123", "firstReferrerUrl": "https://example.com/ref", "firstLandingPage": "/landing", "acquiredViaCreatorId": "d2e3f4a5-b6c7-8901-2345-6789abcdef01", "utmSource": "google", "utmMedium": "cpc", "utmCampaign": "spring_promo", "utmTerm": "creator-tools", "utmContent": "banner-top", "registrationDevice": "mobile", "registrationCountry": "TR", "createdAt": "2025-12-01T08:30:00.000Z" } } ``` | Field | Type | Notes | | -------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------- | | `referredBy` | string (UUID) \| null | The user id of whoever referred this account | | `referralCode` | string \| null | The referral code that was used at signup (e.g. another user's `User.referralCode`) | | `firstReferrerUrl` | string \| null | The HTTP `Referer` captured on the first landing | | `firstLandingPage` | string \| null | Path of the first page hit (e.g. `/landing`, `/creators/alice`) | | `acquiredViaCreatorId` | string (UUID) \| null | If the user signed up through a creator's profile / link, the creator's id | | `utmSource` / `utmMedium` / `utmCampaign` / `utmTerm` / `utmContent` | string \| null | UTM query params captured at first landing | | `registrationDevice` | string \| null | Device classification (e.g. `mobile`, `desktop`, `tablet`) | | `registrationCountry` | string \| null | ISO country code from the IP geo-lookup at signup time | | `createdAt` | string (ISO 8601) | When the user record was created โ€” also when this snapshot was captured | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ---------------------- | ---------------------------------- | | `401` | (guard) | Missing / invalid bearer token | | `404` | `error.user.not_found` | Token decoded but user row missing | ## Side effects [#side-effects] 1. `prisma.user.findUnique({ where: { id: userId }, select: { ...all 13 attribution fields } })`. 2. Throw `not_found` if missing. 3. Return the row. No mutations โ€” attribution is **immutable** post-signup; mutating it would break analytics retention. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/users/attribution \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type UserAttribution = { referredBy: string | null; referralCode: string | null; firstReferrerUrl: string | null; firstLandingPage: string | null; acquiredViaCreatorId: string | null; utmSource: string | null; utmMedium: string | null; utmCampaign: string | null; utmTerm: string | null; utmContent: string | null; registrationDevice: string | null; registrationCountry: string | null; createdAt: string; }; async function getAttribution(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/attribution', { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Attribution fetch failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const userKeys = { attribution: () => ['users', 'attribution'] as const, }; export function useAttribution() { return useQuery({ queryKey: userKeys.attribution(), queryFn: async () => { const res = await fetch('/api/v1/users/attribution'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Attribution fetch failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as UserAttribution; }, // Attribution never changes after signup โ€” cache aggressively staleTime: Infinity, gcTime: 60 * 60_000, // 1 hour }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `318โ€“325` (`getAttribution`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `231โ€“270` (`UserAttributionDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `818โ€“839` (`getAttribution`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User.referredBy`, `User.referralCode`, `User.firstReferrerUrl`, `User.firstLandingPage`, `User.acquiredViaCreatorId`, `User.utm*`, `User.registrationDevice`, `User.registrationCountry` | # Remove Avatar (/docs/user/avatar-remove) `POST /api/v1/users/avatar/remove` โ€” ๐Ÿ”‘ **Bearer** Sets `User.avatarUrl = null` and **deletes the avatar file from object storage** in the background (fire-and-forget). Idempotent โ€” calling it on a user with no avatar succeeds with no side effect on storage. This is `POST` (not `DELETE`) because some clients / proxies strip bodies on `DELETE`. Treat it as the canonical "remove" verb for avatars. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | ------------------------------ | | `401` | (guard) | Missing / invalid bearer token | ## Side effects [#side-effects] 1. Read current `User.avatarUrl` (to remember which file to delete). 2. `prisma.user.update({ avatarUrl: null })`. 3. **Fire-and-forget**: extract object-storage key from the previous URL and delete the file. Failures are logged, not surfaced. 4. If `avatarUrl` was already `null`, no storage call is made โ€” DB write is still issued (idempotent set). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/users/avatar/remove \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function removeAvatar(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/avatar/remove', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Remove avatar failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useRemoveAvatar() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/users/avatar/remove', { method: 'POST' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Remove avatar failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['users', 'profile'] }); qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ---------------------- | ----------------------------------------------------- | ------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `81โ€“88` (`removeAvatar`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/user/user.service.ts` | `114โ€“135` (`removeAvatar`) | | Object storage cleanup | `apps/api-core/src/modules/upload/upload.service.ts` | `extractKeyFromUrl()`, `deleteFile()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User.avatarUrl` | # Set Avatar (/docs/user/avatar-set) `PATCH /api/v1/users/avatar` โ€” ๐Ÿ”‘ **Bearer** Persists a new avatar URL on `User.avatarUrl` and **deletes the previous file from object storage** in the background (fire-and-forget, no error surfaced if the old file is already gone). This endpoint does not upload โ€” it only persists the URL after upload. To upload the image bytes, use the upload module first to get a CDN URL, then send that URL here. The cleanup of the previous file is the reason to prefer this over `PATCH /users/profile { avatarUrl }`. The previous file deletion happens **after** the response is returned. If the old file is referenced elsewhere (e.g., cached in another row), reflect that before calling this endpoint. ## Request [#request] ### Body โ€” `SetAvatarDto` [#body--setavatardto] | Field | Type | Required | Validation | Notes | | ----------- | ------------ | -------- | ---------------------------- | ----------------------------- | | `avatarUrl` | string (URL) | โœ“ | `IsUrl()`, `MaxLength(2048)` | CDN URL of the uploaded image | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofavatarurlresponsedto] ```json { "success": true, "data": { "avatarUrl": "https://cdn.bio.re/avatars/user/uuid.webp" } } ``` | Field | Type | Notes | | ----------- | ------ | -------------------------------------------------------------- | | `avatarUrl` | string | Echoed back โ€” same URL you sent (server does not transform it) | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | ----------------------------------------------------- | | `400` | (DTO validation) | `avatarUrl` not a valid URL OR longer than 2048 chars | | `401` | (guard) | Missing / invalid bearer token | ## Side effects [#side-effects] 1. Read current `User.avatarUrl` (to remember which file to delete). 2. `prisma.user.update({ avatarUrl })`. 3. **Fire-and-forget**: extract object-storage key from the old URL and delete it via the upload service. Failures are logged, not surfaced to the caller (so a stale orphan never breaks the avatar update path). 4. Audit log: `[avatar] Updated for user {userId}`. ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/users/avatar \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"avatarUrl": "https://cdn.bio.re/avatars/user/abc.webp"}' ``` ```ts type AvatarUrlResponse = { avatarUrl: string }; async function setAvatar(accessToken: string, avatarUrl: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/avatar', { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ avatarUrl }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Set avatar failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useSetAvatar() { const qc = useQueryClient(); return useMutation({ mutationFn: async (avatarUrl: string) => { const res = await fetch('/api/v1/users/avatar', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ avatarUrl }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Set avatar failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as AvatarUrlResponse; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['users', 'profile'] }); qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ---------------------- | ---------------------------------------------------------------- | ------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `73โ€“79` (`setAvatar`) | | DTO (request) | `apps/api-core/src/modules/user/dto/index.ts` | `5โ€“7` (`SetAvatarDto`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `113โ€“116` (`AvatarUrlResponseDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `90โ€“112` (`setAvatar`) | | Object storage cleanup | `apps/api-core/src/modules/upload/upload.service.ts` | `extractKeyFromUrl()`, `deleteFile()` | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User.avatarUrl` | # Block a User (/docs/user/block) `POST /api/v1/users/block/:userId` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **10 req / hour** Adds the target user to the caller's `BlockList`. **Self-block is rejected** โ€” `ownerId === blockedId` throws `400 user.block.self`. **Double-block is a 409** โ€” calling twice for the same target throws `user.block.already_blocked`. The mutation is audit-logged at warn level for moderation review. Block is **directional** โ€” a blocks b is independent from b blocks a. Server-side feed/messaging filters use the union of both directions, but the underlying row is one-way. Blocked users are not notified. Don't surface "you have been blocked" UI on the blocked side โ€” that's a deliberate design choice across the platform. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | -------- | ------------- | --------------- | ----------------- | | `userId` | string (UUID) | `ParseUUIDPipe` | The user to block | No body. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ---------------------------- | ------------------------------------------------------------ | | `400` | `user.block.self` | `userId` path param equals the bearer's subject | | `400` | (validation) | `userId` not a valid UUID | | `401` | (guard) | Missing / invalid bearer token | | `409` | `user.block.already_blocked` | A `BlockList` row already exists for this owner/blocked pair | | `429` | (throttle) | Rate limit exceeded (10 req/hour) | ## Side effects [#side-effects] 1. Reject self-block (`ownerId === blockedId`) โ†’ `user.block.self`. 2. `prisma.blockList.findFirst({ where: { ownerId, blockedId } })`. If found โ†’ `already_blocked`. 3. `prisma.blockList.create({ data: { id: randomUUID(), ownerId, blockedId } })`. 4. Audit log (warn level): `[block] AUDIT: User {ownerId} blocked {blockedId}`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/users/block/f0e1d2c3-b4a5-6789-0123-456789abcdef \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function blockUser(accessToken: string, blockedUserId: string): Promise { const res = await fetch(`https://api.bio.re/api/v1/users/block/${blockedUserId}`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Block failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useBlockUser() { const qc = useQueryClient(); return useMutation({ mutationFn: async (blockedUserId: string) => { const res = await fetch(`/api/v1/users/block/${blockedUserId}`, { method: 'POST' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Block failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['users', 'blocked'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------- | ------------------------ | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `286โ€“296` (`blockUser`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/user/user.service.ts` | `767โ€“777` (`blockUser`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `BlockList` | # List Blocked Users (/docs/user/blocked) `GET /api/v1/users/blocked` โ€” ๐Ÿ”‘ **Bearer** Returns the **first 50 most recently blocked** users from the caller's `BlockList`, plus the `total` count. Pagination is **not exposed** through this endpoint โ€” the controller calls the service with the defaults (`page = 1`, `limit = 50`). For users with more than 50 blocks, the additional rows are not currently reachable via this REST endpoint; surface a "view all" affordance only when `total <= 50`. The service supports pagination internally (`page`, `limit` params, max 50/page) but the controller doesn't accept query params. If you need paginated reads in the future, the service is ready โ€” track the controller change in a follow-up. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofblockedusersresponsedto] ```json { "success": true, "data": { "items": [ { "id": "f0e1d2c3-b4a5-6789-0123-456789abcdef", "username": "alice", "displayName": "Alice Smith", "blockedAt": "2026-04-29T20:00:00.000Z" }, { "id": "e1d2c3b4-a5f6-7890-1234-56789abcdef0", "username": "bob", "displayName": null, "blockedAt": "2026-04-28T15:30:00.000Z" } ], "total": 2 } } ``` | Field | Type | Notes | | --------------------- | ----------------- | ----------------------------------------------------------------------------------------------------- | | `items` | array | Up to 50 entries, ordered by `blockedAt` DESC | | `items[].id` | string (UUID) | The blocked user's id (NOT the `BlockList` row id) โ€” pass to `DELETE /users/block/:userId` to unblock | | `items[].username` | string \| null | Blocked user's username (may be null if never set) | | `items[].displayName` | string \| null | Blocked user's display name | | `items[].blockedAt` | string (ISO 8601) | When the block row was created | | `total` | number | Total `BlockList` rows for this user (may exceed `items.length` when total > 50) | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | ------------------------------ | | `401` | (guard) | Missing / invalid bearer token | ## Side effects [#side-effects] 1. `prisma.blockList.findMany({ where: { ownerId: userId }, include: { blocked: { select: { id, username, displayName } } }, orderBy: { createdAt: 'desc' }, skip: 0, take: 50 })`. 2. `prisma.blockList.count({ where: { ownerId: userId } })` โ€” runs in parallel with `findMany`. 3. Map each `BlockList` row โ†’ `{ id: blockedId, username, displayName, blockedAt: createdAt }`. 4. Return `{ items, total }`. No mutations. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/users/blocked \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type BlockedUserItem = { id: string; username: string | null; displayName: string | null; blockedAt: string; }; type BlockedUsersResponse = { items: BlockedUserItem[]; total: number; }; async function getBlockedUsers(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/blocked', { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Blocked list fetch failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const userKeys = { blocked: () => ['users', 'blocked'] as const, }; export function useBlockedUsers() { return useQuery({ queryKey: userKeys.blocked(), queryFn: async () => { const res = await fetch('/api/v1/users/blocked'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Blocked list fetch failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as BlockedUsersResponse; }, staleTime: 30_000, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `308โ€“314` (`getBlockedUsers`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `205โ€“227` (`BlockedUsersResponseDto`, `BlockedUserItemDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `788โ€“808` (`getBlockedUsers`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `BlockList`, `User.username`, `User.displayName` | # Change Email (Request) (/docs/user/change-email) `POST /api/v1/users/change-email` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **3 req / hour** Initiates an email-change flow. Re-confirms the current password, validates the new email (format + disposable-domain + MX + GDPR tombstone + uniqueness), creates a fresh verification token, and queues a verification email to the **new** address through the active email provider (admin-managed). The account's `User.email` does **not** change here โ€” it only flips after the user clicks the link in the verification email (separate verify endpoint, in the `auth` module). The verification email is sent to the **NEW** email, not the current one. The current account email is unchanged until the link is followed. This means a stolen access token cannot quietly hijack the account by swapping email โ€” the attacker would also need access to the new mailbox. The dispatch is delegated to the **active email provider** (admin-managed via `external.email.active_provider`; failover handled server-side). Vendor identity stays in admin โ€” this endpoint guarantees delivery via whichever provider is active. The send itself is enqueued via the notification pipeline (BullMQ G2) โ€” failures to deliver SMTP-side are logged but do not fail the request. ## Request [#request] ### Body โ€” `ChangeEmailDto` [#body--changeemaildto] | Field | Type | Required | Validation | Notes | | ---------- | ------ | -------- | -------------- | --------------------------------------------------------------------- | | `newEmail` | string | โœ“ | `IsEmail()` | The desired new email โ€” server normalizes via `.toLowerCase().trim()` | | `password` | string | โœ“ | `MinLength(8)` | Current account password โ€” re-confirms via bcrypt | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofemailchangemessagedto] ```json { "success": true, "data": { "message": "Verification email sent to your new address. Please check your inbox." } } ``` | Field | Type | Notes | | --------- | ------ | --------------------------------------------------------------------------- | | `message` | string | Localizable confirmation message โ€” render under the form, no further fields | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `400` | (DTO validation) | `newEmail` not a valid email format; `password` shorter than 8 chars | | `400` | `user.change_email.password_required` | Account has no `passwordHash` (OAuth-only โ€” set a password via the change-password flow first) | | `400` | `user.change_email.password_incorrect` | Bcrypt password check failed | | `400` | `user.change_email.email_same` | New email equals current `User.email` after normalization | | `400` | `user.change_email.email_invalid` | Email validator rejected (disposable domain or MX failure) | | `401` | (guard) | Missing / invalid bearer token | | `404` | `user.change_email.not_found` | Token decoded but user row missing | | `409` | `user.change_email.email_taken` | Active user already holds this email (also caught from Prisma P2002 race) | | `409` | `user.change_email.email_previously_deleted` | Email matches the deleted-tombstone hash (`deleted_@deleted.bio.re`) โ€” refuse to reuse for compliance | | `429` | (throttle) | Rate limit exceeded (3 req/hour) | ## Side effects [#side-effects] 1. Normalize submitted email; lookup `User`; reject if no `passwordHash`. 2. **Bcrypt-compare** `password` against `User.passwordHash`. 3. Reject if `newEmail === user.email` (no-op). 4. **Email validator** โ€” checks disposable-domain blocklist + MX record presence. 5. Reject if any other user already holds this email. 6. **GDPR tombstone check** โ€” compute `sha256(newEmail)`; if a row exists with `email = "deleted_@deleted.bio.re"` AND `status = DELETED`, reject. (Prevents reusing emails of accounts that were deleted under GDPR right-to-erasure.) 7. **Inside one transaction**: * `UPDATE EmailVerification SET verified=true, usedAt=now() WHERE userId=:userId AND newEmail IS NOT NULL AND verified=false` โ€” invalidate any pending change-email tokens. * `INSERT EmailVerification { userId, token=randomUUID(), newEmail, expiresAt = now() + auth.verification_token_expiry_hours }` (default 24h). * On Prisma `P2002` (concurrent email claim), throw `email_taken`. 8. **Queue verification email** โ€” `notificationService.send({ eventKey: 'email_verification', userId, variables: { username, verifyUrl } })`. This enqueues a BullMQ job consumed by worker-service, which dispatches via the active email provider. Failure is logged, not surfaced. 9. Audit log: `[emailChange] Verification sent for user {userId} to ` (last-4 prefix masking via `maskEmail()`). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/users/change-email \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "newEmail": "new-address@example.com", "password": "current-account-password" }' ``` ```ts type ChangeEmailInput = { newEmail: string; password: string }; type ChangeEmailResponse = { message: string }; async function changeEmail(accessToken: string, input: ChangeEmailInput): Promise { const res = await fetch('https://api.bio.re/api/v1/users/change-email', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Change email failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useChangeEmail() { return useMutation({ mutationFn: async (input: ChangeEmailInput) => { const res = await fetch('/api/v1/users/change-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Change email failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as ChangeEmailResponse; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | --------------------- | ---------------------------------------------------------------- | ------------------------------------------------------ | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `125โ€“135` (`changeEmail`) | | DTO (request) | `apps/api-core/src/modules/user/dto/index.ts` | `26โ€“32` (`ChangeEmailDto`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `127โ€“130` (`EmailChangeMessageDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `271โ€“345` (`requestEmailChange`) | | Notification pipeline | `apps/api-core/src/modules/notification/notification.service.ts` | `send()` (enqueues BullMQ G2 job) | | Email provider | (admin-managed) | `external.email.active_provider` ConfigService key | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User.email`, `User.passwordHash`, `EmailVerification` | # Check Username Availability (/docs/user/check-username) `GET /api/v1/users/check-username` โ€” ๐ŸŒ **Public** ยท Rate limit: **30 req / minute** Real-time check used by the registration / settings form before submit. Returns `{ available: true }` only when the value normalizes within the configured length bounds, matches the format regex, **and** is not currently held by an active user or the reserved-name list. This is the only public endpoint in the user module โ€” needed because the registration form runs before login. The 30/min throttle is per-IP. Pair it with client-side debouncing (\~300ms) to keep within budget. A length / format failure also returns `{ available: false }` โ€” there is **no separate "invalid format" code path**. If you need to distinguish "taken" from "malformed", validate format client-side before calling. The cooldown rule applies only to `PATCH /users/username`, not here. ## Request [#request] ### Query parameters [#query-parameters] | Param | Type | Required | Notes | | ---------- | ------ | -------- | ---------------------------------------------------------------------------------------- | | `username` | string | โœ“ | The candidate username โ€” server normalizes via `.toLowerCase().trim()` before evaluation | No body, no headers required. ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofusernameavailabilitydto] ```json { "success": true, "data": { "available": true } } ``` | Field | Type | Notes | | ----------- | ------- | --------------------------------------------------------------------- | | `available` | boolean | `true` only when length + format + uniqueness + not-reserved all pass | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | -------------------------------- | | `429` | (throttle) | Rate limit exceeded (30 req/min) | The endpoint never throws on invalid input โ€” `available: false` is the universal "not usable" signal. ## Side effects [#side-effects] 1. Normalize input (`.toLowerCase().trim()`). 2. Length check against `site.username_min_length` / `site.username_max_length` (admin-managed) โ†’ if outside bounds, return `{ available: false }`. 3. Format check against `^[a-z0-9._-]+$` โ†’ if failing, return `{ available: false }`. 4. `prisma.user.findUnique({ where: { username } })` โ†’ if hit, return `{ available: false }`. 5. `prisma.reservedUsername.findUnique({ where: { username } })` โ†’ if hit, return `{ available: false }`. 6. Otherwise: return `{ available: true }`. No mutations. ## Code samples [#code-samples] ```bash curl 'https://api.bio.re/api/v1/users/check-username?username=johndoe' ``` ```ts async function checkUsername(username: string): Promise { const url = new URL('https://api.bio.re/api/v1/users/check-username'); url.searchParams.set('username', username); const res = await fetch(url); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Check failed'), { code: json?.error?.code, }); } return json.data.available; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const usernameKeys = { availability: (username: string) => ['users', 'check-username', username] as const, }; export function useUsernameAvailability(username: string) { // Caller is responsible for debouncing the input upstream. return useQuery({ queryKey: usernameKeys.availability(username), queryFn: async () => { const url = new URL('/api/v1/users/check-username', window.location.origin); url.searchParams.set('username', username); const res = await fetch(url); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Check failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data.available as boolean; }, enabled: username.length >= 3, staleTime: 10_000, // 10s โ€” registration form lifetime is short }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | ---------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `114โ€“121` (`checkUsername`, `@Public()`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `120โ€“123` (`UsernameAvailabilityDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `246โ€“259` (`checkUsernameAvailability`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User.username`, `ReservedUsername` | # Get Consent History (/docs/user/consent-history) `GET /api/v1/users/consent` โ€” ๐Ÿ”‘ **Bearer** Returns up to **100 most recent** `ConsentRecord` rows for the calling user, ordered by `createdAt DESC`. Each row exposes the document type / version, whether it was accepted or declined, and the timestamp. The IP / user-agent metadata is **not** returned to the client (kept server-side for compliance audit only). Capped at 100 rows โ€” sufficient for the typical "your consent history" UI. For full audit exports across longer time spans, use the GDPR data-export pipeline (`POST /gdpr/export`) which includes the full table. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ArrayApiResponseOf` [#200-ok--arrayapiresponseofconsenthistoryitemdto] ```json { "success": true, "data": [ { "documentType": "tos", "documentVersion": "2.1", "accepted": true, "createdAt": "2026-04-29T20:00:00.000Z" }, { "documentType": "privacy", "documentVersion": "1.4", "accepted": true, "createdAt": "2026-04-29T20:00:00.000Z" }, { "documentType": "marketing-emails", "documentVersion": "1.0", "accepted": false, "createdAt": "2026-04-29T20:00:00.000Z" } ] } ``` ### Item fields [#item-fields] | Field | Type | Notes | | ----------------- | ----------------- | ----------------------------------------------------------------------------------------- | | `documentType` | string | The key recorded by the original `POST /users/consent` (e.g. `tos`, `privacy`, `cookies`) | | `documentVersion` | string | The version recorded with the consent | | `accepted` | boolean | `true` for opt-in, `false` for explicit decline | | `createdAt` | string (ISO 8601) | Server-side timestamp of when the consent was recorded | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | ------------------------------ | | `401` | (guard) | Missing / invalid bearer token | ## Side effects [#side-effects] 1. `prisma.consentRecord.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: 100, select: { documentType, documentVersion, accepted, createdAt } })`. 2. Return the array. No mutations. 3. **`ipAddress` and `userAgent` are intentionally excluded from the select** โ€” they are stored server-side for compliance audit and are never exposed in this read. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/users/consent \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type ConsentHistoryItem = { documentType: string; documentVersion: string; accepted: boolean; createdAt: string; }; async function getConsentHistory(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/consent', { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Consent history fetch failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const userKeys = { consent: () => ['users', 'consent'] as const, }; export function useConsentHistory() { return useQuery({ queryKey: userKeys.consent(), queryFn: async () => { const res = await fetch('/api/v1/users/consent'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Consent history fetch failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as ConsentHistoryItem[]; }, staleTime: 60_000, // History changes only when user actively records new consent }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ------------------- | ---------------------------------------------------------------- | ----------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `263โ€“269` (`getConsentHistory`) | | DTO (response item) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `189โ€“201` (`ConsentHistoryItemDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `739โ€“751` (`getConsentHistory`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `ConsentRecord` | # Record Consent (/docs/user/consent-record) `POST /api/v1/users/consent` โ€” ๐Ÿ”‘ **Bearer** Records a single immutable consent decision in the `ConsentRecord` log. Captures the user's choice (`accepted: true | false`), the document type and version, plus `req.ip` and the `User-Agent` header for compliance audit. **Append-only** โ€” there is no update/delete; revising consent for the same document is a new row. Compliance-grade. The IP and user-agent are stored to prove *who, when, and from where* a consent was given. The frontend sends only the document type / version / accepted flag; the request metadata (`req.ip`, `req.headers['user-agent']`) is captured server-side. ## Request [#request] ### Body โ€” `RecordConsentDto` [#body--recordconsentdto] | Field | Type | Required | Validation | Notes | | ----------------- | ------- | -------- | ------------- | ------------------------------------------------------------------------------------------ | | `documentType` | string | โœ“ | `IsString()` | Free-form key โ€” typical values: `tos`, `privacy`, `cookies`, `marketing-emails` | | `documentVersion` | string | โœ“ | `IsString()` | Semver-ish version of the document being agreed to (e.g. `2.1`, `2026-04-29`) | | `accepted` | boolean | โœ“ | `IsBoolean()` | `true` for an opt-in, `false` for an explicit decline (declines are also logged for audit) | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `201 Created` โ€” `ApiResponseOf` [#201-created--apiresponseofconsentrecordeddto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } ``` | Field | Type | Notes | | ----- | ------------- | --------------------------------------------------------------- | | `id` | string (UUID) | `ConsentRecord.id` โ€” useful only for tracing in support tickets | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | ------------------------------ | | `400` | (DTO validation) | Missing / wrong-typed fields | | `401` | (guard) | Missing / invalid bearer token | ## Side effects [#side-effects] 1. Generate `id = randomUUID()`. 2. Insert `ConsentRecord { id, userId, documentType, documentVersion, accepted, ipAddress: req.ip, userAgent: req.headers['user-agent'] }`. **No deduplication** โ€” calling twice with the same document/version creates two rows. 3. Audit log: `[consent] {documentType} v{documentVersion} accepted/declined by user {userId}`. 4. No further side effects โ€” does **not** flip any flag on `User`. Document gating is read separately via the consent history (or `hasConsent()` server-side helper). ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/users/consent \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "documentType": "tos", "documentVersion": "2.1", "accepted": true }' ``` ```ts type RecordConsentInput = { documentType: string; documentVersion: string; accepted: boolean; }; async function recordConsent(accessToken: string, input: RecordConsentInput): Promise { const res = await fetch('https://api.bio.re/api/v1/users/consent', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Record consent failed'), { code: json?.error?.code, }); } return json.data.id as string; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useRecordConsent() { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: RecordConsentInput) => { const res = await fetch('/api/v1/users/consent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Record consent failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data.id as string; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['users', 'consent'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | -------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `248โ€“261` (`recordConsent`) | | DTO (request) | `apps/api-core/src/modules/user/dto/index.ts` | `20โ€“24` (`RecordConsentDto`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `182โ€“185` (`ConsentRecordedDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `719โ€“734` (`recordConsent`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `ConsentRecord` | # Deactivate Account (/docs/user/deactivate) `POST /api/v1/users/deactivate` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **3 req / hour** Sets `User.status = DEACTIVATED` and **revokes every active session** for this user. Reversible โ€” the user can come back via `POST /users/reactivate` (auth-session mode) or by clicking a reactivation link sent later (token-bearer mode). **All sessions are revoked, including the one calling this endpoint.** The next request with the existing access token will succeed only until the JWT expires (`auth.access_token_ttl_seconds`); the refresh cookie is dead immediately. Plan a redirect to a logged-out landing page on success. Deactivation is **distinct from deletion**. Deactivation is reversible and preserves all data. Deletion is `POST /users/delete` (or `POST /gdpr/delete`), which schedules permanent erasure after a grace period. Deactivation by itself does not start any deletion timer. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------- | ---------------------------------------------------------------------------------- | | `400` | `error.user.account_not_active` | `User.status` is not `ACTIVE` (already DEACTIVATED, SUSPENDED, BANNED, or DELETED) | | `401` | (guard) | Missing / invalid bearer token | | `404` | `error.user.not_found` | Token decoded but user row missing | | `429` | (throttle) | Rate limit exceeded (3 req/hour) | ## Side effects [#side-effects] 1. Lookup `User`; throw `not_found` if missing. 2. Reject if `User.status !== 'ACTIVE'` โ€” no idempotent re-deactivation. 3. **Inside one transaction**: * `User.status = DEACTIVATED`. * `UPDATE Session SET revoked = true, revokedAt = now() WHERE userId = :userId AND revoked = false` (every live session). 4. Audit log: `[account] AUDIT: Deactivated by user {userId}` (warn level). 5. **No** scheduled deletion is created โ€” that requires the separate `/users/delete` flow. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/users/deactivate \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function deactivateAccount(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/deactivate', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Deactivate failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useDeactivateAccount() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/users/deactivate', { method: 'POST' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Deactivate failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { // Sessions revoked server-side โ€” drop all caches, force re-auth flow qc.clear(); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------- | ---------------------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `158โ€“167` (`deactivateAccount`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/user/user.service.ts` | `402โ€“421` (`deactivateAccount`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User.status` (`UserStatus` enum), `Session.revoked` | # Request Deletion (Legacy) (/docs/user/delete-legacy) `POST /api/v1/users/delete` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **3 req / hour** Legacy alias of `POST /gdpr/delete`. **Same effect** (scheduled deletion + immediate deactivation + session revoke), but it **additionally requires the current account password** in the body and returns the leaner `{ scheduledAt }` shape. **New clients should use [`POST /gdpr/delete`](/docs/user/gdpr-delete).** This legacy endpoint differs from the modern equivalent in three ways: 1. **Body is required** โ€” `{ password }` must be sent and is bcrypt-checked against `User.passwordHash`. 2. **Response is leaner** โ€” `{ scheduledAt }` only, no request id or status field. 3. **Throttle is looser** โ€” 3/hour vs 1/day on the modern endpoint. To cancel a deletion requested through this endpoint, use [`DELETE /gdpr/delete`](/docs/user/gdpr-delete-cancel) โ€” both endpoints write to the same `GDPRRequest` table, so the cancel endpoint sees both. ## Request [#request] ### Body โ€” `RequestDeletionDto` [#body--requestdeletiondto] | Field | Type | Required | Validation | Notes | | ---------- | ------ | -------- | -------------- | ---------------------------------------------------------------------- | | `password` | string | โœ“ | `MinLength(8)` | Current account password โ€” bcrypt-compared against `User.passwordHash` | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofdeletionscheduleddto] ```json { "success": true, "data": { "scheduledAt": "2026-05-29T20:00:00.000Z" } } ``` | Field | Type | Notes | | ------------- | ----------------- | ----------------------------------------------------------------------------------------------------------- | | `scheduledAt` | string (ISO 8601) | When the worker will run the actual purge โ€” `now + gdpr.delete_grace_days` (admin-managed, default 30 days) | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `400` | (DTO validation) | `password` shorter than 8 chars | | `400` | `error.user.password_required` | Account has no `passwordHash` (OAuth-only โ€” set a password first or use the modern endpoint which doesn't require one) | | `400` | `error.user.password_incorrect` | Bcrypt password check failed | | `401` | (guard) | Missing / invalid bearer token | | `404` | `error.user.not_found` | Token decoded but user row missing | | `409` | `error.user.deletion_scheduled` | A previous DELETION request is already `PENDING` | | `429` | (throttle) | Rate limit exceeded (3 req/hour) | ## Side effects [#side-effects] 1. Lookup `User`; reject if missing or no `passwordHash`. 2. **Bcrypt-compare** `password` against `User.passwordHash`. 3. Look for an existing `GDPRRequest { type: DELETION, status: PENDING }`. If found โ†’ `deletion_scheduled`. 4. Compute `scheduledAt = now + gdpr.delete_grace_days * 86400 * 1000`. 5. **Inside one transaction** (same as `/gdpr/delete`): * Insert `GDPRRequest { id, userId, type: DELETION, status: PENDING, scheduledAt }`. * `User.status = DEACTIVATED`. * Revoke every active `Session`. 6. Audit log: `[gdpr] Deletion scheduled for user {userId} at `. ## Migration to the modern endpoint [#migration-to-the-modern-endpoint] ```diff - await fetch('/api/v1/users/delete', { - method: 'POST', - body: JSON.stringify({ password }), - }); + // The modern endpoint does not require the password โ€” re-confirm via UI flow if you want + await fetch('/api/v1/gdpr/delete', { method: 'POST' }); ``` If you need the password-confirmation UX (recommended for destructive operations), keep using this legacy endpoint **or** add an interstitial password re-prompt in your app before calling `/gdpr/delete`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/users/delete \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"password": "current-account-password"}' ``` ```ts async function requestDeletionLegacy(accessToken: string, password: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/delete', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ password }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Deletion request failed'), { code: json?.error?.code, }); } return json.data.scheduledAt as string; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useRequestDeletionLegacy() { const qc = useQueryClient(); return useMutation({ mutationFn: async (password: string) => { const res = await fetch('/api/v1/users/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Deletion request failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data.scheduledAt as string; }, onSuccess: () => { // Sessions revoked server-side โ€” drop all caches, force re-auth flow qc.clear(); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ----------------- | ---------------------------------------------------------------- | ----------------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `223โ€“233` (`requestDeletion`) | | DTO (request) | `apps/api-core/src/modules/user/dto/index.ts` | `16โ€“18` (`RequestDeletionDto`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `168โ€“171` (`DeletionScheduledDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `483โ€“526` (`requestDeletion`) | | Modern equivalent | `apps/api-core/src/modules/user/gdpr.controller.ts` | `72โ€“82` (`requestDeletion`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `GDPRRequest`, `User.status`, `Session.revoked` | # Request Export (Legacy) (/docs/user/export-legacy) `POST /api/v1/users/export` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **3 req / hour** Legacy alias of `POST /gdpr/export`. **Same purpose**, slightly different behavior โ€” kept for backward compatibility. **New clients should use [`POST /gdpr/export`](/docs/user/gdpr-export).** This legacy endpoint differs from it in two ways: 1. **Duplicate-check is looser** โ€” it only blocks when an existing request is `PENDING`. The modern endpoint blocks on `PENDING` **or** `PROCESSING`. 2. **Response is leaner** โ€” only `{ requestId }` is returned, no `status` or `createdAt`. The throttle ceiling is also higher here (3 / hour vs 3 / day on the modern endpoint), which exists for historical reasons. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofexportrequesteddto] ```json { "success": true, "data": { "requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } ``` | Field | Type | Notes | | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `requestId` | string (UUID) | `GDPRRequest.id` โ€” pass to `GET /gdpr/export/:id/status` and `GET /gdpr/export/:id/download` (the modern status / download endpoints accept ids created by either path) | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `401` | (guard) | Missing / invalid bearer token | | `409` | `error.user.export_in_progress` | A previous EXPORT request is `PENDING` (does **not** trigger on `PROCESSING` โ€” that's the modern-endpoint behavior) | | `429` | (throttle) | Rate limit exceeded (3 req/hour) | ## Side effects [#side-effects] 1. Look for an existing `GDPRRequest { type: EXPORT, status: PENDING }` for this user. If found โ†’ `export_in_progress`. 2. Insert `GDPRRequest { id: randomUUID(), userId, type: EXPORT, status: PENDING }`. 3. Audit log: `[gdpr] Export requested for user {userId}: {requestId}`. 4. Worker pickup happens the same way as the modern endpoint โ€” there is one shared worker that processes all `PENDING` `GDPRRequest EXPORT` rows regardless of which controller created them. ## Migration to the modern endpoint [#migration-to-the-modern-endpoint] ```diff - const res = await fetch('/api/v1/users/export', { method: 'POST' }); - const { requestId } = (await res.json()).data; + const res = await fetch('/api/v1/gdpr/export', { method: 'POST' }); + const { id, status, createdAt } = (await res.json()).data; ``` The status / download endpoints (`GET /gdpr/export/:id/status` and `GET /gdpr/export/:id/download`) work identically against either id โ€” there is **no need to migrate already-issued ids**. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/users/export \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function requestExportLegacy(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/export', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Export request failed'), { code: json?.error?.code, }); } return json.data.requestId; } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useRequestExportLegacy() { return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/users/export', { method: 'POST' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Export request failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data.requestId as string; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ----------------- | ---------------------------------------------------------------- | --------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `235โ€“244` (`requestExport`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `175โ€“178` (`ExportRequestedDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `531โ€“550` (`requestDataExport`) | | Modern equivalent | `apps/api-core/src/modules/user/gdpr.controller.ts` | `32โ€“42` (`requestExport`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `GDPRRequest`, `GDPRRequestType.EXPORT` | # Cancel Pending Deletion (/docs/user/gdpr-delete-cancel) `DELETE /api/v1/gdpr/delete` โ€” ๐Ÿ”‘ **Bearer** Cancels a pending deletion and reactivates the account in one transaction. Flips `GDPRRequest.status = CANCELLED` and `User.status = ACTIVE`. **Existing sessions are not restored** โ€” they were revoked when the deletion was requested and remain revoked; the user must log in fresh. The bearer token used here is typically issued **after** logging back in during the grace period โ€” when the user changes their mind, they log in again (which works fine on a `DEACTIVATED` account because login bypasses the deactivation gate explicitly for this flow), then call this endpoint to abort the deletion. Once `GDPRRequest.status` leaves `PENDING` (i.e., the worker has begun `PROCESSING` or already `COMPLETED` the purge), this endpoint is a 404 โ€” there is no rollback for an in-flight or completed purge. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `401` | (guard) | Missing / invalid bearer token | | `404` | `error.gdpr.no_pending_deletion` | No `GDPRRequest DELETION PENDING` row exists for this user (already cancelled, already processed, or never existed) | ## Side effects [#side-effects] 1. Look up the **most recent** `GDPRRequest { type: DELETION, status: PENDING }` for this user. If absent โ†’ `no_pending_deletion`. 2. **Inside one transaction**: * `GDPRRequest.status = CANCELLED` (for the matched row). * `User.status = ACTIVE` (reactivate the account). 3. Audit log: `[gdpr] Deletion cancelled by user {userId}, request `. 4. **Sessions are NOT touched** โ€” they were revoked at deletion-request time and remain so. The user must log in fresh. ## Code samples [#code-samples] ```bash curl -X DELETE https://api.bio.re/api/v1/gdpr/delete \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function cancelGdprDeletion(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/gdpr/delete', { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Cancel failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useCancelGdprDeletion() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/gdpr/delete', { method: 'DELETE' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Cancel failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { // Account is ACTIVE again โ€” invalidate identity caches qc.invalidateQueries({ queryKey: ['users', 'profile'] }); qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------- | ----------------------------------- | | Controller | `apps/api-core/src/modules/user/gdpr.controller.ts` | `84โ€“92` (`cancelDeletion`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/user/user.service.ts` | `683โ€“709` (`gdprCancelDeletion`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `GDPRRequest.status`, `User.status` | # Request Account Deletion (GDPR) (/docs/user/gdpr-delete) `POST /api/v1/gdpr/delete` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **1 req / day** Schedules the calling account for permanent deletion. Creates a `GDPRRequest DELETION PENDING` row, **deactivates the account immediately** (`User.status = DEACTIVATED`), and revokes every active session in the same transaction. The actual purge runs after a grace period (`gdpr.delete_grace_days`, admin-managed, default 30) โ€” until then, the user can cancel via `DELETE /gdpr/delete` (or by clicking the reactivation link from the email flow). **Sessions are revoked immediately**, including the one calling this endpoint. The user must re-authenticate to interact with the account again. Plan a redirect to a logged-out landing page on success. For the legacy alias `POST /users/delete`, see [Request Deletion (Legacy)](/docs/user/delete-legacy). The legacy endpoint additionally requires the current account password in the body โ€” kept for backward compatibility but the modern endpoint here is preferred. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofgdprdeletionrequestdto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "PENDING", "gracePeriodEnds": "2026-05-29T20:00:00.000Z" } } ``` | Field | Type | Notes | | ----------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string (UUID) | `GDPRRequest.id` โ€” pass to `DELETE /gdpr/delete` to cancel | | `status` | enum | Always `PENDING` immediately after creation. Will progress to `PROCESSING` โ†’ `COMPLETED` (worker-driven) or `CANCELLED` (user-driven via cancel endpoint). | | `gracePeriodEnds` | string (ISO 8601) | When the actual purge will run โ€” `now + gdpr.delete_grace_days` (default 30 days) | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------------- | ------------------------------------------------ | | `401` | (guard) | Missing / invalid bearer token | | `409` | `error.gdpr.deletion_already_pending` | A previous DELETION request is already `PENDING` | | `429` | (throttle) | Rate limit exceeded (1 req/day) | ## Side effects [#side-effects] 1. Look for an existing `GDPRRequest { type: DELETION, status: PENDING }` for this user. If found โ†’ `deletion_already_pending`. 2. Compute `gracePeriodEnds = now + gdpr.delete_grace_days * 86400 * 1000`. 3. **Inside one transaction**: * Insert `GDPRRequest { id, userId, type: DELETION, status: PENDING, scheduledAt: gracePeriodEnds }`. * `User.status = DEACTIVATED` โ€” account is hidden during the grace period. * `UPDATE Session SET revoked = true, revokedAt = now() WHERE userId = :userId AND revoked = false`. 4. Audit log: `[gdpr] Self-service deletion requested by user {userId}, grace ends `. 5. **Worker pickup** โ€” after `gracePeriodEnds`, the worker-service cron flips `status` to `PROCESSING`, performs the purge (Art. 17 erasure across all owned data), and sets `status = COMPLETED`. The cancel endpoint becomes a no-op once status leaves `PENDING`. ## Cancel flow [#cancel-flow] To stop a scheduled deletion before the grace period ends: ```bash # Cancel + reactivate the account in one call curl -X DELETE https://api.bio.re/api/v1/gdpr/delete \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` The cancel endpoint flips `GDPRRequest.status = CANCELLED` and `User.status = ACTIVE` atomically. **Sessions stay revoked** โ€” the user must log in fresh after cancelling. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/gdpr/delete \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type GdprDeletionRequest = { id: string; status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; gracePeriodEnds: string; }; async function requestGdprDeletion(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/gdpr/delete', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Deletion request failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useRequestGdprDeletion() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/gdpr/delete', { method: 'POST' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Deletion request failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as GdprDeletionRequest; }, onSuccess: () => { // Sessions revoked server-side โ€” drop all caches, force re-auth flow qc.clear(); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/user/gdpr.controller.ts` | `72โ€“82` (`requestDeletion`) | | DTO (response) | `apps/api-core/src/modules/user/dto/gdpr-response.dto.ts` | `51โ€“63` (`GdprDeletionRequestDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `638โ€“678` (`gdprRequestDeletion`) | | Worker pickup | `apps/worker-service/` | cron scans `GDPRRequest` with `type=DELETION, status=PENDING, scheduledAt <= now()` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `GDPRRequest`, `User.status`, `Session.revoked` | # Download Export (/docs/user/gdpr-export-download) `GET /api/v1/gdpr/export/:id/download` โ€” ๐Ÿ”‘ **Bearer** Returns a **time-limited signed URL** for the completed export archive. Only succeeds when the request is `COMPLETED`; intermediate states return `404 export_not_ready`. The URL is short-lived (expires at `DataExport.expiresAt`, falling back to **+24h** when no expiry is recorded). The signed URL is **single-shot in spirit**. Don't email it, don't paste it in chat โ€” anyone with the URL can download until it expires. Render into an `` and trigger the click immediately, then discard from memory. Returning a URL (rather than streaming the bytes) keeps the API server out of the data path โ€” the file is served directly by object storage. The signing model is owned by the upload service; the URL works without further auth headers until `expiresAt`. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | ----- | ------------- | --------------- | ---------------------------------------- | | `id` | string (UUID) | `ParseUUIDPipe` | The `id` returned by `POST /gdpr/export` | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofgdprexportdownloaddto] ```json { "success": true, "data": { "downloadUrl": "https://cdn.bio.re/exports/uuid/export.zip?sig=...", "expiresAt": "2026-04-30T20:04:32.000Z" } } ``` | Field | Type | Notes | | ------------- | ----------------- | ------------------------------------------------------------------------------------- | | `downloadUrl` | string | Time-limited URL to the export archive โ€” render directly into `` | | `expiresAt` | string (ISO 8601) | When the URL stops working โ€” pulled from `DataExport.expiresAt`, fallback `now + 24h` | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | -------------------------------- | ------------------------------------------------------------------------------- | | `400` | `error.gdpr.not_export` | The `GDPRRequest` exists but is type `DELETION`, not `EXPORT` | | `401` | (guard) | Missing / invalid bearer token | | `403` | `error.gdpr.not_owner` | Request exists but `userId` does not match | | `404` | `error.gdpr.request_not_found` | No `GDPRRequest` row with this id | | `404` | `error.gdpr.export_not_ready` | Status is `PENDING` / `PROCESSING` / `FAILED` / `CANCELLED` (not `COMPLETED`) | | `404` | `error.gdpr.export_file_missing` | Status is `COMPLETED` but no matching `DataExport` row with `fileUrl` was found | ## Side effects [#side-effects] 1. Lookup `GDPRRequest`; throw `request_not_found` if missing. 2. Ownership check (`request.userId === jwt.sub`) โ†’ else `not_owner`. 3. Type check (`request.type === EXPORT`) โ†’ else `not_export`. 4. Status check (`request.status === COMPLETED`) โ†’ else `export_not_ready`. 5. Find the user's most recent `DataExport` with `status = COMPLETED` AND `fileUrl != null` (`orderBy: createdAt desc`). If none โ†’ `export_file_missing`. 6. Hand the file URL to the upload service, which produces a **signed, time-limited URL** suitable for direct browser download. 7. Compute `expiresAt = DataExport.expiresAt ?? (now + 24h)`. 8. Return `{ downloadUrl, expiresAt }`. No mutations. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/gdpr/export/a1b2c3d4-e5f6-7890-abcd-ef1234567890/download \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type GdprExportDownload = { downloadUrl: string; expiresAt: string; }; async function getExportDownload(accessToken: string, id: string): Promise { const res = await fetch(`https://api.bio.re/api/v1/gdpr/export/${id}/download`, { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Download URL fetch failed'), { code: json?.error?.code, }); } return json.data; } // Trigger the download immediately so the URL doesn't linger in memory function triggerDownload(downloadUrl: string) { const a = document.createElement('a'); a.href = downloadUrl; a.download = ''; document.body.appendChild(a); a.click(); a.remove(); } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useGetExportDownload() { // Mutation โ€” not a query โ€” because it produces a side-effect (triggers download) // and we never want to cache the signed URL. return useMutation({ mutationFn: async (id: string) => { const res = await fetch(`/api/v1/gdpr/export/${id}/download`); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Download URL fetch failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as GdprExportDownload; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | ----------------------------------------------------------- | | Controller | `apps/api-core/src/modules/user/gdpr.controller.ts` | `57โ€“68` (`downloadExport`) | | DTO (response) | `apps/api-core/src/modules/user/dto/gdpr-response.dto.ts` | `41โ€“47` (`GdprExportDownloadDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `603โ€“631` (`gdprDownloadExport`) | | URL signing | `apps/api-core/src/modules/upload/upload.service.ts` | `getSignedDownloadUrl()` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `GDPRRequest`, `DataExport.fileUrl`, `DataExport.expiresAt` | # Get Export Status (/docs/user/gdpr-export-status) `GET /api/v1/gdpr/export/:id/status` โ€” ๐Ÿ”‘ **Bearer** Returns the current state of a `GDPRRequest EXPORT` row owned by the calling user. **Ownership-checked** โ€” `request.userId` must match the JWT subject; otherwise `403`. This is the canonical poll endpoint. The status progresses `PENDING` โ†’ `PROCESSING` โ†’ `COMPLETED` (or `FAILED`). Once `COMPLETED`, fetch the signed download URL from `GET /gdpr/export/:id/download`. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | ----- | ------------- | --------------- | ---------------------------------------- | | `id` | string (UUID) | `ParseUUIDPipe` | The `id` returned by `POST /gdpr/export` | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofgdprexportstatusdto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "COMPLETED", "createdAt": "2026-04-29T20:00:00.000Z", "completedAt": "2026-04-29T20:04:32.000Z" } } ``` | Field | Type | Notes | | ------------- | ------------------------- | ----------------------------------------------------------------------------------------------- | | `id` | string (UUID) | Echoes the request id | | `status` | enum | One of `PENDING` / `PROCESSING` / `COMPLETED` / `FAILED` / `CANCELLED` | | `createdAt` | string (ISO 8601) | Request creation timestamp | | `completedAt` | string (ISO 8601) \| null | Set when worker flips status to `COMPLETED` or `FAILED`. `null` while `PENDING` / `PROCESSING`. | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------ | --------------------------------------------------------------- | | `401` | (guard) | Missing / invalid bearer token | | `403` | `error.gdpr.not_owner` | Request exists but `userId` does not match the bearer's subject | | `404` | `error.gdpr.request_not_found` | No `GDPRRequest` row with this id | ## Side effects [#side-effects] 1. `prisma.gdprRequest.findUnique({ where: { id } })` selecting `id, userId, status, createdAt, completedAt`. 2. If row missing โ†’ throw `request_not_found`. 3. If `userId` mismatch โ†’ throw `not_owner`. 4. Return the slice. No mutations. ## Polling guidance [#polling-guidance] * The worker pickup is on the order of seconds; total processing depends on dataset size (typically minutes). * Recommended poll cadence: every 3โ€“5 seconds while the dialog is open. Stop polling once `status === 'COMPLETED'` or `'FAILED'`. * For large datasets, fall back to background polling (e.g., 30s) and surface a notification when done. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/gdpr/export/a1b2c3d4-e5f6-7890-abcd-ef1234567890/status \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type GdprExportStatus = { id: string; status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; createdAt: string; completedAt: string | null; }; async function getExportStatus(accessToken: string, id: string): Promise { const res = await fetch(`https://api.bio.re/api/v1/gdpr/export/${id}/status`, { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Status check failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const gdprKeys = { exportStatus: (id: string) => ['gdpr', 'export', id, 'status'] as const, }; export function useExportStatus(id: string) { return useQuery({ queryKey: gdprKeys.exportStatus(id), queryFn: async () => { const res = await fetch(`/api/v1/gdpr/export/${id}/status`); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Status check failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as GdprExportStatus; }, enabled: Boolean(id), // Poll until terminal state, then stop refetchInterval: (query) => { const data = query.state.data; if (!data) return 5000; return data.status === 'COMPLETED' || data.status === 'FAILED' || data.status === 'CANCELLED' ? false : 5000; }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | --------------------------------- | | Controller | `apps/api-core/src/modules/user/gdpr.controller.ts` | `44โ€“55` (`getExportStatus`) | | DTO (response) | `apps/api-core/src/modules/user/dto/gdpr-response.dto.ts` | `22โ€“37` (`GdprExportStatusDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `586โ€“597` (`gdprGetExportStatus`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `GDPRRequest` | # Request Data Export (GDPR) (/docs/user/gdpr-export) `POST /api/v1/gdpr/export` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **3 req / day** Creates a `GDPRRequest` row of type `EXPORT` in `PENDING` state. A worker-service cron picks the row up, builds the export archive, writes a `DataExport` row, and flips the request status to `COMPLETED` (or `FAILED`). Clients poll `GET /gdpr/export/:id/status` until completion, then call `GET /gdpr/export/:id/download` for the signed URL. This is the **modern** export endpoint. The legacy alias `POST /users/export` still exists for backward compatibility but only checks `PENDING` (not `PROCESSING`) when guarding against duplicates and returns the simpler `{ requestId }` shape. New clients should use this endpoint. Only one export can be in flight per user โ€” if a `PENDING` or `PROCESSING` `GDPRRequest EXPORT` already exists, the call is rejected with `409 export_already_pending`. Wait for the previous one to complete (or hit the 3/day limit and try again tomorrow). ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofgdprexportrequestdto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "PENDING", "createdAt": "2026-04-29T20:00:00.000Z" } } ``` | Field | Type | Notes | | ----------- | ----------------- | ------------------------------------------------------------------------------------------------------------ | | `id` | string (UUID) | `GDPRRequest.id` โ€” pass to status / download endpoints | | `status` | enum | Always `PENDING` immediately after creation. Will progress through `PROCESSING` โ†’ `COMPLETED` (or `FAILED`). | | `createdAt` | string (ISO 8601) | Server-side timestamp | ### Status lifecycle [#status-lifecycle] | Status | Set by | Meaning | | ------------ | ---------------- | ----------------------------------------------------- | | `PENDING` | This endpoint | Queued; worker hasn't started yet | | `PROCESSING` | Worker | Archive is being built | | `COMPLETED` | Worker | Archive ready โ€” `/download` will return a signed URL | | `FAILED` | Worker | Archive build failed โ€” surface the request to support | | `CANCELLED` | (admin / system) | Operator-cancelled โ€” never set by this endpoint | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ----------------------------------- | ------------------------------------------------------ | | `401` | (guard) | Missing / invalid bearer token | | `409` | `error.gdpr.export_already_pending` | A previous EXPORT request is `PENDING` or `PROCESSING` | | `429` | (throttle) | Rate limit exceeded (3 req/day) | ## Side effects [#side-effects] 1. Look for an existing `GDPRRequest` for this user with `type = EXPORT` AND `status IN (PENDING, PROCESSING)`. If found, throw `export_already_pending`. 2. Insert `GDPRRequest { id: randomUUID(), userId, type: EXPORT, status: PENDING }`. 3. Audit log: `[gdpr] Self-service export requested by user {userId}: {id}`. 4. **The worker-service cron** scans for `PENDING` exports, builds the archive, writes a `DataExport` row with `fileUrl` and `expiresAt`, and flips the `GDPRRequest.status`. This pickup is asynchronous โ€” typical completion is on the order of minutes; clients should poll `/gdpr/export/:id/status`. ## Code samples [#code-samples] ```bash curl -X POST https://api.bio.re/api/v1/gdpr/export \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type GdprStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; type GdprExportRequest = { id: string; status: GdprStatus; createdAt: string; }; async function requestGdprExport(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/gdpr/export', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Export request failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useRequestGdprExport() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { const res = await fetch('/api/v1/gdpr/export', { method: 'POST' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Export request failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as GdprExportRequest; }, onSuccess: (data) => { // Seed the status cache so the polling query starts with the freshly created row qc.setQueryData(['gdpr', 'export', data.id, 'status'], { id: data.id, status: data.status, createdAt: data.createdAt, completedAt: null, }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | --------------------------------------------------------- | -------------------------------------------------------------------------- | | Controller | `apps/api-core/src/modules/user/gdpr.controller.ts` | `32โ€“42` (`requestExport`) | | DTO (response) | `apps/api-core/src/modules/user/dto/gdpr-response.dto.ts` | `5โ€“18` (`GdprExportRequestDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `561โ€“581` (`gdprRequestExport`) | | Worker pickup | `apps/worker-service/` | cron scans `GDPRRequest` with `status = PENDING` | | Prisma models | `packages/prisma/prisma/schema.prisma` | `GDPRRequest`, `DataExport`, `GDPRRequestType.EXPORT`, `GDPRRequestStatus` | # Set User Intent (/docs/user/intent) `PATCH /api/v1/users/intent` โ€” ๐Ÿ”‘ **Bearer** Sets `User.intent` to `FAN` or `CREATOR`. **One-time only** โ€” after the value is set, all subsequent calls return `400 intent_already_set`. The atomic `updateMany({ intent: null })` guard prevents a race between two concurrent requests. After this succeeds, route the user accordingly: `FAN` โ†’ discovery feed, `CREATOR` โ†’ onboarding wizard. The flag never changes after the first set; account-type changes are out of scope for this endpoint. ## Request [#request] ### Body โ€” `SetIntentDto` [#body--setintentdto] | Field | Type | Required | Validation | Notes | | -------- | ------------------- | -------- | -------------------- | ------------------------- | | `intent` | enum (`UserIntent`) | โœ“ | `IsEnum(UserIntent)` | One of: `FAN` / `CREATOR` | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------- | ----------------------------------------------------------------------- | | `400` | (DTO validation) | `intent` not in the enum | | `400` | `error.user.intent_already_set` | `User.intent` is non-null โ€” second call after the value was already set | | `401` | (guard) | Missing / invalid bearer token | | `404` | `error.user.not_found` | Token decoded but user row missing | ## Side effects [#side-effects] 1. **Atomic check-and-set**: `prisma.user.updateMany({ where: { id, intent: null }, data: { intent } })`. If the row already had a non-null intent, `count = 0` and no write happens. 2. If `count = 0`, look up the user to distinguish "intent already set" from "user not found", then throw the matching error. 3. On success: increment `userIntentSetTotal` Prometheus counter (labelled by chosen intent). 4. Audit log: `[intent] User {userId} set intent: {intent}`. ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/users/intent \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"intent": "CREATOR"}' ``` ```ts type UserIntent = 'FAN' | 'CREATOR'; async function setIntent(accessToken: string, intent: UserIntent): Promise { const res = await fetch('https://api.bio.re/api/v1/users/intent', { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ intent }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Set intent failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useSetIntent() { const qc = useQueryClient(); return useMutation({ mutationFn: async (intent: UserIntent) => { const res = await fetch('/api/v1/users/intent', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ intent }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Set intent failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['users', 'profile'] }); qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------- | ---------------------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `92โ€“99` (`setIntent`) | | DTO (request) | `apps/api-core/src/modules/user/dto/index.ts` | `45โ€“49` (`SetIntentDto`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/user/user.service.ts` | `223โ€“241` (`setIntent`) | | Prisma enum | `packages/prisma/prisma/schema.prisma` | `enum UserIntent` (`FAN` / `CREATOR`), `User.intent` | # Update User Profile (/docs/user/profile-update) `PATCH /api/v1/users/profile` โ€” ๐Ÿ”‘ **Bearer** Patches a small slice of the user row โ€” `displayName`, `bio`, `avatarUrl`. Sparse update: only the fields you send are touched. Returns the updated row (not the full profile โ€” call `GET /users/profile` for the full read). For avatar handling, prefer the dedicated `PATCH /users/avatar` (it also cleans up the previous file from object storage). Setting `avatarUrl` here works but does **not** delete the old file from storage. ## Request [#request] ### Body โ€” `UpdateProfileDto` [#body--updateprofiledto] All fields optional. Send only what you want to change. | Field | Type | Validation | Notes | | ------------- | ------ | ---------------- | --------------------------------------------------------- | | `displayName` | string | `MaxLength(100)` | Free-form display name | | `bio` | string | `MaxLength(500)` | Free-form bio | | `avatarUrl` | string | `IsUrl()` | CDN URL โ€” prefer `PATCH /users/avatar` for orphan cleanup | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofupdatedprofiledto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "displayName": "John Doe", "bio": "Software engineer.", "avatarUrl": "https://cdn.bio.re/avatars/abc.jpg", "username": "johndoe", "email": "user@example.com", "locale": "en" } } ``` | Field | Type | Notes | | ------------- | -------------- | --------------------------------------------------- | | `id` | string (UUID) | Account id | | `displayName` | string \| null | After update | | `bio` | string \| null | After update | | `avatarUrl` | string \| null | After update | | `username` | string \| null | Echoed for convenience (unchanged by this endpoint) | | `email` | string | Echoed (unchanged) | | `locale` | string \| null | Echoed (unchanged) | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ---------------------- | ------------------------------------------------------------------------- | | `400` | (DTO validation) | `displayName > 100` chars, `bio > 500` chars, `avatarUrl` not a valid URL | | `401` | (guard) | Missing / invalid bearer token | | `404` | `error.user.not_found` | Token decoded but user row missing | ## Side effects [#side-effects] 1. Lookup `User`; if missing, throw `not_found`. 2. Build sparse `data` object from defined keys only. 3. **If no fields supplied**, return the existing user row unchanged (no DB write). 4. Otherwise: `prisma.user.update()` with the sparse data, return the fresh selected slice. 5. Audit log: `[profile] Updated for user {userId}`. ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/users/profile \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "displayName": "John Doe", "bio": "Software engineer." }' ``` ```ts type UpdateProfileInput = { displayName?: string; bio?: string; avatarUrl?: string; }; type UpdatedProfile = { id: string; displayName: string | null; bio: string | null; avatarUrl: string | null; username: string | null; email: string; locale: string | null; }; async function updateProfile(accessToken: string, input: UpdateProfileInput): Promise { const res = await fetch('https://api.bio.re/api/v1/users/profile', { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Update failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useUpdateProfile() { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: UpdateProfileInput) => { const res = await fetch('/api/v1/users/profile', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Update failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as UpdatedProfile; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['users', 'profile'] }); qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | ------------------------------------------------ | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `62โ€“69` (`updateProfile`) | | DTO (request) | `apps/api-core/src/modules/user/dto/index.ts` | `34โ€“43` (`UpdateProfileDto`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `88โ€“109` (`UpdatedProfileDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `61โ€“80` (`updateProfile`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User.displayName`, `User.bio`, `User.avatarUrl` | # Get User Profile (/docs/user/profile) `GET /api/v1/users/profile` โ€” ๐Ÿ”‘ **Bearer** Returns the full profile for the user resolved from the bearer token: identity fields, account flags (`twoFactorEnabled`, `isCreator`, `emailVerified`), linked OAuth accounts, and attribution metadata captured at registration. This is heavier than `GET /auth/me`. `/auth/me` returns the lightweight identity slice for nav-bar / session checks; `/users/profile` returns the full row with social accounts and attribution. Use `/auth/me` for frequent polling, `/users/profile` for the account / settings screens. ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofuserprofiledto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "email": "user@example.com", "username": "johndoe", "displayName": "John Doe", "avatarUrl": "https://cdn.bio.re/avatars/abc.jpg", "bio": "Software engineer and creator.", "status": "ACTIVE", "emailVerified": true, "locale": "en", "lastLoginAt": "2026-04-29T19:00:00.000Z", "twoFactorEnabled": false, "createdAt": "2025-12-01T08:30:00.000Z", "isCreator": false, "socialAccounts": [ { "platform": "INSTAGRAM", "platformUsername": "johndoe", "connectedAt": "2026-01-15T10:00:00.000Z" } ], "referralCode": "REF123", "firstReferrerUrl": "https://example.com/ref", "firstLandingPage": "/landing", "utmSource": "google", "utmMedium": "cpc", "utmCampaign": "spring_promo", "registrationDevice": "mobile", "registrationCountry": "TR" } } ``` ### Fields [#fields] | Field | Type | Notes | | ----------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------- | | `id` | string (UUID) | Account id | | `email` | string | Account email | | `username` | string \| null | `null` until first set via `PATCH /users/username` | | `displayName` | string \| null | Free-form display name | | `avatarUrl` | string \| null | CDN URL of the current avatar | | `bio` | string \| null | Free-form bio (max 500 chars) | | `status` | enum | `ACTIVE` / `SUSPENDED` / `BANNED` / `DEACTIVATED` / `DELETED` | | `emailVerified` | boolean | True after `POST /auth/verify-email` | | `locale` | string \| null | Preferred locale code (e.g. `en`, `tr`) | | `lastLoginAt` | string (ISO 8601) \| null | Most recent login timestamp | | `twoFactorEnabled` | boolean | Mirrors `User.twoFactorEnabled` | | `createdAt` | string (ISO 8601) | Account creation timestamp | | `isCreator` | boolean | `true` if a `CreatorProfile` row exists for this user | | `socialAccounts` | array | Linked OAuth accounts โ€” `{ platform, platformUsername, connectedAt }` | | `referralCode` | string \| null | This user's own referral code | | `firstReferrerUrl` / `firstLandingPage` / `utm*` / `registrationDevice` / `registrationCountry` | string \| null | Attribution snapshot captured at registration | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ---------------------- | ---------------------------------------------------- | | `401` | (guard) | Missing / invalid bearer token | | `404` | `error.user.not_found` | Token decoded but user row missing (deleted account) | ## Side effects [#side-effects] 1. Single `prisma.user.findUnique()` with selected fields + relations (`creatorProfile.id`, `socialAccounts`). 2. `isCreator` derived from whether `creatorProfile` is non-null (the row itself isn't returned). 3. No mutations โ€” pure read. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/users/profile \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type SocialAccount = { platform: string; platformUsername: string | null; connectedAt: string; }; type UserProfile = { id: string; email: string; username: string | null; displayName: string | null; avatarUrl: string | null; bio: string | null; status: 'ACTIVE' | 'SUSPENDED' | 'BANNED' | 'DEACTIVATED' | 'DELETED'; emailVerified: boolean; locale: string | null; lastLoginAt: string | null; twoFactorEnabled: boolean; createdAt: string; isCreator: boolean; socialAccounts: SocialAccount[]; referralCode: string | null; firstReferrerUrl: string | null; firstLandingPage: string | null; utmSource: string | null; utmMedium: string | null; utmCampaign: string | null; registrationDevice: string | null; registrationCountry: string | null; }; async function getProfile(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/profile', { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed to load profile'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const userKeys = { profile: () => ['users', 'profile'] as const, }; export function useProfile() { return useQuery({ queryKey: userKeys.profile(), queryFn: async () => { const res = await fetch('/api/v1/users/profile'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed to load profile'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as UserProfile; }, staleTime: 60_000, // 1 min โ€” profile changes are user-triggered }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | ------------------------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `53โ€“60` (`getProfile`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `18โ€“84` (`UserProfileDto`), `5โ€“14` (`SocialAccountDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `38โ€“59` (`getProfile`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `User`, `CreatorProfile`, `SocialAccount` | # Reactivate Account (/docs/user/reactivate) `POST /api/v1/users/reactivate` โ€” ๐Ÿ”‘ **Bearer** OR ๐ŸŒ **Token-bearer** ยท Rate limit: **10 req / hour** Restores a `DEACTIVATED` account to `ACTIVE`. **Two auth modes** โ€” the endpoint is decorated `@Public()` and resolves identity manually so the email-link landing page can call it without a session. If both a JWT and a token are presented, the **token wins**. **Mode A โ€” auth-session.** User has a valid bearer JWT (or `biore_session` cookie). Send an empty body. Reactivates the JWT subject. **Mode B โ€” token-bearer.** User clicked an email link; no JWT is available. Send the opaque token via `X-Reactivate-Token` header **or** `{ "token": "..." }` body. The header takes precedence if both are present. Validate the token with `GET /auth/reactivate/validate` first to render the right landing UI before this `POST` is fired. If a pending `GDPRRequest DELETION` exists for this user, it is **cancelled** in the same transaction โ€” clicking "Reactivate" before the grace period ends restores the account and aborts the scheduled deletion. ## Request [#request] ### Body โ€” `ReactivateTokenBearerDto` [#body--reactivatetokenbearerdto] All fields optional. Body is `{}` in auth-session mode. | Field | Type | Required | Validation | Notes | | ------- | ------ | -------- | ---------- | -------------------------------------------------------------------------------------------------- | | `token` | string | optional | โ€” | Opaque reactivation token from the email link. Ignored if `X-Reactivate-Token` header is also set. | ### Headers [#headers] | Header | Mode | Notes | | ------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------- | | `Authorization: Bearer ` | A โ€” auth-session | JWT from `POST /auth/login`. Refresh-cookie `biore_session` also accepted as a fallback. | | `X-Reactivate-Token: ` | B โ€” token-bearer | Wins over body's `token` field if both are sent. | If neither a token nor a JWT is found, the request is `401`. ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------------------ | -------------------------------------------------------------------------------------- | | `400` | `error.user.account_not_deactivated` | Account status is not `DEACTIVATED` (already ACTIVE, SUSPENDED, BANNED, or DELETED) | | `400` | (token validation) | Token malformed / expired / already used (consumed via `Challenge.usedAt` single-shot) | | `401` | `error.guard.missing_auth_header` | Both modes failed: no token AND no JWT | | `401` | `error.guard.invalid_token` | Auth-session mode: JWT failed signature verify or wrong token type | | `404` | `error.user.not_found` | Token resolved a userId but the user row is missing | | `429` | (throttle) | Rate limit exceeded (10 req/hour) | ## Side effects [#side-effects] ### Mode A โ€” auth-session [#mode-a--auth-session] 1. Class-level `JwtAuthGuard` is **bypassed** by the `@Public()` metadata; identity is resolved manually. 2. Read `Authorization: Bearer ` header; fall back to `biore_session` cookie. 3. `jwtService.verify(jwt)` โ€” accept only `type === 'access'` (or undefined). 4. Hand off to the shared reactivate path with the resolved `userId`. ### Mode B โ€” token-bearer [#mode-b--token-bearer] 1. Read token from `X-Reactivate-Token` header; if absent, read `body.token`. 2. `challengeService.consumeReactivationToken(token)` โ€” single-shot consume; returns `{ userId }`. Replays raise the underlying token-expired / already-used error. 3. Hand off to the shared reactivate path. ### Shared path (both modes) [#shared-path-both-modes] 1. Lookup `User`; throw `not_found` if missing. 2. Reject if `User.status !== 'DEACTIVATED'`. 3. **Inside one transaction**: * `UPDATE GDPRRequest SET status = CANCELLED WHERE userId = :userId AND type = DELETION AND status = PENDING` (cancels any scheduled deletion). * `User.status = ACTIVE`. 4. Audit log: `[account] AUDIT: Reactivated by user {userId}` (auth-session) or `Reactivated via token for user {userId}` (token-bearer). 5. **Existing sessions stay revoked.** The user must log in fresh after reactivation. ## Code samples [#code-samples] ```bash # Mode B โ€” token-bearer (from email link) curl -X POST https://api.bio.re/api/v1/users/reactivate \ -H 'X-Reactivate-Token: abc123-from-email-link' \ -H 'Content-Type: application/json' \ -d '{}' ``` ```bash # Mode A โ€” auth-session (logged-in user) curl -X POST https://api.bio.re/api/v1/users/reactivate \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{}' ``` ```ts type ReactivateMode = | { mode: 'session'; accessToken: string } | { mode: 'token'; token: string }; async function reactivateAccount(input: ReactivateMode): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (input.mode === 'session') { headers.Authorization = `Bearer ${input.accessToken}`; } else { headers['X-Reactivate-Token'] = input.token; } const res = await fetch('https://api.bio.re/api/v1/users/reactivate', { method: 'POST', headers, body: JSON.stringify({}), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Reactivate failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation } from '@tanstack/react-query'; export function useReactivateAccount() { return useMutation({ mutationFn: async (input: ReactivateMode) => { const headers: Record = { 'Content-Type': 'application/json' }; if (input.mode === 'token') { headers['X-Reactivate-Token'] = input.token; } // 'session' mode: rely on httpOnly biore_session cookie that the browser sends const res = await fetch('/api/v1/users/reactivate', { method: 'POST', headers, body: JSON.stringify({}), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Reactivate failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | ---------------------- | --------------------------------------------------------------- | ------------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `169โ€“221` (`reactivateAccount`, dual-mode) | | DTO (request) | `apps/api-core/src/modules/auth/dto/reactivate-validate.dto.ts` | `ReactivateTokenBearerDto` | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service (auth-session) | `apps/api-core/src/modules/user/user.service.ts` | `426โ€“445` (`reactivateAccount`) | | Service (token-bearer) | `apps/api-core/src/modules/user/user.service.ts` | `455โ€“477` (`reactivateByToken`) | | Token consume | `apps/api-core/src/modules/auth/challenge.service.ts` | `consumeReactivationToken()` | | Pre-flight | `apps/api-core/src/modules/auth/auth.controller.ts` | `407โ€“423` (`GET /auth/reactivate/validate`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User.status`, `GDPRRequest`, `Challenge` | # Update User Settings (/docs/user/settings-update) `PATCH /api/v1/users/settings` โ€” ๐Ÿ”‘ **Bearer** Sparse update โ€” only fields present in the body are touched. If the user has no `UserSettings` row yet, one is created with the submitted values (and Prisma defaults filling the rest). When `locale` changes, the same value is also written back to the legacy `User.locale` field for backward compatibility. **`UserSettings.locale` is the source of truth.** `User.locale` exists as a deprecated mirror for legacy code paths and is kept in sync by this endpoint when you patch `locale`. Frontend should always read locale from `/users/settings`, not `/users/profile`. ## Request [#request] ### Body โ€” `UpdateSettingsDto` [#body--updatesettingsdto] All fields optional. Send only what you want to change. | Field | Type | Validation | Notes | | -------------------- | ------- | --------------------------------- | ------------------------------ | | `locale` | string | `MaxLength(10)` | e.g. `en`, `tr`, `pt-BR` | | `theme` | string | `MaxLength(20)` | e.g. `dark`, `light`, `system` | | `emailNotifications` | boolean | โ€” | | | `pushNotifications` | boolean | โ€” | | | `inAppNotifications` | boolean | โ€” | | | `digestMode` | string | `IsIn(['DAILY','WEEKLY','NONE'])` | | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofusersettingsdto] Returns the **fresh** settings row after update (same shape as `GET /users/settings`): ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "userId": "f0e1d2c3-b4a5-6789-0123-456789abcdef", "locale": "tr", "theme": "dark", "emailNotifications": true, "pushNotifications": false, "inAppNotifications": true, "digestMode": "WEEKLY", "createdAt": "2026-04-29T08:30:00.000Z", "updatedAt": "2026-04-29T20:00:00.000Z" } } ``` ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | -------------------------------------------------- | | `400` | (DTO validation) | Invalid `digestMode` enum, fields exceeding length | | `401` | (guard) | Missing / invalid bearer token | ## Side effects [#side-effects] 1. Lookup existing `UserSettings` row (may be `null`). 2. Build sparse `data` object from defined keys only. 3. **If no fields supplied**, return the existing row unchanged (no DB write). 4. **Inside one transaction**: * If row exists: `prisma.userSettings.update({ where: { userId }, data })`. * If missing: `prisma.userSettings.create({ data: { id: randomUUID(), userId, ...data } })`. * **If `locale` was supplied**: also `prisma.user.update({ where: { id: userId }, data: { locale } })` (legacy mirror). 5. Audit log: `[settings] Updated for user {userId}`. 6. Return a fresh `findUnique` of the updated row โ€” guarantees the response reflects the post-write state. ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/users/settings \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "locale": "tr", "pushNotifications": false, "digestMode": "WEEKLY" }' ``` ```ts type UpdateSettingsInput = { locale?: string; theme?: string; emailNotifications?: boolean; pushNotifications?: boolean; inAppNotifications?: boolean; digestMode?: 'DAILY' | 'WEEKLY' | 'NONE'; }; async function updateSettings(accessToken: string, input: UpdateSettingsInput): Promise { const res = await fetch('https://api.bio.re/api/v1/users/settings', { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Update settings failed'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useUpdateSettings() { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: UpdateSettingsInput) => { const res = await fetch('/api/v1/users/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Update settings failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as UserSettings; }, onSuccess: (data) => { // Write-through cache: settings is the canonical source, profile mirrors locale qc.setQueryData(['users', 'settings'], data); qc.invalidateQueries({ queryKey: ['users', 'profile'] }); qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | --------------------------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `147โ€“154` (`updateSettings`) | | DTO (request) | `apps/api-core/src/modules/user/dto/index.ts` | `51โ€“69` (`UpdateSettingsDto`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `134โ€“164` (`UserSettingsDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `362โ€“392` (`updateSettings`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `UserSettings`, `User.locale` (legacy mirror) | # Get User Settings (/docs/user/settings) `GET /api/v1/users/settings` โ€” ๐Ÿ”‘ **Bearer** Returns the `UserSettings` row for the current user. **Lazy bootstrap** โ€” if the row doesn't exist yet, the server creates it with defaults on this read and returns the freshly created row, so the client never has to special-case `null`. `UserSettings` is a separate table from `User`, joined by `userId`. Defaults are applied at the Prisma level (model defaults) โ€” `emailNotifications`, `pushNotifications`, `inAppNotifications` default to `true`, `locale` / `theme` / `digestMode` default to `null` (UI falls back to product-level defaults). ## Request [#request] No body, no params. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `ApiResponseOf` [#200-ok--apiresponseofusersettingsdto] ```json { "success": true, "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "userId": "f0e1d2c3-b4a5-6789-0123-456789abcdef", "locale": "en", "theme": "dark", "emailNotifications": true, "pushNotifications": true, "inAppNotifications": true, "digestMode": "DAILY", "createdAt": "2026-04-29T08:30:00.000Z", "updatedAt": "2026-04-29T19:00:00.000Z" } } ``` | Field | Type | Notes | | -------------------- | ----------------- | --------------------------------------------------------------------------------------------- | | `id` | string (UUID) | Settings row id | | `userId` | string (UUID) | Owning user id (matches the bearer token's `sub`) | | `locale` | string \| null | Preferred locale code (e.g. `en`, `tr`) โ€” also written back to legacy `User.locale` on update | | `theme` | string \| null | UI theme tag (e.g. `dark`, `light`, `system`) | | `emailNotifications` | boolean | Defaults to `true` | | `pushNotifications` | boolean | Defaults to `true` | | `inAppNotifications` | boolean | Defaults to `true` | | `digestMode` | enum \| null | One of: `DAILY` / `WEEKLY` / `NONE` | | `createdAt` | string (ISO 8601) | Settings row creation timestamp | | `updatedAt` | string (ISO 8601) | Last update timestamp | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------ | ------------------------------ | | `401` | (guard) | Missing / invalid bearer token | ## Side effects [#side-effects] 1. `prisma.userSettings.findUnique({ where: { userId } })`. 2. **If missing** (first access): `prisma.userSettings.create({ data: { id: randomUUID(), userId } })` โ€” Prisma applies model-level defaults; this is the only write this endpoint may perform. 3. Return the row. No mutations on subsequent reads. ## Code samples [#code-samples] ```bash curl https://api.bio.re/api/v1/users/settings \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts type DigestMode = 'DAILY' | 'WEEKLY' | 'NONE'; type UserSettings = { id: string; userId: string; locale: string | null; theme: string | null; emailNotifications: boolean; pushNotifications: boolean; inAppNotifications: boolean; digestMode: DigestMode | null; createdAt: string; updatedAt: string; }; async function getSettings(accessToken: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/settings', { headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed to load settings'), { code: json?.error?.code, }); } return json.data; } ``` ```ts import { useQuery } from '@tanstack/react-query'; export const userKeys = { settings: () => ['users', 'settings'] as const, }; export function useSettings() { return useQuery({ queryKey: userKeys.settings(), queryFn: async () => { const res = await fetch('/api/v1/users/settings'); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Failed to load settings'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } return json.data as UserSettings; }, staleTime: 60_000, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ---------------------------------------------------------------- | ----------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `139โ€“145` (`getSettings`) | | DTO (response) | `apps/api-core/src/modules/user/dto/user-client-response.dto.ts` | `134โ€“164` (`UserSettingsDto`) | | Service | `apps/api-core/src/modules/user/user.service.ts` | `351โ€“360` (`getSettings`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `UserSettings` | # Unblock a User (/docs/user/unblock) `DELETE /api/v1/users/block/:userId` โ€” ๐Ÿ”‘ **Bearer** Removes the `BlockList` row for the `(ownerId, blockedId)` pair. **Not idempotent** โ€” if no row exists, returns `404 user.block.not_blocked` so the caller knows nothing changed. The opposite direction (other user blocking this user) is unaffected. The path mirrors `POST /users/block/:userId` exactly โ€” same param, same auth. Method (POST vs DELETE) is the only differentiator. ## Request [#request] ### Path parameters [#path-parameters] | Param | Type | Validation | Notes | | -------- | ------------- | --------------- | ------------------- | | `userId` | string (UUID) | `ParseUUIDPipe` | The user to unblock | No body. | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Reason | | ----- | ------------------------ | ----------------------------------------------------- | | `400` | (validation) | `userId` not a valid UUID | | `401` | (guard) | Missing / invalid bearer token | | `404` | `user.block.not_blocked` | No `BlockList` row exists for this owner/blocked pair | ## Side effects [#side-effects] 1. `prisma.blockList.findFirst({ where: { ownerId, blockedId } })`. If absent โ†’ `not_blocked`. 2. `prisma.blockList.delete({ where: { id: existing.id } })`. 3. Audit log (info level): `[block] User {ownerId} unblocked {blockedId}`. ## Code samples [#code-samples] ```bash curl -X DELETE https://api.bio.re/api/v1/users/block/f0e1d2c3-b4a5-6789-0123-456789abcdef \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```ts async function unblockUser(accessToken: string, blockedUserId: string): Promise { const res = await fetch(`https://api.bio.re/api/v1/users/block/${blockedUserId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` }, }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Unblock failed'), { code: json?.error?.code, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useUnblockUser() { const qc = useQueryClient(); return useMutation({ mutationFn: async (blockedUserId: string) => { const res = await fetch(`/api/v1/users/block/${blockedUserId}`, { method: 'DELETE' }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Unblock failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['users', 'blocked'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------- | ------------------------- | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `298โ€“306` (`unblockUser`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/user/user.service.ts` | `779โ€“786` (`unblockUser`) | | Prisma model | `packages/prisma/prisma/schema.prisma` | `BlockList` | # Change Username (/docs/user/username) `PATCH /api/v1/users/username` โ€” ๐Ÿ”‘ **Bearer** ยท Rate limit: **5 req / hour** Sets or rotates `User.username`. Format-validates against admin-configurable bounds (`site.username_min_length`, `site.username_max_length`, default 3โ€“30, lowercase alphanumeric + `._-`), enforces a per-user cooldown after each change (`username.change_cooldown_days`, default 30), checks the desired value against active users + the reserved-name list, and writes a `UsernameHistory` row for audit. **First-time set has no cooldown.** If the user has never set a username (`User.username = null`), the cooldown check is skipped โ€” they can claim a name on signup-completion flows without waiting. The `error.user.username_cooldown` error includes a `daysLeft` payload param so the UI can render `"Try again in 7 days"` without re-fetching the change history. ## Request [#request] ### Body โ€” `ChangeUsernameDto` [#body--changeusernamedto] | Field | Type | Required | Validation | Notes | | ---------- | ------ | -------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `username` | string | โœ“ | min 3, max 30, regex `^[a-z0-9._-]+$` | Lowercase only โ€” server normalizes via `.toLowerCase().trim()`. The DTO bounds match defaults; admin can widen via `site.username_min_length` / `site.username_max_length`. | | Header | Required | Notes | | ------------------------------------- | -------- | --------------------------- | | `Authorization: Bearer ` | โœ“ | JWT from `POST /auth/login` | ## Response [#response] ### `200 OK` โ€” `SuccessOnlyResponseDto` [#200-ok--successonlyresponsedto] ```json { "success": true } ``` | Field | Type | Notes | | --------- | ------- | -------------------- | | `success` | boolean | Always `true` on 200 | ### Errors [#errors] | HTTP | `code` / `i18nKey` | Payload params | Reason | | ----- | ------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------ | | `400` | `error.user.username_length` | `{ minLen, maxLen }` | Outside admin-configured length bounds | | `400` | `error.user.username_format` | โ€” | Failed regex (must be lowercase alphanumeric + `._-`) | | `400` | `error.user.username_same` | โ€” | The submitted value equals the current `User.username` (only checked for non-first-time set) | | `400` | `error.user.username_cooldown` | `{ daysLeft }` | Last change is within the cooldown window | | `401` | (guard) | โ€” | Missing / invalid bearer token | | `404` | `error.user.not_found` | โ€” | Token decoded but user row missing | | `409` | `error.user.username_taken` | โ€” | Already taken by another user OR matches a reserved name OR concurrent claim race (Prisma P2002) | | `429` | (throttle) | โ€” | Rate limit exceeded (5 req/hour) | ## Side effects [#side-effects] 1. Normalize submitted value (`.toLowerCase().trim()`). 2. Length check against `site.username_min_length` / `site.username_max_length` (admin-managed defaults 3 / 30). 3. Format check against `^[a-z0-9._-]+$`. 4. Lookup `User`; throw `not_found` if missing. 5. Determine `isFirstSet = (user.username === null)`. Cooldown checks below skip when first-set. 6. Reject if value equals current username (`username_same`). 7. **Cooldown check** โ€” read `usernameHistory` (most recent `changedAt`); if within `username.change_cooldown_days` (default 30), throw `username_cooldown` with `{ daysLeft }`. 8. Availability check โ€” `prisma.user.findUnique({ where: { username } })` AND `prisma.reservedUsername.findUnique`; throw `username_taken` if either hits. 9. **Inside one transaction**: * Insert `UsernameHistory { oldUsername, newUsername, changedAt: now() }`. * Update `User.username = normalized`. * On Prisma `P2002` (concurrent claim race), throw `username_taken`. 10. Audit log: `[username] Changed: โ†’ (user {userId})`. ## Code samples [#code-samples] ```bash curl -X PATCH https://api.bio.re/api/v1/users/username \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"username": "johndoe"}' ``` ```ts type UsernameError = { code: | 'error.user.username_length' | 'error.user.username_format' | 'error.user.username_same' | 'error.user.username_cooldown' | 'error.user.username_taken' | 'error.user.not_found'; daysLeft?: number; // present when code === 'error.user.username_cooldown' minLen?: number; // present when code === 'error.user.username_length' maxLen?: number; // present when code === 'error.user.username_length' }; async function changeUsername(accessToken: string, username: string): Promise { const res = await fetch('https://api.bio.re/api/v1/users/username', { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ username }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Change username failed'), { code: json?.error?.code, daysLeft: json?.error?.daysLeft, minLen: json?.error?.minLen, maxLen: json?.error?.maxLen, }); } } ``` ```ts import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useChangeUsername() { const qc = useQueryClient(); return useMutation({ mutationFn: async (username: string) => { const res = await fetch('/api/v1/users/username', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), }); const json = await res.json(); if (!res.ok || !json.success) { throw Object.assign(new Error(json?.error?.message ?? 'Change username failed'), { code: json?.error?.code, i18nKey: json?.error?.i18nKey, // Cooldown errors carry daysLeft for UI rendering daysLeft: json?.error?.daysLeft, }); } }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['users', 'profile'] }); qc.invalidateQueries({ queryKey: ['auth', 'me'] }); }, }); } ``` ## Try it [#try-it] ## Source [#source] | Source | Path | Lines | | -------------- | ----------------------------------------------------- | ------------------------------------------------------ | | Controller | `apps/api-core/src/modules/user/user.controller.ts` | `103โ€“112` (`changeUsername`) | | DTO (request) | `apps/api-core/src/modules/user/dto/index.ts` | `9โ€“14` (`ChangeUsernameDto`) | | DTO (response) | `apps/api-core/src/common/dto/common-response.dto.ts` | `SuccessOnlyResponseDto` | | Service | `apps/api-core/src/modules/user/user.service.ts` | `145โ€“217` (`changeUsername`) | | Prisma models | `packages/prisma/prisma/schema.prisma` | `User.username`, `UsernameHistory`, `ReservedUsername` |