db(couchdb): auto-provision databases on startup for production strategy; add TokenService with CouchDB-backed token storage and localStorage fallback; switch OAuth to unified config for client IDs and redirect URI; express Request typing for req.user; align exportAsEnvVars with show-config expectations; remove Vite importmap from index.html; prefer babel-jest over ts-jest; remove duplicate uuid mocking from Jest config

This commit is contained in:
William Valentin
2025-09-09 12:30:38 -07:00
parent 062e0973c1
commit 15170a4f43
17 changed files with 1097 additions and 67 deletions

View File

@@ -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<string, string> | undefined;
const method = init?.method || 'GET';
const body = init?.body as string | undefined;
return await new Promise<Response>((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<string, string>();
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<boolean> => {
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
);
});

View File

@@ -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,

View File

@@ -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<AuthenticatedUser | null> {
// 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<boolean> {

View File

@@ -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<void> {
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<T>(
method: string,
path: string,
body?: unknown
): Promise<T> {
if (!this.couchBaseUrl || !this.couchAuthHeader) {
throw new Error('CouchDB not configured');
}
const url = `${this.couchBaseUrl}${path}`;
const headers: Record<string, string> = {
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<T>(key: string): T[] {
if (typeof localStorage === 'undefined') return [];
try {
return JSON.parse(localStorage.getItem(key) || '[]') as T[];
} catch {
return [];
}
}
private setLocalArray<T>(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<void> {
if (this.usingCouch) {
await this.ensureDatabase();
const docId = `ver-${token.token}`;
let existing: TokenDoc | null = null;
try {
existing = await this.makeCouchRequest<TokenDoc>(
'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<EmailVerificationToken>(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<EmailVerificationToken | null> {
if (this.usingCouch) {
await this.ensureDatabase();
const docId = `ver-${token}`;
try {
const doc = await this.makeCouchRequest<TokenDoc>(
'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<EmailVerificationToken>(LS_VERIFICATION_KEY);
const found = list.find(t => t.token === token);
return found
? {
...found,
expiresAt: fromISO(found.expiresAt),
}
: null;
}
async deleteVerificationTokensForUser(userId: string): Promise<void> {
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<EmailVerificationToken>(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<void> {
if (this.usingCouch) {
await this.ensureDatabase();
const docId = `rst-${token.token}`;
let existing: TokenDoc | null = null;
try {
existing = await this.makeCouchRequest<TokenDoc>(
'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<PasswordResetToken>(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<PasswordResetToken | null> {
if (this.usingCouch) {
await this.ensureDatabase();
const docId = `rst-${token}`;
try {
const doc = await this.makeCouchRequest<TokenDoc>(
'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<PasswordResetToken>(LS_RESET_KEY);
const found = list.find(t => t.token === token);
return found
? {
...found,
expiresAt: fromISO(found.expiresAt),
}
: null;
}
async deletePasswordResetToken(token: string): Promise<void> {
if (this.usingCouch) {
await this.ensureDatabase();
const docId = `rst-${token}`;
// Need current _rev to delete
try {
const doc = await this.makeCouchRequest<TokenDoc>(
'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<PasswordResetToken>(LS_RESET_KEY);
const filtered = list.filter(t => t.token !== token);
this.setLocalArray(LS_RESET_KEY, filtered);
}
async deletePasswordResetTokensForUser(userId: string): Promise<void> {
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<PasswordResetToken>(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<number> {
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<EmailVerificationToken>(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<PasswordResetToken>(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;

View File

@@ -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<void> {

View File

@@ -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();