import fs from 'fs'; import path from 'path'; import { randomUUID } from 'crypto'; const DATA_DIR = path.join( __dirname, '..', '..', 'build', 'data' ); if ( !fs.existsSync( DATA_DIR ) ) fs.mkdirSync( DATA_DIR, { recursive: true } ); export interface UserProduct { id: string; acquiredAt: string; source: string; } export interface User { id: string; email: string; passwordHash: string; roles: string[]; products: UserProduct[]; settings: Record; createdAt: string; } export interface RefreshToken { token: string; userId: string; expiresAt: string; usedAt?: string; replacedBy?: string; } export interface ResetToken { token: string; userId: string; expiresAt: string; } function read( table: string ): T[] { const file = path.join( DATA_DIR, `${table}.json` ); if ( !fs.existsSync( file ) ) return []; return JSON.parse( fs.readFileSync( file, 'utf8' ) ) as T[]; } function write( table: string, rows: T[] ): void { fs.writeFileSync( path.join( DATA_DIR, `${table}.json` ), JSON.stringify( rows, null, 2 ) ); } export const users = { all: (): User[] => read( 'users' ), findById: ( id: string ): User | undefined => read( 'users' ).find( u => u.id === id ), findByEmail: ( email: string ): User | undefined => read( 'users' ).find( u => u.email.toLowerCase() === email.toLowerCase() ), create( email: string, passwordHash: string ): User { const rows = read( 'users' ); if ( rows.find( u => u.email.toLowerCase() === email.toLowerCase() ) ) throw new Error( 'Taken' ); const user: User = { id: randomUUID(), email, passwordHash, roles: [ 'user' ], products: [], settings: {}, createdAt: new Date().toISOString() }; write( 'users', [ ...rows, user ] ); return user; }, update( id: string, patch: Partial> ): void { const rows = read( 'users' ).map( u => u.id === id ? { ...u, ...patch } : u ); write( 'users', rows ); }, delete( id: string ): void { write( 'users', read( 'users' ).filter( u => u.id !== id ) ); } }; export const refreshTokens = { find: ( token: string ): RefreshToken | undefined => read( 'refreshTokens' ).find( t => t.token === token ), create( userId: string, ttlMs: number = 30 * 24 * 60 * 60 * 1000 ): RefreshToken { const token: RefreshToken = { token: randomUUID(), userId, expiresAt: new Date( Date.now() + ttlMs ).toISOString() }; // Prune rows that are fully expired — used-and-superseded rows are kept // (their expiresAt is unchanged) so grace-window lookups still find them. const rows = read( 'refreshTokens' ).filter( t => new Date( t.expiresAt ) >= new Date() ); write( 'refreshTokens', [ ...rows, token ] ); return token; }, // Marks a token as rotated instead of deleting it, so a near-simultaneous // second refresh call using the same token can still be resolved to the // replacement pair within the grace window (see REFRESH_GRACE_MS in auth.ts). markUsed( token: string, replacedBy: string ): void { const rows = read( 'refreshTokens' ).map( t => t.token === token ? { ...t, usedAt: new Date().toISOString(), replacedBy } : t ); write( 'refreshTokens', rows ); }, delete( token: string ): void { write( 'refreshTokens', read( 'refreshTokens' ).filter( t => t.token !== token ) ); }, deleteForUser( userId: string ): void { write( 'refreshTokens', read( 'refreshTokens' ).filter( t => t.userId !== userId ) ); } }; export const resetTokens = { find: ( token: string ): ResetToken | undefined => read( 'resetTokens' ).find( t => t.token === token ), create( userId: string ): ResetToken { const token: ResetToken = { token: randomUUID(), userId, expiresAt: new Date( Date.now() + 60 * 60 * 1000 ).toISOString() }; const existing = read( 'resetTokens' ).filter( t => t.userId !== userId ); write( 'resetTokens', [ ...existing, token ] ); return token; }, delete( token: string ): void { write( 'resetTokens', read( 'resetTokens' ).filter( t => t.token !== token ) ); }, deleteForUser( userId: string ): void { write( 'resetTokens', read( 'resetTokens' ).filter( t => t.userId !== userId ) ); } };