diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml
new file mode 100644
index 0000000..c78f0ce
--- /dev/null
+++ b/.github/workflows/test-integration.yml
@@ -0,0 +1,81 @@
+name: Integration Tests (CouchDB)
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: [main, develop]
+ paths:
+ - 'meds/**'
+ push:
+ branches: [main, develop]
+ paths:
+ - 'meds/**'
+
+jobs:
+ integration-tests:
+ name: Run integration tests with CouchDB
+ # Optional: run only when manually dispatched or when repo variable enables it
+ if: ${{ github.event_name == 'workflow_dispatch' || vars.RUN_COUCHDB_INTEGRATION == 'true' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ permissions:
+ contents: read
+
+ services:
+ couchdb:
+ image: couchdb:3
+ ports:
+ - 5984:5984
+ env:
+ COUCHDB_USER: admin
+ COUCHDB_PASSWORD: password
+
+ env:
+ # Point integration tests at the service container
+ VITE_COUCHDB_URL: ${{ vars.VITE_COUCHDB_URL != '' && vars.VITE_COUCHDB_URL || 'http://couchdb:5984' }}
+ VITE_COUCHDB_USER: ${{ vars.VITE_COUCHDB_USER != '' && vars.VITE_COUCHDB_USER || 'admin' }}
+ VITE_COUCHDB_PASSWORD: ${{ secrets.VITE_COUCHDB_PASSWORD != '' && secrets.VITE_COUCHDB_PASSWORD || 'password' }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ working-directory: meds
+ run: bun install --frozen-lockfile
+
+ - name: Wait for CouchDB to be ready
+ run: |
+ echo "Waiting for CouchDB at ${VITE_COUCHDB_URL} ..."
+ for i in {1..60}; do
+ if curl -fsS "${VITE_COUCHDB_URL}/" >/dev/null 2>&1; then
+ echo "CouchDB is up!"
+ break
+ else
+ echo "Attempt $i: CouchDB not ready, retrying..."
+ sleep 2
+ fi
+ done
+ echo "Checking authentication and DB listing..."
+ curl -fsS -u "${VITE_COUCHDB_USER}:${VITE_COUCHDB_PASSWORD}" "${VITE_COUCHDB_URL}/_all_dbs" >/dev/null
+
+ - name: Run integration tests
+ working-directory: meds
+ env:
+ # Ensure tests target CouchDB and not the mock
+ USE_MOCK_DB: 'false'
+ run: bun run test:integration
+
+ - name: Upload Jest coverage (if present)
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: jest-coverage
+ path: |
+ meds/coverage
+ if-no-files-found: ignore
diff --git a/Makefile b/Makefile
index 1dfa691..eaf43d0 100644
--- a/Makefile
+++ b/Makefile
@@ -14,7 +14,7 @@ DOCKER_IMAGE ?= $(APP_NAME):latest
export
-.PHONY: help install clean dev build test docker-build docker-run docker-clean info
+.PHONY: help install clean dev build test docker-build docker-run docker-clean info couchdb-up couchdb-down
# Default target
.DEFAULT_GOAL := help
@@ -90,3 +90,18 @@ docker-clean: ## Clean Docker resources and containers
@docker rmi $(DOCKER_IMAGE) 2>/dev/null || true
@docker image prune -f 2>/dev/null || true
@docker container prune -f 2>/dev/null || true
+
+##@ Test Services
+
+couchdb-up: ## Start local CouchDB for integration tests
+ @echo "Starting CouchDB test service..."
+ @docker compose -f docker-compose.ci.yml up -d couchdb
+ @echo "CouchDB is starting at http://localhost:$${VITE_COUCHDB_PORT:-5984}"
+ @echo "Export credentials for tests if needed:"
+ @echo " export VITE_COUCHDB_URL=http://localhost:$${VITE_COUCHDB_PORT:-5984}"
+ @echo " export VITE_COUCHDB_USER=$${VITE_COUCHDB_USER:-admin}"
+ @echo " export VITE_COUCHDB_PASSWORD=$${VITE_COUCHDB_PASSWORD:-password}"
+
+couchdb-down: ## Stop CouchDB test service and remove volume
+ @echo "Stopping CouchDB test service and removing volume..."
+ @docker compose -f docker-compose.ci.yml down -v
diff --git a/README.md b/README.md
index bb27e58..b39b0c3 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,22 @@ VITE_GOOGLE_CLIENT_ID=your-google-client-id
VITE_GITHUB_CLIENT_ID=your-github-client-id
```
+### **OAuth Redirects & Token Persistence**
+
+- OAuth Redirect URI
+ - The redirect URI is derived from the unified configuration’s `APP_BASE_URL`:
+ - Redirect URI = `${APP_BASE_URL}/auth/callback`
+ - Defaults:
+ - Development: `APP_BASE_URL=http://localhost:5173` → `http://localhost:5173/auth/callback`
+ - Test: `APP_BASE_URL=http://localhost:3000` → `http://localhost:3000/auth/callback`
+ - Production: respects your configured base URL (e.g., `https://rxminder.com/auth/callback`)
+ - To change the redirect URI, set `APP_BASE_URL` accordingly.
+
+- Token Persistence (Email Verification & Password Reset)
+ - Production (CouchDB configured): tokens are stored server-side in CouchDB (`auth_tokens` database) for secure, multi-device flows.
+ - Development/Test or when CouchDB isn’t configured: tokens fall back to `localStorage` for demo purposes.
+ - This hybrid approach enables secure flows in production while keeping local development simple.
+
### **Database Strategy**
The application automatically selects the appropriate database strategy:
diff --git a/bun.lock b/bun.lock
index 95daede..a49ab1b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,7 +8,7 @@
"jsonwebtoken": "^9.0.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
- "uuid": "^12.0.0",
+ "uuid": "^12.0.0"
},
"devDependencies": {
"@babel/core": "^7.28.4",
@@ -40,9 +40,9 @@
"shelljs": "^0.10.0",
"ts-jest": "^29.4.1",
"typescript": "^5.9.2",
- "vite": "^7.1.4",
- },
- },
+ "vite": "^7.1.4"
+ }
+ }
},
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
@@ -2515,6 +2515,6 @@
"eclint/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
- "yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
+ "yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="]
}
}
diff --git a/config/unified.config.ts b/config/unified.config.ts
index 9ea25d5..fd00197 100644
--- a/config/unified.config.ts
+++ b/config/unified.config.ts
@@ -869,6 +869,28 @@ export function exportAsEnvVars(
COUCHDB_PASSWORD: configToUse.database.password,
COUCHDB_DATABASE_NAME: configToUse.database.name,
USE_MOCK_DB: configToUse.database.useMock.toString(),
+ // Vite-compatible database vars
+ VITE_COUCHDB_URL: configToUse.database.url,
+ VITE_COUCHDB_USER: configToUse.database.username,
+ VITE_COUCHDB_PASSWORD: configToUse.database.password,
+
+ // Authentication & Security
+ JWT_SECRET: configToUse.auth.jwtSecret,
+ JWT_EXPIRES_IN: configToUse.auth.jwtExpiresIn,
+ SESSION_SECRET: configToUse.security.sessionSecret,
+
+ // Email
+ EMAIL_PROVIDER: configToUse.email.provider,
+ VITE_MAILGUN_API_KEY: configToUse.email.mailgun?.apiKey || '',
+ VITE_MAILGUN_DOMAIN: configToUse.email.mailgun?.domain || '',
+ MAILGUN_FROM_NAME: configToUse.email.fromName,
+ MAILGUN_FROM_EMAIL: configToUse.email.fromEmail,
+
+ // OAuth
+ VITE_GOOGLE_CLIENT_ID: configToUse.oauth.google?.clientId || '',
+ GOOGLE_CLIENT_SECRET: configToUse.oauth.google?.clientSecret || '',
+ VITE_GITHUB_CLIENT_ID: configToUse.oauth.github?.clientId || '',
+ GITHUB_CLIENT_SECRET: configToUse.oauth.github?.clientSecret || '',
// Container
CONTAINER_REGISTRY: configToUse.container.registry,
diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml
new file mode 100644
index 0000000..13816e7
--- /dev/null
+++ b/docker-compose.ci.yml
@@ -0,0 +1,41 @@
+# docker-compose file for spinning up a local CouchDB instance used by integration tests
+#
+# Usage (locally):
+# docker compose -f meds/docker-compose.ci.yml up -d couchdb
+# export VITE_COUCHDB_URL=http://localhost:5984
+# export VITE_COUCHDB_USER=admin
+# export VITE_COUCHDB_PASSWORD=password
+# npm run test:integration
+#
+# Notes:
+# - This compose file is meant for CI and local testing only.
+# - Credentials default to admin/password (override via env vars).
+# - Data is persisted in a named volume "couchdb-test-data".
+
+version: '3.8'
+
+services:
+ couchdb:
+ image: couchdb:3
+ container_name: meds-couchdb-test
+ environment:
+ COUCHDB_USER: ${VITE_COUCHDB_USER:-admin}
+ COUCHDB_PASSWORD: ${VITE_COUCHDB_PASSWORD:-password}
+ # Optional: set a node name (single node)
+ # NODENAME: couchdb@127.0.0.1
+ ports:
+ - '${VITE_COUCHDB_PORT:-5984}:5984'
+ volumes:
+ - couchdb-test-data:/opt/couchdb/data
+ # Healthcheck is optional; curl may not be available in the base image.
+ # If you add curl to the image, uncomment this block to enable depends_on:service_healthy in other services.
+ # healthcheck:
+ # test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5984/_up || exit 1"]
+ # interval: 5s
+ # timeout: 3s
+ # retries: 20
+ restart: unless-stopped
+
+volumes:
+ couchdb-test-data:
+ name: couchdb-test-data
diff --git a/index.html b/index.html
index 5d1ee3e..a152aca 100644
--- a/index.html
+++ b/index.html
@@ -32,15 +32,7 @@
},
};
-
+
diff --git a/jest.config.json b/jest.config.json
index 5eb7791..5291544 100644
--- a/jest.config.json
+++ b/jest.config.json
@@ -1,5 +1,4 @@
{
- "preset": "ts-jest",
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["/tests/setup.ts"],
"testMatch": [
@@ -23,11 +22,10 @@
"coverageReporters": ["text", "lcov", "html"],
"moduleNameMapper": {
"^@/(.*)$": "/$1",
- "^uuid$": "/tests/__mocks__/uuid.js",
"^node-fetch$": "/tests/__mocks__/node-fetch.js"
},
"transform": {
- "^.+\\.tsx?$": "ts-jest",
+ "^.+\\.tsx?$": "babel-jest",
"^.+\\.jsx?$": "babel-jest"
},
"transformIgnorePatterns": ["node_modules/(?!(@jest/transform|uuid|node-fetch)/)"],
diff --git a/package.json b/package.json
index beb0189..3d73ed9 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"test:fast": "jest --testPathPatterns='(utils|types|services).*test\\.(ts|js)$' --passWithNoTests",
"test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'",
"test:services": "jest --testPathPatterns='services.*test\\.(ts|js)$'",
+ "test:integration": "jest --testPathPatterns='(tests/integration/.*\\.test\\.(ts|js)|services/.*/__tests__/integration/.*\\.test\\.(ts|js))$'",
"lint:markdown": "markdownlint-cli2 \"**/*.md\"",
"lint:markdown:fix": "markdownlint-cli2 --fix \"**/*.md\"",
"check:secrets": "secretlint \"**/*\"",
diff --git a/services/auth/__tests__/integration/token.service.integration.test.ts b/services/auth/__tests__/integration/token.service.integration.test.ts
new file mode 100644
index 0000000..eb72983
--- /dev/null
+++ b/services/auth/__tests__/integration/token.service.integration.test.ts
@@ -0,0 +1,336 @@
+import type { EmailVerificationToken } from '../../auth.types';
+
+// Integration tests for TokenService against a live CouchDB instance.
+// These tests require a local CouchDB at http://localhost:5984 with admin:password
+// or credentials provided via environment variables.
+//
+// To run only these tests:
+// jest meds/services/auth/__tests__/integration/token.service.integration.test.ts
+//
+// Skips automatically if CouchDB is not reachable.
+
+describe('TokenService (integration with CouchDB)', () => {
+ const COUCH_URL =
+ process.env.VITE_COUCHDB_URL ||
+ process.env.COUCHDB_URL ||
+ 'http://localhost:5984';
+ const COUCH_USER =
+ process.env.VITE_COUCHDB_USER || process.env.COUCHDB_USER || 'admin';
+ const COUCH_PASS =
+ process.env.VITE_COUCHDB_PASSWORD ||
+ process.env.COUCHDB_PASSWORD ||
+ 'password';
+
+ const BASIC_AUTH =
+ 'Basic ' + Buffer.from(`${COUCH_USER}:${COUCH_PASS}`).toString('base64');
+
+ let couchUp = false;
+ let previousFetch: typeof fetch | undefined;
+ let previousHeaders: typeof Headers | undefined;
+ let previousRequest: typeof Request | undefined;
+ let previousResponse: typeof Response | undefined;
+
+ // TokenService and its dependencies will be imported after configuring env and fetch.
+ let tokenService: any;
+
+ // Utility: install a custom HTTP(S) fetch to reach real CouchDB (bypass test mocks)
+ const installCustomFetch = async () => {
+ previousFetch = global.fetch;
+ previousHeaders = global.Headers as typeof Headers | undefined;
+ previousRequest = global.Request as typeof Request | undefined;
+ previousResponse = global.Response as typeof Response | undefined;
+
+ // Use Node core undici (Node 18+)
+ // Install a minimal HTTP(S)-based fetch to bypass test mocks
+ const customFetch = async (url: string, init?: RequestInit) => {
+ const { URL } = await import('node:url');
+ const http = await import('node:http');
+ const https = await import('node:https');
+ const u = new URL(url);
+ const isHttps = u.protocol === 'https:';
+ const mod = isHttps ? https : http;
+ const headers = init?.headers as Record | undefined;
+ const method = init?.method || 'GET';
+ const body = init?.body as string | undefined;
+ return await new Promise((resolve, reject) => {
+ const req = mod.request(
+ {
+ protocol: u.protocol,
+ hostname: u.hostname,
+ port: u.port || (isHttps ? 443 : 80),
+ path: u.pathname + (u.search || ''),
+ method,
+ headers,
+ },
+ res => {
+ const chunks: Buffer[] = [];
+ res.on('data', d =>
+ chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(d))
+ );
+ res.on('end', () => {
+ const buf = Buffer.concat(chunks);
+ const status = res.statusCode || 0;
+ const headersMap = new Map();
+ for (const [k, v] of Object.entries(res.headers)) {
+ if (Array.isArray(v)) headersMap.set(k, v.join(', '));
+ else if (v != null) headersMap.set(k, String(v));
+ }
+ const responseLike = {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: {
+ get: (k: string) =>
+ headersMap.get(k.toLowerCase()) ||
+ headersMap.get(k) ||
+ null,
+ has: (k: string) =>
+ headersMap.has(k.toLowerCase()) || headersMap.has(k),
+ },
+ json: async () => {
+ const txt = buf.toString('utf8');
+ return txt ? JSON.parse(txt) : {};
+ },
+ text: async () => buf.toString('utf8'),
+ } as unknown as Response;
+ resolve(responseLike);
+ });
+ }
+ );
+ req.on('error', reject);
+ if (body) req.write(body);
+ req.end();
+ });
+ };
+ // Override global fetch temporarily
+ // @ts-ignore - test environment mutation
+ global.fetch = customFetch as unknown as typeof fetch;
+ };
+
+ const restoreMockFetch = () => {
+ if (previousFetch) global.fetch = previousFetch;
+ if (previousHeaders) global.Headers = previousHeaders as typeof Headers;
+ if (previousRequest) global.Request = previousRequest as typeof Request;
+ if (previousResponse) global.Response = previousResponse as typeof Response;
+ };
+
+ const pingCouch = async (): Promise => {
+ try {
+ const res = await fetch(`${COUCH_URL}/`, {
+ headers: { Authorization: BASIC_AUTH },
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+ };
+
+ beforeAll(async () => {
+ // Use development settings and disable mock DB so TokenService uses CouchDB
+ process.env.NODE_ENV = 'development';
+ process.env.USE_MOCK_DB = 'false';
+ process.env.VITE_COUCHDB_URL = COUCH_URL;
+ process.env.VITE_COUCHDB_USER = COUCH_USER;
+ process.env.VITE_COUCHDB_PASSWORD = COUCH_PASS;
+
+ await installCustomFetch();
+
+ // Verify CouchDB is reachable
+ couchUp = await pingCouch();
+ if (!couchUp) {
+ console.warn(
+ `⚠️ CouchDB not reachable at ${COUCH_URL}. Skipping TokenService integration tests.`
+ );
+ restoreMockFetch();
+ return;
+ }
+
+ // Ensure a clean module graph and import TokenService fresh with current env
+ jest.resetModules();
+ // import after env is set so unified config picks up these values
+ const mod = await import('../../token.service');
+ tokenService = mod.tokenService;
+
+ // Trigger DB provisioning just in case
+ // Save a no-op token then delete it; this ensures database exists.
+ const bootstrapToken = `bootstrap-${Date.now()}`;
+ await tokenService.savePasswordResetToken({
+ userId: 'bootstrap',
+ email: 'bootstrap@example.com',
+ token: bootstrapToken,
+ expiresAt: new Date(Date.now() + 60_000),
+ });
+ await tokenService.deletePasswordResetToken(bootstrapToken);
+ }, 30000);
+
+ afterAll(async () => {
+ if (couchUp) {
+ try {
+ // Best-effort cleanup of expired tokens
+ await tokenService.cleanupExpiredTokens();
+ } catch {
+ // ignore
+ }
+ }
+ // Restore original mocked fetch for the rest of the test suite
+ restoreMockFetch();
+ });
+
+ const itIf = (cond: boolean) => (cond ? it : it.skip);
+
+ itIf(couchUp)(
+ 'saves and retrieves a verification token',
+ async () => {
+ const tokenValue = `ver-${Date.now()}-${Math.random()
+ .toString(16)
+ .slice(2)}`;
+ const userId = `u-${Date.now()}`;
+ const email = `user-${Date.now()}@example.com`;
+ const expiresAt = new Date(Date.now() + 5 * 60_000);
+
+ const token: EmailVerificationToken = {
+ userId,
+ email,
+ token: tokenValue,
+ expiresAt,
+ };
+
+ await tokenService.saveVerificationToken(token);
+
+ const fetched = await tokenService.findVerificationToken(tokenValue);
+ expect(fetched).toBeTruthy();
+ expect(fetched.userId).toBe(userId);
+ expect(fetched.email).toBe(email);
+ expect(new Date(fetched.expiresAt).getTime()).toBe(expiresAt.getTime());
+
+ // Cleanup
+ await tokenService.deleteVerificationTokensForUser(userId);
+ const after = await tokenService.findVerificationToken(tokenValue);
+ expect(after).toBeNull();
+ },
+ 30000
+ );
+
+ itIf(couchUp)(
+ 'deletes only verification tokens for the specified user',
+ async () => {
+ const tokenA = `verA-${Date.now()}-${Math.random()
+ .toString(16)
+ .slice(2)}`;
+ const tokenB = `verB-${Date.now()}-${Math.random()
+ .toString(16)
+ .slice(2)}`;
+ const userA = `userA-${Date.now()}`;
+ const userB = `userB-${Date.now()}`;
+ const exp = new Date(Date.now() + 10 * 60_000);
+
+ await tokenService.saveVerificationToken({
+ userId: userA,
+ email: 'a@example.com',
+ token: tokenA,
+ expiresAt: exp,
+ });
+ await tokenService.saveVerificationToken({
+ userId: userB,
+ email: 'b@example.com',
+ token: tokenB,
+ expiresAt: exp,
+ });
+
+ // Delete tokens for userA
+ await tokenService.deleteVerificationTokensForUser(userA);
+
+ // Verify A is deleted
+ const fa = await tokenService.findVerificationToken(tokenA);
+ expect(fa).toBeNull();
+
+ // Verify B still exists
+ const fb = await tokenService.findVerificationToken(tokenB);
+ expect(fb).toBeTruthy();
+ expect(fb.userId).toBe(userB);
+
+ // Cleanup B
+ await tokenService.deleteVerificationTokensForUser(userB);
+ const fbAfter = await tokenService.findVerificationToken(tokenB);
+ expect(fbAfter).toBeNull();
+ },
+ 30000
+ );
+
+ itIf(couchUp)(
+ 'password reset token lifecycle (save, find, delete)',
+ async () => {
+ const tokenValue = `rst-${Date.now()}-${Math.random()
+ .toString(16)
+ .slice(2)}`;
+ const userId = `u-${Date.now()}`;
+ const email = `reset-${Date.now()}@example.com`;
+ const exp = new Date(Date.now() + 5 * 60_000);
+
+ await tokenService.savePasswordResetToken({
+ userId,
+ email,
+ token: tokenValue,
+ expiresAt: exp,
+ });
+
+ const fetched = await tokenService.findPasswordResetToken(tokenValue);
+ expect(fetched).toBeTruthy();
+ expect(fetched.userId).toBe(userId);
+ expect(fetched.email).toBe(email);
+
+ await tokenService.deletePasswordResetToken(tokenValue);
+
+ const after = await tokenService.findPasswordResetToken(tokenValue);
+ expect(after).toBeNull();
+ },
+ 30000
+ );
+
+ itIf(couchUp)(
+ 'cleanup removes expired tokens',
+ async () => {
+ const expiredToken = `expired-${Date.now()}-${Math.random()
+ .toString(16)
+ .slice(2)}`;
+ const userId = `u-exp-${Date.now()}`;
+ const email = `expired-${Date.now()}@example.com`;
+ const past = new Date(Date.now() - 60_000);
+
+ // Save one expired verification token
+ await tokenService.saveVerificationToken({
+ userId,
+ email,
+ token: expiredToken,
+ expiresAt: past,
+ });
+
+ // And one valid token to ensure only expired is removed
+ const validToken = `valid-${Date.now()}-${Math.random()
+ .toString(16)
+ .slice(2)}`;
+ const future = new Date(Date.now() + 60_000);
+ await tokenService.saveVerificationToken({
+ userId,
+ email,
+ token: validToken,
+ expiresAt: future,
+ });
+
+ const removed = await tokenService.cleanupExpiredTokens();
+ expect(removed).toBeGreaterThanOrEqual(1);
+
+ const expiredFetched =
+ await tokenService.findVerificationToken(expiredToken);
+ expect(expiredFetched).toBeNull();
+
+ const validFetched = await tokenService.findVerificationToken(validToken);
+ expect(validFetched).toBeTruthy();
+
+ // Cleanup valid
+ await tokenService.deleteVerificationTokensForUser(userId);
+ const validAfter = await tokenService.findVerificationToken(validToken);
+ expect(validAfter).toBeNull();
+ },
+ 30000
+ );
+});
diff --git a/services/auth/auth.service.ts b/services/auth/auth.service.ts
index c062256..b7549e6 100644
--- a/services/auth/auth.service.ts
+++ b/services/auth/auth.service.ts
@@ -3,6 +3,7 @@ import { AuthenticatedUser } from './auth.types';
import { EmailVerificationService } from './emailVerification.service';
import { databaseService } from '../database';
import { logger } from '../logging';
+import { tokenService } from './token.service';
const emailVerificationService = new EmailVerificationService();
@@ -203,17 +204,13 @@ const authService = {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour expiry
- // Store reset token (in production, save to database)
- const resetTokens = JSON.parse(
- localStorage.getItem('password_reset_tokens') || '[]'
- );
- resetTokens.push({
+ // Persist reset token
+ await tokenService.savePasswordResetToken({
userId: user._id,
- email: user.email,
+ email: user.email!,
token: resetToken,
expiresAt,
});
- localStorage.setItem('password_reset_tokens', JSON.stringify(resetTokens));
// Send reset email
const emailSent = await emailVerificationService.sendPasswordResetEmail(
@@ -229,14 +226,8 @@ const authService = {
},
async resetPassword(token: string, newPassword: string) {
- // Get reset tokens
- const resetTokens = JSON.parse(
- localStorage.getItem('password_reset_tokens') || '[]'
- );
- const resetToken = resetTokens.find(
- (t: { token: string; userId: string; email: string; expiresAt: Date }) =>
- t.token === token
- );
+ // Load reset token
+ const resetToken = await tokenService.findPasswordResetToken(token);
if (!resetToken) {
throw new Error('Invalid or expired reset token');
@@ -265,14 +256,7 @@ const authService = {
});
// Remove used token
- const filteredTokens = resetTokens.filter(
- (t: { token: string; userId: string; email: string; expiresAt: Date }) =>
- t.token !== token
- );
- localStorage.setItem(
- 'password_reset_tokens',
- JSON.stringify(filteredTokens)
- );
+ await tokenService.deletePasswordResetToken(token);
return {
user: updatedUser,
diff --git a/services/auth/emailVerification.service.ts b/services/auth/emailVerification.service.ts
index 548be16..a526e3b 100644
--- a/services/auth/emailVerification.service.ts
+++ b/services/auth/emailVerification.service.ts
@@ -3,6 +3,7 @@ import { EmailVerificationToken, AuthenticatedUser } from './auth.types';
import { mailgunService } from '../mailgun.service';
import { AccountStatus } from './auth.constants';
import { databaseService } from '../database';
+import { tokenService } from './token.service';
const TOKEN_EXPIRY_HOURS = 24;
@@ -21,12 +22,8 @@ export class EmailVerificationService {
expiresAt,
};
- // Store token in localStorage for demo (in production, save to database)
- const tokens = JSON.parse(
- localStorage.getItem('verification_tokens') || '[]'
- );
- tokens.push(verificationToken);
- localStorage.setItem('verification_tokens', JSON.stringify(tokens));
+ // Persist verification token via TokenService
+ await tokenService.saveVerificationToken(verificationToken);
// Send verification email via Mailgun
if (user.email) {
@@ -45,13 +42,7 @@ export class EmailVerificationService {
async validateVerificationToken(
token: string
): Promise {
- // Get tokens from localStorage
- const tokens = JSON.parse(
- localStorage.getItem('verification_tokens') || '[]'
- );
- const verificationToken = tokens.find(
- (t: EmailVerificationToken) => t.token === token
- );
+ const verificationToken = await tokenService.findVerificationToken(token);
if (!verificationToken) {
return null;
@@ -78,14 +69,8 @@ export class EmailVerificationService {
await databaseService.updateUser(updatedUser);
- // Remove used token
- const tokens = JSON.parse(
- localStorage.getItem('verification_tokens') || '[]'
- );
- const filteredTokens = tokens.filter(
- (t: EmailVerificationToken) => t.userId !== user._id
- );
- localStorage.setItem('verification_tokens', JSON.stringify(filteredTokens));
+ // Remove used token(s) for this user
+ await tokenService.deleteVerificationTokensForUser(user._id);
}
async sendPasswordResetEmail(email: string, token: string): Promise {
diff --git a/services/auth/token.service.ts b/services/auth/token.service.ts
new file mode 100644
index 0000000..a4907a7
--- /dev/null
+++ b/services/auth/token.service.ts
@@ -0,0 +1,447 @@
+/* meds/services/auth/token.service.ts
+ *
+ * TokenService - unified persistence for email verification and password reset tokens.
+ * - In production (CouchDB configured), tokens are stored in a CouchDB database.
+ * - Otherwise, tokens are stored in localStorage for demo/dev/testing.
+ */
+
+import { EmailVerificationToken } from './auth.types';
+import type { CouchDBDocument } from '../../types';
+import { getDatabaseConfig } from '../../config/unified.config';
+import { logger } from '../logging';
+
+export interface PasswordResetToken {
+ userId: string;
+ email: string;
+ token: string;
+ expiresAt: Date;
+}
+
+type TokenType = 'verification' | 'reset';
+
+interface TokenDoc extends CouchDBDocument {
+ tokenType: TokenType;
+ token: string;
+ userId: string;
+ email?: string;
+ expiresAt: string; // ISO string
+ createdAt: string; // ISO string
+}
+
+const DB_NAME = 'auth_tokens';
+
+// Storage keys for localStorage fallback (kept compatible with existing code)
+const LS_VERIFICATION_KEY = 'verification_tokens';
+const LS_RESET_KEY = 'password_reset_tokens';
+
+function toISO(date: Date | string): string {
+ return (date instanceof Date ? date : new Date(date)).toISOString();
+}
+
+function fromISO(date: string | Date): Date {
+ return date instanceof Date ? date : new Date(date);
+}
+
+function base64Auth(user: string, pass: string): string {
+ // btoa may not exist in some environments (e.g., Node). Fallback to Buffer.
+ if (typeof btoa !== 'undefined') {
+ return btoa(`${user}:${pass}`);
+ }
+
+ return Buffer.from(`${user}:${pass}`).toString('base64');
+}
+
+export class TokenService {
+ private couchBaseUrl: string | null = null;
+ private couchAuthHeader: string | null = null;
+ private initialized = false;
+ private usingCouch = false;
+
+ constructor() {
+ this.configure();
+ }
+
+ private configure() {
+ const db = getDatabaseConfig();
+ // Use CouchDB only when a URL is provided and not explicitly mocked
+ this.usingCouch = !!db.url && db.url !== 'mock' && !db.useMock;
+ if (this.usingCouch) {
+ this.couchBaseUrl = db.url.replace(/\/+$/, '');
+ this.couchAuthHeader = `Basic ${base64Auth(db.username, db.password)}`;
+ // Best-effort DB provisioning; do not block or throw
+ this.ensureDatabase().catch(err => {
+ logger.db.error('Failed to ensure auth token database', err as Error);
+ // Fallback to localStorage if DB provisioning fails
+ this.usingCouch = false;
+ });
+ }
+ }
+
+ private async ensureDatabase(): Promise {
+ if (this.initialized || !this.usingCouch || !this.couchBaseUrl) return;
+
+ const head = await fetch(`${this.couchBaseUrl}/${DB_NAME}`, {
+ method: 'HEAD',
+ headers: {
+ Authorization: this.couchAuthHeader!,
+ },
+ });
+
+ if (head.status === 404) {
+ const create = await fetch(`${this.couchBaseUrl}/${DB_NAME}`, {
+ method: 'PUT',
+ headers: {
+ Authorization: this.couchAuthHeader!,
+ 'Content-Type': 'application/json',
+ },
+ });
+ if (!create.ok) {
+ throw new Error(
+ `Failed to create ${DB_NAME} database: HTTP ${create.status}`
+ );
+ }
+ } else if (!head.ok && head.status !== 200) {
+ throw new Error(
+ `Failed to check database ${DB_NAME}: HTTP ${head.status}`
+ );
+ }
+
+ this.initialized = true;
+ }
+
+ private async makeCouchRequest(
+ method: string,
+ path: string,
+ body?: unknown
+ ): Promise {
+ if (!this.couchBaseUrl || !this.couchAuthHeader) {
+ throw new Error('CouchDB not configured');
+ }
+
+ const url = `${this.couchBaseUrl}${path}`;
+ const headers: Record = {
+ Authorization: this.couchAuthHeader,
+ 'Content-Type': 'application/json',
+ };
+ const init: RequestInit = { method, headers };
+ if (body !== undefined) init.body = JSON.stringify(body);
+
+ const res = await fetch(url, init);
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`HTTP ${res.status}: ${text}`);
+ }
+ // Some Couch endpoints (DELETE) may return JSON or text; attempt JSON first
+ const contentType = res.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) {
+ return (await res.json()) as T;
+ }
+ return (await res.text()) as unknown as T;
+ }
+
+ // -------- LocalStorage helpers --------
+
+ private getLocalArray(key: string): T[] {
+ if (typeof localStorage === 'undefined') return [];
+ try {
+ return JSON.parse(localStorage.getItem(key) || '[]') as T[];
+ } catch {
+ return [];
+ }
+ }
+
+ private setLocalArray(key: string, items: T[]): void {
+ if (typeof localStorage === 'undefined') return;
+ localStorage.setItem(key, JSON.stringify(items));
+ }
+
+ // -------- Public API: Verification Tokens --------
+
+ async saveVerificationToken(token: EmailVerificationToken): Promise {
+ if (this.usingCouch) {
+ await this.ensureDatabase();
+ const docId = `ver-${token.token}`;
+ let existing: TokenDoc | null = null;
+ try {
+ existing = await this.makeCouchRequest(
+ 'GET',
+ `/${DB_NAME}/${docId}`
+ );
+ } catch (_err) {
+ existing = null;
+ }
+
+ const doc: TokenDoc = {
+ _id: docId,
+ _rev: existing?._rev || '',
+ tokenType: 'verification',
+ token: token.token,
+ userId: token.userId,
+ email: token.email,
+ expiresAt: toISO(token.expiresAt),
+ createdAt: existing?.createdAt || new Date().toISOString(),
+ };
+
+ const res = await this.makeCouchRequest<{ id: string; rev: string }>(
+ 'PUT',
+ `/${DB_NAME}/${docId}`,
+ doc
+ );
+ doc._rev = res.rev;
+ return;
+ }
+
+ // localStorage fallback
+ const list =
+ this.getLocalArray(LS_VERIFICATION_KEY);
+ // Replace any existing token with same token value
+ const filtered = list.filter(t => t.token !== token.token);
+ filtered.push(token);
+ this.setLocalArray(LS_VERIFICATION_KEY, filtered);
+ }
+
+ async findVerificationToken(
+ token: string
+ ): Promise {
+ if (this.usingCouch) {
+ await this.ensureDatabase();
+ const docId = `ver-${token}`;
+ try {
+ const doc = await this.makeCouchRequest(
+ 'GET',
+ `/${DB_NAME}/${docId}`
+ );
+ return {
+ userId: doc.userId,
+ email: doc.email || '',
+ token: doc.token,
+ expiresAt: fromISO(doc.expiresAt),
+ };
+ } catch (_err) {
+ return null;
+ }
+ }
+
+ const list =
+ this.getLocalArray(LS_VERIFICATION_KEY);
+ const found = list.find(t => t.token === token);
+ return found
+ ? {
+ ...found,
+ expiresAt: fromISO(found.expiresAt),
+ }
+ : null;
+ }
+
+ async deleteVerificationTokensForUser(userId: string): Promise {
+ if (this.usingCouch) {
+ await this.ensureDatabase();
+ // Use Mango query to find all verification tokens for user
+ const result = await this.makeCouchRequest<{
+ docs: TokenDoc[];
+ warning?: string;
+ }>('POST', `/${DB_NAME}/_find`, {
+ selector: { userId, tokenType: 'verification' },
+ });
+
+ for (const doc of result.docs) {
+ try {
+ await this.makeCouchRequest(
+ 'DELETE',
+ `/${DB_NAME}/${doc._id}?rev=${doc._rev}`
+ );
+ } catch (err) {
+ logger.db.error(
+ 'Failed to delete verification token doc',
+ err as Error
+ );
+ }
+ }
+ return;
+ }
+
+ const list =
+ this.getLocalArray(LS_VERIFICATION_KEY);
+ const filtered = list.filter(t => t.userId !== userId);
+ this.setLocalArray(LS_VERIFICATION_KEY, filtered);
+ }
+
+ // -------- Public API: Password Reset Tokens --------
+
+ async savePasswordResetToken(token: PasswordResetToken): Promise {
+ if (this.usingCouch) {
+ await this.ensureDatabase();
+ const docId = `rst-${token.token}`;
+ let existing: TokenDoc | null = null;
+ try {
+ existing = await this.makeCouchRequest(
+ 'GET',
+ `/${DB_NAME}/${docId}`
+ );
+ } catch (_err) {
+ existing = null;
+ }
+
+ const doc: TokenDoc = {
+ _id: docId,
+ _rev: existing?._rev || '',
+ tokenType: 'reset',
+ token: token.token,
+ userId: token.userId,
+ email: token.email,
+ expiresAt: toISO(token.expiresAt),
+ createdAt: existing?.createdAt || new Date().toISOString(),
+ };
+
+ const res = await this.makeCouchRequest<{ id: string; rev: string }>(
+ 'PUT',
+ `/${DB_NAME}/${docId}`,
+ doc
+ );
+ doc._rev = res.rev;
+ return;
+ }
+
+ const list = this.getLocalArray(LS_RESET_KEY);
+ const filtered = list.filter(t => t.token !== token.token);
+ filtered.push(token);
+ this.setLocalArray(LS_RESET_KEY, filtered);
+ }
+
+ async findPasswordResetToken(
+ token: string
+ ): Promise {
+ if (this.usingCouch) {
+ await this.ensureDatabase();
+ const docId = `rst-${token}`;
+ try {
+ const doc = await this.makeCouchRequest(
+ 'GET',
+ `/${DB_NAME}/${docId}`
+ );
+ return {
+ userId: doc.userId,
+ email: doc.email || '',
+ token: doc.token,
+ expiresAt: fromISO(doc.expiresAt),
+ };
+ } catch (_err) {
+ return null;
+ }
+ }
+
+ const list = this.getLocalArray(LS_RESET_KEY);
+ const found = list.find(t => t.token === token);
+ return found
+ ? {
+ ...found,
+ expiresAt: fromISO(found.expiresAt),
+ }
+ : null;
+ }
+
+ async deletePasswordResetToken(token: string): Promise {
+ if (this.usingCouch) {
+ await this.ensureDatabase();
+ const docId = `rst-${token}`;
+ // Need current _rev to delete
+ try {
+ const doc = await this.makeCouchRequest(
+ 'GET',
+ `/${DB_NAME}/${docId}`
+ );
+ await this.makeCouchRequest(
+ 'DELETE',
+ `/${DB_NAME}/${docId}?rev=${doc._rev}`
+ );
+ } catch (err) {
+ // Token may already be gone; log at debug level if available
+ logger.db.warn?.(
+ 'Password reset token not found to delete',
+ err as Error
+ );
+ }
+ return;
+ }
+
+ const list = this.getLocalArray(LS_RESET_KEY);
+ const filtered = list.filter(t => t.token !== token);
+ this.setLocalArray(LS_RESET_KEY, filtered);
+ }
+
+ async deletePasswordResetTokensForUser(userId: string): Promise {
+ if (this.usingCouch) {
+ await this.ensureDatabase();
+ const result = await this.makeCouchRequest<{
+ docs: TokenDoc[];
+ }>('POST', `/${DB_NAME}/_find`, {
+ selector: { userId, tokenType: 'reset' },
+ });
+
+ for (const doc of result.docs) {
+ try {
+ await this.makeCouchRequest(
+ 'DELETE',
+ `/${DB_NAME}/${doc._id}?rev=${doc._rev}`
+ );
+ } catch (err) {
+ logger.db.error(
+ 'Failed to delete password reset token doc',
+ err as Error
+ );
+ }
+ }
+ return;
+ }
+
+ const list = this.getLocalArray(LS_RESET_KEY);
+ const filtered = list.filter(t => t.userId !== userId);
+ this.setLocalArray(LS_RESET_KEY, filtered);
+ }
+
+ // Optional maintenance utility: remove expired tokens
+ async cleanupExpiredTokens(): Promise {
+ let removed = 0;
+ const now = new Date();
+
+ if (this.usingCouch) {
+ await this.ensureDatabase();
+ // Fetch all tokens and filter by expiry (for small scale). For large scale, create an index.
+ const result = await this.makeCouchRequest<{
+ rows: Array<{ id: string; value: unknown; doc: TokenDoc }>;
+ }>('GET', `/${DB_NAME}/_all_docs?include_docs=true`);
+
+ for (const row of result.rows) {
+ const doc = row.doc;
+ if (doc && new Date(doc.expiresAt) < now) {
+ try {
+ await this.makeCouchRequest(
+ 'DELETE',
+ `/${DB_NAME}/${doc._id}?rev=${doc._rev}`
+ );
+ removed++;
+ } catch (err) {
+ logger.db.error('Failed to delete expired token', err as Error);
+ }
+ }
+ }
+ return removed;
+ }
+
+ // localStorage cleanup
+ const ver = this.getLocalArray(LS_VERIFICATION_KEY);
+ const verKeep = ver.filter(t => fromISO(t.expiresAt) >= now);
+ removed += ver.length - verKeep.length;
+ this.setLocalArray(LS_VERIFICATION_KEY, verKeep);
+
+ const rst = this.getLocalArray(LS_RESET_KEY);
+ const rstKeep = rst.filter(t => fromISO(t.expiresAt) >= now);
+ removed += rst.length - rstKeep.length;
+ this.setLocalArray(LS_RESET_KEY, rstKeep);
+
+ return removed;
+ }
+}
+
+export const tokenService = new TokenService();
+export default tokenService;
diff --git a/services/database/ProductionDatabaseStrategy.ts b/services/database/ProductionDatabaseStrategy.ts
index c3f0904..2758736 100644
--- a/services/database/ProductionDatabaseStrategy.ts
+++ b/services/database/ProductionDatabaseStrategy.ts
@@ -28,6 +28,11 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
url: dbConfig.url,
username: dbConfig.username,
});
+
+ // Provision required databases on startup (non-blocking)
+ this.initializeDatabases().catch(error => {
+ logger.db.error('Failed to initialize databases', error as Error);
+ });
}
private async initializeDatabases(): Promise {
diff --git a/services/oauth.ts b/services/oauth.ts
index 6912817..307dac2 100644
--- a/services/oauth.ts
+++ b/services/oauth.ts
@@ -4,9 +4,12 @@ import {
navigationService,
} from './navigation/navigation.interface';
+import { getAppConfig, getOAuthConfig } from '../config/unified.config';
+
// Mock OAuth configuration
-const GOOGLE_CLIENT_ID = 'mock_google_client_id';
-const GITHUB_CLIENT_ID = 'mock_github_client_id';
+const { google, github } = getOAuthConfig();
+const GOOGLE_CLIENT_ID = google?.clientId || 'mock_google_client_id';
+const GITHUB_CLIENT_ID = github?.clientId || 'mock_github_client_id';
// Mock OAuth endpoints
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
@@ -17,7 +20,8 @@ const GOOGLE_SCOPES = 'openid email profile';
const GITHUB_SCOPES = 'user:email';
// Mock redirect URI
-const REDIRECT_URI = 'http://localhost:3000/auth/callback';
+const APP_BASE_URL = getAppConfig().baseUrl;
+const REDIRECT_URI = `${APP_BASE_URL.replace(/\/$/, '')}/auth/callback`;
// Mock OAuth state generation
const generateState = () => crypto.randomUUID();
diff --git a/tests/README.md b/tests/README.md
index c6537d6..d058183 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -25,7 +25,9 @@ services/
└── auth/
└── __tests__/ # Unit tests for authentication services
├── auth.integration.test.ts
- └── emailVerification.test.ts
+ ├── emailVerification.test.ts
+ └── integration/
+ └── token.service.integration.test.ts
```
## Running Tests
@@ -68,6 +70,79 @@ bun run test:e2e:debug
bun run test:e2e:report
```
+#### TokenService CouchDB Integration
+
+##### Using Docker Compose locally
+
+You can spin up a local CouchDB for integration tests using the included docker-compose file:
+
+```bash
+docker compose -f meds/docker-compose.ci.yml up -d couchdb
+
+export VITE_COUCHDB_URL=http://localhost:5984
+export VITE_COUCHDB_USER=admin
+export VITE_COUCHDB_PASSWORD=password
+
+bun run test:integration
+```
+
+- The compose file is intended for local/CI testing only.
+- Data is stored in a named volume (couchdb-test-data) and can be removed with:
+ - docker compose -f meds/docker-compose.ci.yml down -v
+
+##### Optional CI workflow
+
+An optional GitHub Actions workflow exists to run these CouchDB-backed tests:
+
+- Name: Integration Tests (CouchDB)
+- Triggers:
+ - Manually via “Run workflow” (workflow_dispatch), or
+ - Automatically if repository variable RUN_COUCHDB_INTEGRATION is set to "true"
+- It provisions a CouchDB service and runs:
+ - bun run test:integration
+
+To enable automatic runs, set the repository variable RUN_COUCHDB_INTEGRATION to "true" in your repo settings. You can also override credentials via:
+
+- VITE_COUCHDB_URL (defaults to http://couchdb:5984 in CI)
+- VITE_COUCHDB_USER (defaults to admin)
+- VITE_COUCHDB_PASSWORD (defaults to password if not set as a secret)
+
+Integration tests for the TokenService talk to a live CouchDB if available. They automatically skip when CouchDB is not reachable.
+
+```bash
+# Run TokenService integration tests (requires CouchDB at VITE_COUCHDB_URL)
+# Defaults: VITE_COUCHDB_URL=http://localhost:5984, VITE_COUCHDB_USER=admin, VITE_COUCHDB_PASSWORD=password
+VITE_COUCHDB_URL=http://localhost:5984 \
+VITE_COUCHDB_USER=admin \
+VITE_COUCHDB_PASSWORD=password \
+bun run test meds/services/auth/__tests__/integration/token.service.integration.test.ts
+```
+
+- Provide your own CouchDB credentials via environment variables if different from the defaults above.
+- These tests will provision an `auth_tokens` database automatically and clean up test data as part of the lifecycle.
+
+#### OAuth Redirect Configuration
+
+OAuth redirect URIs are derived from the unified configuration:
+
+- Redirect URI = `${APP_BASE_URL}/auth/callback`
+- Development default: `APP_BASE_URL=http://localhost:5173`
+- Test default: `APP_BASE_URL=http://localhost:3000`
+
+To exercise OAuth URL construction with your own IDs, set:
+
+- `VITE_GOOGLE_CLIENT_ID`
+- `VITE_GITHUB_CLIENT_ID`
+
+Example:
+
+```bash
+APP_BASE_URL=http://localhost:5173 \
+VITE_GOOGLE_CLIENT_ID=your-google-client-id \
+VITE_GITHUB_CLIENT_ID=your-github-client-id \
+bun run dev
+```
+
### Manual Testing Scripts
#### Admin Login Debug
@@ -122,7 +197,7 @@ bun tests/manual/auth-db-debug.js
### Jest Configuration (`jest.config.json`)
-- TypeScript support with ts-jest
+- TypeScript transformed via babel-jest (@babel/preset-typescript); import.meta handled in Babel config
- jsdom environment for DOM testing
- Coverage reporting
- Module path mapping
diff --git a/types/express.d.ts b/types/express.d.ts
new file mode 100644
index 0000000..29e2f29
--- /dev/null
+++ b/types/express.d.ts
@@ -0,0 +1,28 @@
+/**
+ * Express Request type augmentation
+ *
+ * Adds a `user` property to `Express.Request` to store authenticated user
+ * information attached by authentication middleware.
+ *
+ * The `user` shape is flexible to accommodate different token verification
+ * outputs. When using our JWT-based middleware, it will typically match
+ * `TokenPayload`, but we also allow `string` or a generic record for
+ * compatibility with other strategies.
+ */
+
+import type { TokenPayload } from '../services/auth/auth.types';
+
+declare global {
+ namespace Express {
+ interface Request {
+ /**
+ * Authenticated user payload attached by middleware.
+ * - Usually matches TokenPayload in this project.
+ * - May be a string or a generic object when using different verifiers.
+ */
+ user?: TokenPayload | string | Record;
+ }
+ }
+}
+
+export {};