@ -60,6 +60,7 @@ import {
import { pullMonthlyCosts } from './aws-costs'
import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability , duesAging , module Revenue , type DateRange } from './repos-reports'
import { clientProfitability , duesAging , module Revenue , type DateRange } from './repos-reports'
import { listUsageRateCards , setUsageRateCard } from './repos-rate-card'
import { listUsageRateCards , setUsageRateCard } from './repos-rate-card'
import { makeRateLimiter } from './rate-limit'
import {
import {
addProjectMilestone , ensureProjectMilestones , listProjectMilestones , milestoneBoard ,
addProjectMilestone , ensureProjectMilestones , listProjectMilestones , milestoneBoard ,
projectsPendingMilestone , setMilestone ,
projectsPendingMilestone , setMilestone ,
@ -107,12 +108,42 @@ export function apiRouter(
r . get ( '/health' , ( _req , res ) = > {
r . get ( '/health' , ( _req , res ) = > {
res . json ( { ok : true , service : 'sims-hq' , version : '0.1.0' } )
res . json ( { ok : true , service : 'sims-hq' , version : '0.1.0' } )
} )
} )
// D24 (red-team): brute-force defense. Two fixed-window limiters — per client IP and
// per target email — layered over the persistent per-account lockout in login(). The
// per-email bucket trips even when an attacker rotates source IPs; the per-IP bucket
// stops one host hammering many accounts. login() itself is constant-time.
const loginIpLimit = makeRateLimiter ( 20 , 60 _000 ) // 20 attempts / IP / minute
const loginEmailLimit = makeRateLimiter ( 8 , 15 * 60 _000 ) // 8 attempts / email / 15 min
// D24 (red-team): cap credential reveals per user so one insider account cannot silently
// bulk-decrypt every client's banking DB password / portal secret. Every reveal is also
// audited; this bounds the burst. Generous for legitimate support use.
const revealLimit = makeRateLimiter ( 20 , 60 _000 ) // 20 reveals / user / minute
const revealGuard = ( res : Response ) : boolean = > {
const ok = revealLimit ( ( res . locals [ 'staff' ] as { id : string } ) . id )
if ( ! ok ) res . status ( 429 ) . json ( { ok : false , error : 'Too many reveals in a short time. Please slow down.' } )
return ok
}
r . post ( '/auth/login' , async ( req , res ) = > {
r . post ( '/auth/login' , async ( req , res ) = > {
try {
try {
const { email , password } = req . body as { email? : string ; password? : string }
const { email , password } = req . body as { email? : string ; password? : string }
const out = typeof email === 'string' && typeof password === 'string' ? await login ( db , email , password ) : null
const ip = req . ip ? ? 'unknown'
if ( ! out ) { res . status ( 401 ) . json ( { ok : false , error : 'Invalid email or password' } ) ; return }
if ( ! loginIpLimit ( ip ) ) {
res . json ( { ok : true , . . . out } )
res . status ( 429 ) . json ( { ok : false , error : 'Too many attempts. Please wait a minute and try again.' } ) ; return
}
if ( typeof email !== 'string' || typeof password !== 'string' ) {
res . status ( 401 ) . json ( { ok : false , error : 'Invalid email or password' } ) ; return
}
if ( ! loginEmailLimit ( email . toLowerCase ( ) ) ) {
res . status ( 429 ) . json ( { ok : false , error : 'Too many attempts for this account. Please try again later.' } ) ; return
}
const out = await login ( db , email , password )
if ( ! out . ok ) {
const msg = out . reason === 'locked'
? 'This account is temporarily locked after too many failed attempts. Try again in 15 minutes.'
: 'Invalid email or password'
res . status ( 401 ) . json ( { ok : false , error : msg } ) ; return
}
res . json ( { ok : true , token : out.token , staff : out.staff } )
} catch ( err ) {
} catch ( err ) {
res . status ( 400 ) . json ( { ok : false , error : err instanceof Error ? err.message : String ( err ) } )
res . status ( 400 ) . json ( { ok : false , error : err instanceof Error ? err.message : String ( err ) } )
}
}
@ -242,6 +273,7 @@ export function apiRouter(
if ( ! isManagerial ( viewer . role ) ) {
if ( ! isManagerial ( viewer . role ) ) {
res . status ( 403 ) . json ( { ok : false , error : 'Owner or manager only' } ) ; return
res . status ( 403 ) . json ( { ok : false , error : 'Owner or manager only' } ) ; return
}
}
if ( ! revealGuard ( res ) ) return
const id = String ( req . params [ 'id' ] ? ? '' )
const id = String ( req . params [ 'id' ] ? ? '' )
if ( ( await getClient ( db , id ) ) === null ) {
if ( ( await getClient ( db , id ) ) === null ) {
res . status ( 404 ) . json ( { ok : false , error : 'Client not found' } ) ; return
res . status ( 404 ) . json ( { ok : false , error : 'Client not found' } ) ; return
@ -585,8 +617,12 @@ export function apiRouter(
if ( ! isManagerial ( viewer . role ) ) {
if ( ! isManagerial ( viewer . role ) ) {
res . status ( 403 ) . json ( { ok : false , error : 'Owner or manager only' } ) ; return
res . status ( 403 ) . json ( { ok : false , error : 'Owner or manager only' } ) ; return
}
}
if ( ! revealGuard ( res ) ) return
try {
try {
const id = String ( req . params [ 'id' ] ? ? '' )
const id = String ( req . params [ 'id' ] ? ? '' )
if ( ( await getClientModule ( db , id ) ) === null ) {
res . status ( 404 ) . json ( { ok : false , error : 'Client module not found' } ) ; return
}
res . json ( { ok : true , password : await revealModulePassword ( db , viewer . id , id , gmail ( ) . keyHex ) } )
res . json ( { ok : true , password : await revealModulePassword ( db , viewer . id , id , gmail ( ) . keyHex ) } )
} catch ( err ) {
} catch ( err ) {
res . status ( 400 ) . json ( { ok : false , error : err instanceof Error ? err.message : String ( err ) } )
res . status ( 400 ) . json ( { ok : false , error : err instanceof Error ? err.message : String ( err ) } )
@ -624,6 +660,7 @@ export function apiRouter(
if ( ! isManagerial ( viewer . role ) ) {
if ( ! isManagerial ( viewer . role ) ) {
res . status ( 403 ) . json ( { ok : false , error : 'Owner or manager only' } ) ; return
res . status ( 403 ) . json ( { ok : false , error : 'Owner or manager only' } ) ; return
}
}
if ( ! revealGuard ( res ) ) return
try {
try {
const key = ( req . body as { key? : unknown } ) . key
const key = ( req . body as { key? : unknown } ) . key
if ( typeof key !== 'string' ) throw new Error ( 'key must be a string' )
if ( typeof key !== 'string' ) throw new Error ( 'key must be a string' )